From 4766f39dd50a233f5d22fe9e30e03ee03242f87b Mon Sep 17 00:00:00 2001 From: Donovan Tjemmes Date: Fri, 17 Jul 2026 12:18:51 -0500 Subject: [PATCH 1/3] fix(assistant): deliver the launcher's composer seed to the dock composer --- src/assistant/AssistantDock.tsx | 5 +++- src/assistant/AssistantPanel.tsx | 10 ++++++++ src/web-react/chat-composer.test.tsx | 37 ++++++++++++++++++++++++++++ src/web-react/chat-composer.tsx | 23 +++++++++++++++++ 4 files changed, 74 insertions(+), 1 deletion(-) diff --git a/src/assistant/AssistantDock.tsx b/src/assistant/AssistantDock.tsx index 8b1dff6..7d04b82 100644 --- a/src/assistant/AssistantDock.tsx +++ b/src/assistant/AssistantDock.tsx @@ -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, @@ -214,6 +215,8 @@ export function AssistantDock({ toolRenderers={toolRenderers} renderConfirmedResult={renderConfirmedResult} renderTranscript={renderTranscript} + composerSeed={seed} + onComposerSeedApplied={clearSeed} /> {isDesktop && ( 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 = @@ -159,6 +165,8 @@ export function AssistantPanel({ toolRenderers, renderConfirmedResult, renderTranscript, + composerSeed = null, + onComposerSeedApplied, }: AssistantPanelProps) { const models = useAssistantModels(); const threads = useAssistantThreads(userId); @@ -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 ? ( { 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( + , + ) + const input = screen.getByLabelText('Message input') as HTMLTextAreaElement + + rerender( + , + ) + + expect(input.value).toBe('Build a workflow that uses `github.issues.create` to ') + expect(onSeedApplied).toHaveBeenCalledOnce() + expect(document.activeElement).toBe(input) + }) + + it('applies a second seed after the first is cleared, replacing the draft', () => { + const onSeedApplied = vi.fn() + const { rerender } = render( + , + ) + const input = screen.getByLabelText('Message input') as HTMLTextAreaElement + type(input, 'first plus edits') + + rerender() + rerender() + + expect(input.value).toBe('second ') + expect(onSeedApplied).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/web-react/chat-composer.tsx b/src/web-react/chat-composer.tsx index 8266aca..36a6287 100644 --- a/src/web-react/chat-composer.tsx +++ b/src/web-react/chat-composer.tsx @@ -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. `` + `` or * ``). Rendered in a row above the input by default. */ controls?: ReactNode @@ -159,6 +166,8 @@ export function ChatComposer({ value, onValueChange, initialValue, + seed, + onSeedApplied, controls, controlsPlacement = 'above', onAttach, @@ -199,6 +208,20 @@ export function ChatComposer({ el.style.height = `${Math.min(el.scrollHeight, MAX_HEIGHT)}px` }, [text]) + // Adopt a one-shot seed. Runs whenever the seed transitions to a string + // (host sets it → consumed here → host clears it via onSeedApplied), so a + // second seed while the composer stays mounted still applies. + useEffect(() => { + if (seed == null) return + setText(seed) + onSeedApplied?.() + const el = textareaRef.current + if (el) { + el.focus() + el.setSelectionRange(seed.length, seed.length) + } + }, [seed, setText, onSeedApplied]) + // Cmd/Ctrl+L focuses the composer from anywhere — the shortcut the hint // advertises. Scoped to when the shortcut is enabled and not disabled. useEffect(() => { From 97b866407b13f8d71dc4d81ca1f039801fe5a20f Mon Sep 17 00:00:00 2001 From: Donovan Tjemmes Date: Fri, 17 Jul 2026 12:41:22 -0500 Subject: [PATCH 2/3] fix(assistant): apply seed only on prop transition, place caret after render --- src/web-react/chat-composer.test.tsx | 4 ++++ src/web-react/chat-composer.tsx | 32 ++++++++++++++++++++-------- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/src/web-react/chat-composer.test.tsx b/src/web-react/chat-composer.test.tsx index 4b6a61e..2bd20f8 100644 --- a/src/web-react/chat-composer.test.tsx +++ b/src/web-react/chat-composer.test.tsx @@ -207,6 +207,10 @@ describe('ChatComposer seed', () => { 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', () => { diff --git a/src/web-react/chat-composer.tsx b/src/web-react/chat-composer.tsx index 36a6287..348b91c 100644 --- a/src/web-react/chat-composer.tsx +++ b/src/web-react/chat-composer.tsx @@ -208,20 +208,34 @@ export function ChatComposer({ el.style.height = `${Math.min(el.scrollHeight, MAX_HEIGHT)}px` }, [text]) - // Adopt a one-shot seed. Runs whenever the seed transitions to a string - // (host sets it → consumed here → host clears it via onSeedApplied), so a - // second seed while the composer stays mounted still applies. + // 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. + const prevSeedRef = useRef(null) + const pendingCaretRef = useRef(null) useEffect(() => { - if (seed == null) return + const prev = prevSeedRef.current + prevSeedRef.current = seed ?? null + if (seed == null || seed === prev) return setText(seed) + pendingCaretRef.current = seed onSeedApplied?.() - const el = textareaRef.current - if (el) { - el.focus() - el.setSelectionRange(seed.length, seed.length) - } }, [seed, setText, onSeedApplied]) + // 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(() => { From 546b09f5cac3faf21a60649a8f7edd1f4dae760e Mon Sep 17 00:00:00 2001 From: Donovan Tjemmes Date: Fri, 17 Jul 2026 17:41:10 -0500 Subject: [PATCH 3/3] fix(assistant): ignore seed in controlled mode, cover panel forwarding + caret edge --- src/assistant/AssistantPanel.test.tsx | 35 ++++++++++++++++++ src/web-react/chat-composer.test.tsx | 52 +++++++++++++++++++++++++++ src/web-react/chat-composer.tsx | 22 +++++++++--- 3 files changed, 105 insertions(+), 4 deletions(-) diff --git a/src/assistant/AssistantPanel.test.tsx b/src/assistant/AssistantPanel.test.tsx index 75ea552..0d415b9 100644 --- a/src/assistant/AssistantPanel.test.tsx +++ b/src/assistant/AssistantPanel.test.tsx @@ -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( + + {}} + composerSeed={null} + onComposerSeedApplied={onComposerSeedApplied} + /> + , + ); + + rerender( + + {}} + composerSeed="Draft this workflow" + onComposerSeedApplied={onComposerSeedApplied} + /> + , + ); + + const input = (await screen.findByLabelText( + "Message input", + )) as HTMLTextAreaElement; + expect(input.value).toBe("Draft this workflow"); + expect(onComposerSeedApplied).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/web-react/chat-composer.test.tsx b/src/web-react/chat-composer.test.tsx index 2bd20f8..1370d45 100644 --- a/src/web-react/chat-composer.test.tsx +++ b/src/web-react/chat-composer.test.tsx @@ -227,4 +227,56 @@ describe('ChatComposer seed', () => { 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( + , + ) + const input = screen.getByLabelText('Message input') as HTMLTextAreaElement + + rerender( + , + ) + + // 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( + , + ) + 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( + , + ) + + // 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) + }) }) diff --git a/src/web-react/chat-composer.tsx b/src/web-react/chat-composer.tsx index 348b91c..e143580 100644 --- a/src/web-react/chat-composer.tsx +++ b/src/web-react/chat-composer.tsx @@ -211,17 +211,31 @@ export function ChatComposer({ // 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. + // 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(null) const pendingCaretRef = useRef(null) useEffect(() => { const prev = prevSeedRef.current prevSeedRef.current = seed ?? null - if (seed == null || seed === prev) return + if (seed == null || seed === prev || isControlled) return setText(seed) - pendingCaretRef.current = seed onSeedApplied?.() - }, [seed, setText, 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