diff --git a/.changeset/chat-instructions-from-history.md b/.changeset/chat-instructions-from-history.md new file mode 100644 index 000000000..fa1d2ef76 --- /dev/null +++ b/.changeset/chat-instructions-from-history.md @@ -0,0 +1,6 @@ +--- +"@truefoundry/trueforge": patch +"@truefoundry/trueforge-ui": patch +--- + +Draft system instructions from an existing chat, then let the user edit before applying diff --git a/packages/trueforge-ui/src/atoms/GenerateInstructionsButton.tsx b/packages/trueforge-ui/src/atoms/GenerateInstructionsButton.tsx new file mode 100644 index 000000000..5434440ee --- /dev/null +++ b/packages/trueforge-ui/src/atoms/GenerateInstructionsButton.tsx @@ -0,0 +1,254 @@ +'use client'; + +import { + useTrueFoundryAdoptAgentSpec, + useTrueFoundryAgentSpec, + useTrueFoundryFlushAgentSpec, +} from '@truefoundry/assistant-ui-runtime'; +import { useEffect, useId, useRef, useState } from 'react'; +import { useAuiState } from '../assistant-ui.js'; +import { Icon } from '../icons/Icon.js'; +import { hasGenerateInstructionsFromChat } from '../server/generateInstructionsFromChat.js'; +import { useOptionalServer } from '../server/ServerContext.js'; +import { useOptionalShellMode } from '../server/ShellModeContext.js'; +import { getErrorMessage } from '../utils/getErrorMessage.js'; +import { auiButtonClass } from './lib/buttonClasses.js'; +import { auiInputClass } from './lib/inputClasses.js'; +import { CenteredModal } from './primitives/CenteredModal.js'; + +export type GenerateInstructionsButtonProps = { + disabled?: boolean; + className?: string; +}; + +export function useGenerateInstructionsVisible(): boolean { + const shell = useOptionalShellMode(); + const server = useOptionalServer(); + const remoteId = useAuiState(s => s.threadListItem.remoteId); + if (shell == null || shell.mode.status !== 'active') return false; + if (remoteId == null || remoteId.length === 0) return false; + return hasGenerateInstructionsFromChat(server); +} + +export function GenerateInstructionsButton({ disabled = false, className }: GenerateInstructionsButtonProps) { + const visible = useGenerateInstructionsVisible(); + if (!visible) return null; + return ; +} + +function GenerateInstructionsButtonContent({ disabled, className }: { disabled: boolean; className?: string }) { + const server = useOptionalServer(); + const shell = useOptionalShellMode(); + const remoteId = useAuiState(s => s.threadListItem.remoteId); + const { agentSpec } = useTrueFoundryAgentSpec(); + const agentSpecRef = useRef(agentSpec); + agentSpecRef.current = agentSpec; + const flushAgentSpec = useTrueFoundryFlushAgentSpec(); + const adoptAgentSpec = useTrueFoundryAdoptAgentSpec(); + const titleId = useId(); + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [applying, setApplying] = useState(false); + const [draft, setDraft] = useState(''); + const [sources, setSources] = useState>([]); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + const errorRef = useRef(null); + const inFlightRef = useRef(false); + const applyingRef = useRef(false); + const canApply = shell?.mode.status === 'active' && shell.mode.isMutable && agentSpec != null; + + useEffect(() => { + if (error === null) return; + errorRef.current?.scrollIntoView?.({ block: 'nearest', behavior: 'smooth' }); + }, [error]); + + const close = () => { + if (loading || applying) return; + setOpen(false); + setDraft(''); + setSources([]); + setError(null); + setCopied(false); + }; + + const generate = async () => { + if (!hasGenerateInstructionsFromChat(server) || remoteId == null || inFlightRef.current) return; + inFlightRef.current = true; + setLoading(true); + setError(null); + setCopied(false); + try { + const result = await server.generateInstructionsFromChat({ sessionId: remoteId }); + setDraft(result.instructions); + setSources(result.sources); + } catch (caught) { + setDraft(''); + setSources([]); + setError(getErrorMessage(caught, 'Could not generate instructions from this chat')); + } finally { + inFlightRef.current = false; + setLoading(false); + } + }; + + const show = () => { + if (inFlightRef.current || applyingRef.current || loading || applying) return; + setOpen(true); + void generate(); + }; + + const apply = async () => { + if ( + !canApply || + agentSpecRef.current === null || + remoteId == null || + !hasGenerateInstructionsFromChat(server) || + applyingRef.current + ) { + return; + } + const instructions = draft.trim(); + if (instructions.length === 0) return; + applyingRef.current = true; + setApplying(true); + setError(null); + try { + await flushAgentSpec(); + const latest = agentSpecRef.current; + if (latest === null) return; + const next = { ...latest, instructions }; + const updated = await server.updateSession({ sessionId: remoteId, agentSpec: next }); + adoptAgentSpec({ agentSpec: next, updatedAt: updated.updatedAt }); + setOpen(false); + setDraft(''); + setSources([]); + } catch (caught) { + setError(getErrorMessage(caught, 'Could not apply instructions to this chat')); + } finally { + applyingRef.current = false; + setApplying(false); + } + }; + + const copyDraft = async () => { + if (draft.trim().length === 0 || typeof navigator === 'undefined' || navigator.clipboard == null) return; + await navigator.clipboard.writeText(draft); + setCopied(true); + }; + + return ( + <> + + + !next && close()} + title="Instructions from this chat" + className="md:h-auto md:max-h-[85dvh] md:max-w-2xl" + aria-label="Instructions from this chat" + > +
+
+

+ This is a draft from the conversation. Edit it before it becomes this chat's system instructions. + Nothing is saved until you apply it. +

+ +