Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/chat-instructions-from-history.md
Original file line number Diff line number Diff line change
@@ -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
254 changes: 254 additions & 0 deletions packages/trueforge-ui/src/atoms/GenerateInstructionsButton.tsx
Original file line number Diff line number Diff line change
@@ -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 <GenerateInstructionsButtonContent disabled={disabled} className={className} />;
}

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<Array<{ turnId: string; role: 'user' | 'assistant'; excerpt: string }>>([]);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const errorRef = useRef<HTMLParagraphElement>(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();
};
Comment thread
cursor[bot] marked this conversation as resolved.

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);
}
};
Comment thread
cursor[bot] marked this conversation as resolved.

const copyDraft = async () => {
if (draft.trim().length === 0 || typeof navigator === 'undefined' || navigator.clipboard == null) return;
await navigator.clipboard.writeText(draft);
setCopied(true);
};

return (
<>
<button
type="button"
disabled={disabled || loading || applying || open}
className={auiButtonClass({ variant: 'ghost', size: 'sm', className })}
onClick={show}
>
<Icon name="lightbulb" className="size-3.5" />
From chat
</button>

<CenteredModal
open={open}
onOpenChange={next => !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"
>
<div className="flex min-h-0 w-full flex-1 flex-col">
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-3">
<p className="text-text-secondary mb-3 text-sm">
This is a draft from the conversation. Edit it before it becomes this chat&apos;s system instructions.
Nothing is saved until you apply it.
</p>

<label className="mb-3 block" htmlFor={titleId}>
<span className="mb-1.5 block text-sm font-medium">Suggested instructions</span>
<textarea
id={titleId}
value={draft}
disabled={loading || applying}
onChange={event => setDraft(event.target.value)}
rows={8}
placeholder={loading ? 'Reading this chat…' : 'No suggestion yet.'}
className={auiInputClass('resize-y py-2 disabled:opacity-60')}
/>
</label>

{sources.length > 0 ? (
<div className="mb-3">
<p className="text-text-secondary mb-1.5 text-xs font-semibold tracking-wide uppercase">
Inferred from
</p>
<ul className="space-y-1.5">
{sources.map(source => (
<li
key={`${source.turnId}-${source.role}-${source.excerpt}`}
className="text-text-secondary text-xs"
>
<span className="text-text-primary font-medium">{source.role}</span>
{': '}
{source.excerpt}
</li>
))}
</ul>
</div>
) : null}

{canApply ? null : (
<p className="text-text-secondary mb-2 text-xs">
This chat is bound to a saved agent, so the draft is not applied here. Copy it, or save a new agent.
</p>
)}

{error ? (
<p
ref={errorRef}
role="alert"
className="text-failure-bg mt-2 text-sm wrap-break-word whitespace-pre-wrap"
>
{error}
</p>
) : null}
</div>

<div className="bg-card-bg sticky bottom-0 z-10 flex shrink-0 flex-wrap justify-end gap-2 border-t border-border px-5 py-4">
<button
type="button"
disabled={loading || applying}
className={auiButtonClass({ variant: 'secondary' })}
onClick={close}
>
Cancel
</button>
<button
type="button"
disabled={loading || applying || draft.trim().length === 0}
className={auiButtonClass({ variant: 'secondary' })}
onClick={() => void copyDraft()}
>
{copied ? 'Copied' : 'Copy'}
</button>
{canApply ? (
<button
type="button"
disabled={loading || applying || draft.trim().length === 0}
className={auiButtonClass({ variant: 'default' })}
onClick={() => void apply()}
>
{applying ? 'Applying…' : 'Apply to this chat'}
</button>
) : null}
</div>
</div>
</CenteredModal>
</>
);
}

declare module '../theme/SlotsProvider.js' {
interface AtomSlots {
GenerateInstructionsButton: typeof GenerateInstructionsButton;
}
}
2 changes: 2 additions & 0 deletions packages/trueforge-ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,8 @@ export { DraftCompositeSelector } from './atoms/draft/DraftCompositeSelector.js'
export type { DraftCompositeSelectorProps } from './atoms/draft/DraftCompositeSelector.js';
export { DraftModelSelector } from './atoms/draft/DraftModelSelector.js';
export type { DraftModelSelectorProps } from './atoms/draft/DraftModelSelector.js';
export { GenerateInstructionsButton } from './atoms/GenerateInstructionsButton.js';
export type { GenerateInstructionsButtonProps } from './atoms/GenerateInstructionsButton.js';
export { SaveAgentButton } from './atoms/SaveAgentButton.js';
export type { SaveAgentButtonProps } from './atoms/SaveAgentButton.js';
export { SaveAgentForm } from './atoms/SaveAgentForm.js';
Expand Down
2 changes: 2 additions & 0 deletions packages/trueforge-ui/src/layouts/DrawerLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export function DrawerLayout({ className }: { className?: string }) {
const shell = useOptionalShellMode();
const isMobile = useIsMobile();
const ClearChatButton = useSlot('ClearChatButton');
const GenerateInstructionsButton = useSlot('GenerateInstructionsButton');
const AgentDetailsPage = useSlot('AgentDetailsPage');
const AgentsLibrary = useSlot('AgentsLibrary');
const SessionsPage = useSlot('SessionsPage');
Expand Down Expand Up @@ -122,6 +123,7 @@ export function DrawerLayout({ className }: { className?: string }) {
{!overlayOpen ? (
<>
<ClearChatButton />
<GenerateInstructionsButton />
<SaveAgentButton />
</>
) : null}
Expand Down
2 changes: 2 additions & 0 deletions packages/trueforge-ui/src/layouts/SidebarLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ export function SidebarLayout({ className }: { className?: string }) {
const AgentsLibrary = useSlot('AgentsLibrary');
const SessionsPage = useSlot('SessionsPage');
const ClearChatButton = useSlot('ClearChatButton');
const GenerateInstructionsButton = useSlot('GenerateInstructionsButton');
const SaveAgentButton = useSlot('SaveAgentButton');
const SelectAgentEmptyState = useSlot('SelectAgentEmptyState');
const [mobileNavOpen, setMobileNavOpen] = useState(false);
Expand Down Expand Up @@ -250,6 +251,7 @@ export function SidebarLayout({ className }: { className?: string }) {
<NamedAgentHeaderLabel />
<span className="min-w-0 flex-1" />
<ClearChatButton />
<GenerateInstructionsButton />
<SaveAgentButton />
</>
) : (
Expand Down
2 changes: 2 additions & 0 deletions packages/trueforge-ui/src/layouts/StackChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export function StackChatPanel({ className, threadHeaderEnd }: StackChatPanelPro
const aui = useAui();
const shell = useOptionalShellMode();
const ClearChatButton = useSlot('ClearChatButton');
const GenerateInstructionsButton = useSlot('GenerateInstructionsButton');
const AgentDetailsPage = useSlot('AgentDetailsPage');
const AgentsLibrary = useSlot('AgentsLibrary');
const SessionsPage = useSlot('SessionsPage');
Expand Down Expand Up @@ -149,6 +150,7 @@ export function StackChatPanel({ className, threadHeaderEnd }: StackChatPanelPro
{threadHeaderEnd}
<span className="min-w-0 flex-1" />
<ClearChatButton />
<GenerateInstructionsButton />
<SaveAgentButton />
</header>
<div className="min-h-0 flex-1">{isIdle ? <SelectAgentEmptyState /> : <Thread />}</div>
Expand Down
Loading