Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 4 additions & 1 deletion src/assistant/AssistantDock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,8 @@ export function AssistantDock({
renderConfirmedResult,
renderTranscript,
}: AssistantDockProps) {
const { open, openAssistant, closeAssistant } = useAssistantLauncher();
const { open, openAssistant, closeAssistant, seed, clearSeed } =
useAssistantLauncher();
const chat = useAssistantChat(userId, {
onWorkflowMutation,
onConnectRequirement,
Expand Down Expand Up @@ -214,6 +215,8 @@ export function AssistantDock({
toolRenderers={toolRenderers}
renderConfirmedResult={renderConfirmedResult}
renderTranscript={renderTranscript}
composerSeed={seed}
onComposerSeedApplied={clearSeed}
/>
{isDesktop && (
<ResizeHandle
Expand Down
35 changes: 35 additions & 0 deletions src/assistant/AssistantPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -704,3 +704,38 @@ describe("AssistantPanel model selection display", () => {
expect(chat.setModel).not.toHaveBeenCalled();
});
});

describe("AssistantPanel composer seed", () => {
it("forwards composerSeed into the composer and reports it applied", async () => {
const onComposerSeedApplied = vi.fn();
const { rerender } = render(
<AssistantClientProvider client={client}>
<AssistantPanel
chat={makeChat()}
userId="u1"
onClose={() => {}}
composerSeed={null}
onComposerSeedApplied={onComposerSeedApplied}
/>
</AssistantClientProvider>,
);

rerender(
<AssistantClientProvider client={client}>
<AssistantPanel
chat={makeChat()}
userId="u1"
onClose={() => {}}
composerSeed="Draft this workflow"
onComposerSeedApplied={onComposerSeedApplied}
/>
</AssistantClientProvider>,
);

const input = (await screen.findByLabelText(
"Message input",
)) as HTMLTextAreaElement;
expect(input.value).toBe("Draft this workflow");
expect(onComposerSeedApplied).toHaveBeenCalledOnce();
});
});
10 changes: 10 additions & 0 deletions src/assistant/AssistantPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ export interface AssistantPanelProps {
* absent, the built-in transcript (web-react `ChatMessages`) renders the
* conversation. */
renderTranscript?: (view: AssistantTranscriptView) => ReactNode;
/** One-shot composer prefill from the host's launcher (e.g. a page's "Create
* with assistant" button passing `openAssistant(seed)`). The panel adopts it
* as the composer draft and calls `onComposerSeedApplied` once, so the host
* clears its seed state (consume-once). */
composerSeed?: string | null;
onComposerSeedApplied?: () => void;
}

const EMPTY_STATE =
Expand Down Expand Up @@ -159,6 +165,8 @@ export function AssistantPanel({
toolRenderers,
renderConfirmedResult,
renderTranscript,
composerSeed = null,
onComposerSeedApplied,
}: AssistantPanelProps) {
const models = useAssistantModels();
const threads = useAssistantThreads(userId);
Expand Down Expand Up @@ -591,6 +599,8 @@ export function AssistantPanel({
isStreaming={streaming}
disabled={chat.restoring || state.status === "awaiting_confirm"}
placeholder="Message the assistant…"
seed={composerSeed}
onSeedApplied={onComposerSeedApplied}
controls={
pickerModels.length > 0 ? (
<ModelPicker
Expand Down
93 changes: 93 additions & 0 deletions src/web-react/chat-composer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -187,3 +187,96 @@ describe('ModelPicker priorityGroup', () => {
expect(screen.getAllByText('My Fine-Tune')).toHaveLength(1)
})
})

describe('ChatComposer seed', () => {
it('adopts a seed as the draft, focuses the input, and reports consumption', () => {
const onSeedApplied = vi.fn()
const { rerender } = render(
<ChatComposer onSend={vi.fn()} seed={null} onSeedApplied={onSeedApplied} />,
)
const input = screen.getByLabelText('Message input') as HTMLTextAreaElement

rerender(
<ChatComposer
onSend={vi.fn()}
seed="Build a workflow that uses `github.issues.create` to "
onSeedApplied={onSeedApplied}
/>,
)

expect(input.value).toBe('Build a workflow that uses `github.issues.create` to ')
expect(onSeedApplied).toHaveBeenCalledOnce()
expect(document.activeElement).toBe(input)
// Caret must land at the END of the seeded text, positioned after the
// seeded value reached the DOM (not clamped against the pre-seed value).
expect(input.selectionStart).toBe(input.value.length)
expect(input.selectionEnd).toBe(input.value.length)
})

it('applies a second seed after the first is cleared, replacing the draft', () => {
const onSeedApplied = vi.fn()
const { rerender } = render(
<ChatComposer onSend={vi.fn()} seed="first " onSeedApplied={onSeedApplied} />,
)
const input = screen.getByLabelText('Message input') as HTMLTextAreaElement
type(input, 'first plus edits')

rerender(<ChatComposer onSend={vi.fn()} seed={null} onSeedApplied={onSeedApplied} />)
rerender(<ChatComposer onSend={vi.fn()} seed="second " onSeedApplied={onSeedApplied} />)

expect(input.value).toBe('second ')
expect(onSeedApplied).toHaveBeenCalledTimes(2)
})

it('ignores the seed in controlled mode (the host drives value)', () => {
const onSeedApplied = vi.fn()
const onValueChange = vi.fn()
const { rerender } = render(
<ChatComposer
onSend={vi.fn()}
value="host text"
onValueChange={onValueChange}
seed={null}
onSeedApplied={onSeedApplied}
/>,
)
const input = screen.getByLabelText('Message input') as HTMLTextAreaElement

rerender(
<ChatComposer
onSend={vi.fn()}
value="host text"
onValueChange={onValueChange}
seed="seeded "
onSeedApplied={onSeedApplied}
/>,
)

// A controlled value is not clobbered, and consume-once does not fire —
// consistent with `initialValue` being ignored in controlled mode.
expect(input.value).toBe('host text')
expect(onSeedApplied).not.toHaveBeenCalled()
expect(onValueChange).not.toHaveBeenCalled()
})

it('positions the caret even when the seed equals the current text', () => {
const onSeedApplied = vi.fn()
const { rerender } = render(
<ChatComposer onSend={vi.fn()} seed={null} onSeedApplied={onSeedApplied} />,
)
const input = screen.getByLabelText('Message input') as HTMLTextAreaElement
// The user types the exact string the host is about to seed.
type(input, 'same text')

rerender(
<ChatComposer onSend={vi.fn()} seed="same text" onSeedApplied={onSeedApplied} />,
)

// setText is a no-op (value unchanged), but the caret is still placed at the
// end and consume-once still fires — no stranded pending-caret state.
expect(input.value).toBe('same text')
expect(onSeedApplied).toHaveBeenCalledOnce()
expect(document.activeElement).toBe(input)
expect(input.selectionStart).toBe(input.value.length)
})
})
51 changes: 51 additions & 0 deletions src/web-react/chat-composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,13 @@ export interface ChatComposerProps {
/** Initial text in uncontrolled mode; ignored when `value` is provided. */
initialValue?: string

/** One-shot external prefill: when this becomes a non-null string the
* composer adopts it as the draft (replacing any current draft), focuses the
* input with the caret at the end, and reports consumption via
* `onSeedApplied` so the host can clear its seed state. */
seed?: string | null
onSeedApplied?: () => void

/** Inline controls (e.g. `<ModelPicker/>` + `<EffortPicker/>` or
* `<AgentSessionControls/>`). Rendered in a row above the input by default. */
controls?: ReactNode
Expand Down Expand Up @@ -159,6 +166,8 @@ export function ChatComposer({
value,
onValueChange,
initialValue,
seed,
onSeedApplied,
controls,
controlsPlacement = 'above',
onAttach,
Expand Down Expand Up @@ -199,6 +208,48 @@ export function ChatComposer({
el.style.height = `${Math.min(el.scrollHeight, MAX_HEIGHT)}px`
}, [text])

// Adopt a one-shot seed. Applies only when the `seed` PROP transitions to a
// new string (host sets it → consumed here → host clears it via
// onSeedApplied), so an unstable callback identity re-running this effect
// can never re-apply a still-set seed over the user's typing. Like
// `initialValue`, the seed is honored ONLY in uncontrolled mode — a
// controlled host drives its own `value` (which would shadow `setText`), so
// it seeds by updating that state itself.
const prevSeedRef = useRef<string | null>(null)
const pendingCaretRef = useRef<string | null>(null)
useEffect(() => {
const prev = prevSeedRef.current
prevSeedRef.current = seed ?? null
if (seed == null || seed === prev || isControlled) return
setText(seed)
onSeedApplied?.()
const el = textareaRef.current
if (el && el.value === seed) {
// The DOM already shows the seed — setText was a no-op (the user had
// typed the exact string), so no re-render is coming and the [text]
// effect below won't fire. Position the caret now instead of leaving a
// stranded pendingCaretRef.
el.focus()
el.setSelectionRange(seed.length, seed.length)
} else {
// Defer caret positioning until the seeded value renders (see below).
pendingCaretRef.current = seed
}
}, [seed, setText, onSeedApplied, isControlled])

// Focus + caret-to-end AFTER the seeded value has rendered into the DOM —
// setSelectionRange in the applying effect would run against the pre-render
// value and clamp the caret to the old text's length.
useEffect(() => {
if (pendingCaretRef.current == null || pendingCaretRef.current !== text)
return
pendingCaretRef.current = null
const el = textareaRef.current
if (!el) return
el.focus()
el.setSelectionRange(text.length, text.length)
}, [text])

// Cmd/Ctrl+L focuses the composer from anywhere — the shortcut the hint
// advertises. Scoped to when the shortcut is enabled and not disabled.
useEffect(() => {
Expand Down