fix: seed artifact companion composer on first open - #2529
Conversation
Sending an artifact comment to an agent opened the companion chat with an empty composer on the first open; the staged prompt only appeared after closing and reopening the agent. Two StrictMode-only races combined: 1. createBoundSession wrote the prefill but delegated slot activation to ArtifactChatPanel's effect, so switchSlot landed in the same commit that mounts the embedded ChatPage. There ChatPage's mount-only slot re-fetch effect (double-invoked, stale activeSlot capture) re-asserted the prior active slot as the last write, so activeSlot never became the bound slot and the prefill was never consumed. 2. Once activated, ChatPage's per-slot draft-restore effect consumed and removed the prefill on the first mount invoke, then the second invoke found nothing and reset the composer to the empty draft. Activate the bound slot in createBoundSession back-to-back with writePrefill (matching the follow-up worktree handler), and guard the draft-restore consumer against the mount double-invoke with a consumed-prefill ref. Add a StrictMode regression test.
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed Review detailsNo findings on the changed lines. The diff is a well-reasoned React StrictMode timing fix: No findings. [OPUS-REVIEWED] 7360ea2 |
UX Review (Fable 5, fork) — ✅ PASSAdvisory UX-level review of UX-Verdict: PASS Pure timing fix — the staged prompt now appears on first open instead of second; no strings, layout, or flows change, and the repaired behavior matches the label's promise. [UX-REVIEWED] 7360ea2 |
GPT 5.6 Review (fork) — ✅ no blocking findingsReviewed Review detailsNo findings. |
Design Review (Fable 5, fork) — ✅ PASSAdvisory design-level review of Design-Verdict: PASS Root-cause fix at the right layer — activation ordering moved to the session owner, consumption made idempotent, with a real-ChatPage StrictMode regression test. Suggestions
[DESIGN-REVIEWED] 7360ea2 |
Sending an artifact comment to an agent opened the companion chat with an empty composer on the first open; the staged prompt only appeared after closing and reopening the agent. Two StrictMode-only races combined: 1. createBoundSession wrote the prefill but delegated slot activation to ArtifactChatPanel's effect, so switchSlot landed in the same commit that mounts the embedded ChatPage. There ChatPage's mount-only slot re-fetch effect (double-invoked, stale activeSlot capture) re-asserted the prior active slot as the last write, so activeSlot never became the bound slot and the prefill was never consumed. 2. Once activated, ChatPage's per-slot draft-restore effect consumed and removed the prefill on the first mount invoke, then the second invoke found nothing and reset the composer to the empty draft. Activate the bound slot in createBoundSession back-to-back with writePrefill (matching the follow-up worktree handler), and guard the draft-restore consumer against the mount double-invoke with a consumed-prefill ref. Add a StrictMode regression test.
What
Fixes the artifact companion chat opening with an empty composer the first time a comment is sent to an agent (the staged prompt only appears on the second open).
Closes #2524.
Root cause
Two
React.StrictMode-only races combined (the app mounts underStrictMode, and the artifact companion panel mountsChatPagefresh every first open, so it hits mount-effect double-invokes the always-mounted/chatpage never sees):createBoundSession(ArtifactDetailPage.tsx) wrote the prefill viawritePrefillbut delegated slot activation toArtifactChatPanel'suseEffect. ThatswitchSlottherefore lands in the same commit that mounts the embeddedChatPage, where ChatPage's mount-only slot re-fetch effect — StrictMode double-invoked, empty deps so it captures a staleactiveSlot— re-asserts the prior active slot as the last write.activeSlotnever becomes the bound slot, so the prefill is never matched.PREFILL_STORAGE_KEYon StrictMode's first mount invoke, then the second invoke finds nothing and resets the composer to the empty incoming draft.The follow-up worktree handler in
ChatPage.tsxalready documents the correct sequencing (writePrefillthenswitchSlotback-to-back) — but it works there because/chat's ChatPage is already mounted, so its consumer effect runs once on a dep-change, never a mount double-invoke.Fix
ArtifactDetailPage.tsx—createBoundSessionnow dispatchesswitchSlot(res.key)immediately afterwritePrefill+addSlotOptimistic, soactiveSlotis already the bound slot before the panel mounts ChatPage (idempotent once equal). Covers both the "Ask agent to address" flow and "New chat" (both route throughcreateBoundSession).ChatPage.tsx— aconsumedPrefillRefguards the draft-restore consumer against the mount double-invoke: when the effect re-runs for the same active slot after already consuming the prefill, it leaves the composer as-is instead of wiping it to the empty draft. A genuine slot switch changesactiveSlot, so the guard can never mask a real draft restore.Tests
src/test/ChatPageEmbeddedPrefill.test.tsxmounts the real ChatPage the wayArtifactChatPaneldoes, underStrictMode, and asserts the composer is seeded with the staged prompt on the first open. It fails onmain(empty composer) and passes with this change.cd website && npx tsc -bclean;eslint0 errors; ran the ChatPage + Artifact + Sidebar test surface (65 files / 496 tests) — all green, confirming the shared draft-restore effect change is regression-free.Known related edge (not fixed here, flagged for maintainers)
An audit surfaced a narrower sibling: re-staging an "Ask agent to address" prompt while the companion panel is already open on that same slot (
openCompanionChatresume path,ArtifactDetailPage.tsx:1147) can strand the prompt, becauseArtifactChatPanel's ref-guarded effect no-opsswitchSlotwhen the slot is unchanged and nothing re-fires the consumer. It's a distinct trigger from the reported first-open bug and touches hotter shared code; happy to address in a follow-up if you'd like it in scope.