fix(chat-pane): route late sends and uploads to the slot they belong to - #8942
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Every late async result now carries the slot it was started for — a real loss class closed at the right layer, with the store/live-composer split soundly reasoned. [DESIGN-REVIEWED] 7c31c07 |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All the load-bearing claims verify: MembersPage.tsx:615 rebinds one First-Principles-Verdict: CONCERNS Every item traces to a quoted blocking defect, but the rule the PR itself generalizes has one counted, unfixed sibling in ChatPage's upload-failure path. What this change shipsIntent: make every late-resolving send or upload land on the member it was started for, not whoever is on screen — a FIX (nine quoted blocking findings from review of #8852).
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 7c31c07 |
UX Review (Fable 5) — 🟡 CONCERNSUX-level review of The evidence is complete. Both screenshots reconcile with the diff, the recording covers draft parking, and both new user-facing strings pre-exist in the catalog ( UX-Verdict: CONCERNS Same upload failure wears two faces — dismissible banner vs bare transcript row — and the blind reader couldn't tell they were the same thing. Watch
Evidence gaps
[UX-REVIEWED] 7c31c07 |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsI've now examined both candidates against the actual code. Candidate 1 ( Candidate 2 ( Neither candidate re-derives a concrete input → call path → observable wrong outcome at 80+. No new grounded finding surfaced during falsification. No findings. [OPUS-REVIEWED] 7c31c07 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
ae56361 to
f91cf59
Compare
bd311cf to
8157fe1
Compare
Review of the steer-only DM composer (#8852) surfaced that ChatPane's composer is pane-local state while its host can rebind the instance to another slot (Members page) or unmount it with work in flight. Anything that resolves late then lands on whatever is on screen, or nowhere: - a refused/unconfirmed send restored A's draft into B's composer, or into an unmounted component (silent loss); - an upload finishing after a switch staged its file for the wrong member; an upload failing after a switch or page exit was dropped; - a recovery landing after close+reopen was overwritten by the new pane's next park; - a steer the server demoted to the queue was not bound to its queue card, so cancelling restored marker text without the file chip; - a busy send with no receipt (no optimistic bubble) vanished; a queued card with identical text could mask that loss. ChatPane now parks its composer per slot in utils/chatPaneDrafts (the repo's createSlotDraftStore on pane-owned keys), takes a slot's parked draft when it shows the slot and subscribes to late arrivals for it; all recovery sites carry the sending slot; uploads carry their originating slot (paths -> live or parked composer, failures -> banner when on screen, else that slot's transcript). The upload banner renders through the shared ErrorNotice (its dead dismiss key is removed from every locale). Demoted steers bind pre-send state to their queue_id. Only an echo carrying the send's own sendId counts as delivered. Tests: ChatPane.draftRecovery.test.tsx (11 cases). Evidence frames 10-12 under temp-screenshots/members-steer-only.
8157fe1 to
7c31c07
Compare
|
Head GPT 5.6 — chatPaneDrafts.ts:36 "Pane drafts exceed the aggregate storage budget" — fixed. The pane's text store no longer sits in localStorage beside ChatPage's two 2 MiB stores: both pane stores are now sessionStorage (parking is a within-session hand-off; it does not need to outlive the tab) with a 256 KiB byte cap, and every parked draft is also mirrored in memory (write-through; reads prefer the mirror, then storage for a reloaded tab). A persist the browser refuses — quota, disabled storage — therefore still hands the draft back for as long as the tab lives. New unit test GPT 5.6 — ChatPane.tsx:713 "Queue cancellation can beat attachment stashing" (origin: validation) — pre-existing, not introduced or touched by this PR: |
|
Head UX Review (CONCERNS)
First Principles (CONCERNS)
|
0afdd12
into
feat/members-chat-steer-only
A member DM is a conversation with one named member, not an operator
console. Talking to a person has no "queue this until they finish" step,
so while the member is working a send now goes straight into its running
turn: the composer keeps the plain Send button (no Steer/Queue split, no
queue stack) and a confirmed steer renders as an ordinary message (no
"Steered into the running turn" badge). The main chat keeps its split
button; split view (Cmd+D) gains it (it was queue-only, the same missing
props as the DM).
ChatInput gains `busyMode: 'split' | 'steer-only'` (default split).
steer-only renders the idle send button while busy and ignores the
persisted per-slot Queue preference, since there is no Queue to pick.
ChatPane wires the chat-core steer path (`sendTurn({ steer: true })`,
slot-scoped optimistic steer bubble, ChatPage's receipt policy) and
passes `canSteer`/`onSteer`/`busyMode` through; a sub-agents-only busy
pane sends with the steer flag like ChatPage does. transcriptRenderers
gains `hideSteerBadge`, wired by ChatPane from busyMode. MembersPage
mounts the pane with `busyMode="steer-only"`. A refused pane send
frames the server's reason (new key send_failed_with_error_restored,
13 locales + en-XA) instead of showing it bare.
Draft/recovery hardening surfaced by review (per-slot draft parking,
cross-member/unmount recovery routing, upload-failure attribution,
demoted-steer queue binding) landed via the stacked PR #8942 and rides
in this commit. In steer-only, `hideSteerBadge` also silences main's
"Steering..." pending pulse and requeued note -- same vocabulary.
Tests (CI-run): ChatInput steer-only pins, ChatPane.steerOnly (busy DM
steers, idle sends plain, default pane keeps the split, refused steer
restores, badge hidden only in steer-only, framed refusal copy),
MembersPage asserts the host asks for steer-only. Capture harness,
frames and idle->busy recordings under temp-screenshots/members-steer-only.
A member DM is a conversation with one named member, not an operator
console. Talking to a person has no "queue this until they finish" step,
so while the member is working a send now goes straight into its running
turn: the composer keeps the plain Send button (no Steer/Queue split, no
queue stack) and a confirmed steer renders as an ordinary message (no
"Steered into the running turn" badge). The main chat keeps its split
button; split view (Cmd+D) gains it (it was queue-only, the same missing
props as the DM).
ChatInput gains `busyMode: 'split' | 'steer-only'` (default split).
steer-only renders the idle send button while busy and ignores the
persisted per-slot Queue preference, since there is no Queue to pick.
ChatPane wires the chat-core steer path (`sendTurn({ steer: true })`,
slot-scoped optimistic steer bubble, ChatPage's receipt policy) and
passes `canSteer`/`onSteer`/`busyMode` through; a sub-agents-only busy
pane sends with the steer flag like ChatPage does. transcriptRenderers
gains `hideSteerBadge`, wired by ChatPane from busyMode. MembersPage
mounts the pane with `busyMode="steer-only"`. A refused pane send
frames the server's reason (new key send_failed_with_error_restored,
13 locales + en-XA) instead of showing it bare.
Draft/recovery hardening surfaced by review (per-slot draft parking,
cross-member/unmount recovery routing, upload-failure attribution,
demoted-steer queue binding) landed via the stacked PR #8942 and rides
in this commit. In steer-only, `hideSteerBadge` also silences main's
"Steering..." pending pulse and requeued note -- same vocabulary.
Tests (CI-run): ChatInput steer-only pins, ChatPane.steerOnly (busy DM
steers, idle sends plain, default pane keeps the split, refused steer
restores, badge hidden only in steer-only, framed refusal copy),
MembersPage asserts the host asks for steer-only. Capture harness,
frames and idle->busy recordings under temp-screenshots/members-steer-only.
A member DM is a conversation with one named member, not an operator
console. Talking to a person has no "queue this until they finish" step,
so while the member is working a send now goes straight into its running
turn: the composer keeps the plain Send button (no Steer/Queue split, no
queue stack) and a confirmed steer renders as an ordinary message (no
"Steered into the running turn" badge). The main chat keeps its split
button; split view (Cmd+D) gains it (it was queue-only, the same missing
props as the DM).
ChatInput gains `busyMode: 'split' | 'steer-only'` (default split).
steer-only renders the idle send button while busy and ignores the
persisted per-slot Queue preference, since there is no Queue to pick.
ChatPane wires the chat-core steer path (`sendTurn({ steer: true })`,
slot-scoped optimistic steer bubble, ChatPage's receipt policy) and
passes `canSteer`/`onSteer`/`busyMode` through; a sub-agents-only busy
pane sends with the steer flag like ChatPage does. transcriptRenderers
gains `hideSteerBadge`, wired by ChatPane from busyMode. MembersPage
mounts the pane with `busyMode="steer-only"`. A refused pane send
frames the server's reason (new key send_failed_with_error_restored,
13 locales + en-XA) instead of showing it bare.
Draft/recovery hardening surfaced by review (per-slot draft parking,
cross-member/unmount recovery routing, upload-failure attribution,
demoted-steer queue binding) landed via the stacked PR #8942 and rides
in this commit. In steer-only, `hideSteerBadge` also silences main's
"Steering..." pending pulse and requeued note -- same vocabulary.
Tests (CI-run): ChatInput steer-only pins, ChatPane.steerOnly (busy DM
steers, idle sends plain, default pane keeps the split, refused steer
restores, badge hidden only in steer-only, framed refusal copy),
MembersPage asserts the host asks for steer-only. Capture harness,
frames and idle->busy recordings under temp-screenshots/members-steer-only.
A member DM is a conversation with one named member, not an operator
console. Talking to a person has no "queue this until they finish" step,
so while the member is working a send now goes straight into its running
turn: the composer keeps the plain Send button (no Steer/Queue split, no
queue stack) and a confirmed steer renders as an ordinary message (no
"Steered into the running turn" badge). The main chat keeps its split
button; split view (Cmd+D) gains it (it was queue-only, the same missing
props as the DM).
ChatInput gains `busyMode: 'split' | 'steer-only'` (default split).
steer-only renders the idle send button while busy and ignores the
persisted per-slot Queue preference, since there is no Queue to pick.
ChatPane wires the chat-core steer path (`sendTurn({ steer: true })`,
slot-scoped optimistic steer bubble, ChatPage's receipt policy) and
passes `canSteer`/`onSteer`/`busyMode` through; a sub-agents-only busy
pane sends with the steer flag like ChatPage does. transcriptRenderers
gains `hideSteerBadge`, wired by ChatPane from busyMode. MembersPage
mounts the pane with `busyMode="steer-only"`. A refused pane send
frames the server's reason (new key send_failed_with_error_restored,
13 locales + en-XA) instead of showing it bare.
Draft/recovery hardening surfaced by review (per-slot draft parking,
cross-member/unmount recovery routing, upload-failure attribution,
demoted-steer queue binding) landed via the stacked PR #8942 and rides
in this commit. In steer-only, `hideSteerBadge` also silences main's
"Steering..." pending pulse and requeued note -- same vocabulary.
Tests (CI-run): ChatInput steer-only pins, ChatPane.steerOnly (busy DM
steers, idle sends plain, default pane keeps the split, refused steer
restores, badge hidden only in steer-only, framed refusal copy),
MembersPage asserts the host asks for steer-only. Capture harness,
frames and idle->busy recordings under temp-screenshots/members-steer-only.
A member DM is a conversation with one named member, not an operator
console. Talking to a person has no "queue this until they finish" step,
so while the member is working a send now goes straight into its running
turn: the composer keeps the plain Send button (no Steer/Queue split, no
queue stack) and a confirmed steer renders as an ordinary message (no
"Steered into the running turn" badge). The main chat keeps its split
button; split view (Cmd+D) gains it (it was queue-only, the same missing
props as the DM).
ChatInput gains `busyMode: 'split' | 'steer-only'` (default split).
steer-only renders the idle send button while busy and ignores the
persisted per-slot Queue preference, since there is no Queue to pick.
ChatPane wires the chat-core steer path (`sendTurn({ steer: true })`,
slot-scoped optimistic steer bubble, ChatPage's receipt policy) and
passes `canSteer`/`onSteer`/`busyMode` through; a sub-agents-only busy
pane sends with the steer flag like ChatPage does. transcriptRenderers
gains `hideSteerBadge`, wired by ChatPane from busyMode. MembersPage
mounts the pane with `busyMode="steer-only"`. A refused pane send
frames the server's reason (new key send_failed_with_error_restored,
13 locales + en-XA) instead of showing it bare.
Draft/recovery hardening surfaced by review (per-slot draft parking,
cross-member/unmount recovery routing, upload-failure attribution,
demoted-steer queue binding) landed via the stacked PR #8942 and rides
in this commit. In steer-only, `hideSteerBadge` also silences main's
"Steering..." pending pulse and requeued note -- same vocabulary.
The steer-only `user` row reuses ChatPageMessageContent.renderUserContent
(main's single owner of user-bubble content since #9433).
Tests (CI-run): ChatInput steer-only pins, ChatPane.steerOnly (busy DM
steers, idle sends plain, default pane keeps the split, refused steer
restores, badge hidden only in steer-only, framed refusal copy),
MembersPage asserts the host asks for steer-only. Capture harness,
frames and idle->busy recordings under temp-screenshots/members-steer-only.
A member DM is a conversation with one named member, not an operator
console. Talking to a person has no "queue this until they finish" step,
so while the member is working a send now goes straight into its running
turn: the composer keeps the plain Send button (no Steer/Queue split, no
queue stack) and a confirmed steer renders as an ordinary message (no
"Steered into the running turn" badge). The main chat keeps its split
button; split view (Cmd+D) gains it (it was queue-only, the same missing
props as the DM).
ChatInput gains `busyMode: 'split' | 'steer-only'` (default split).
steer-only renders the idle send button while busy and ignores the
persisted per-slot Queue preference, since there is no Queue to pick.
ChatPane wires the chat-core steer path (`sendTurn({ steer: true })`,
slot-scoped optimistic steer bubble, ChatPage's receipt policy) and
passes `canSteer`/`onSteer`/`busyMode` through; a sub-agents-only busy
pane sends with the steer flag like ChatPage does. transcriptRenderers
gains `hideSteerBadge`, wired by ChatPane from busyMode. MembersPage
mounts the pane with `busyMode="steer-only"`. A refused pane send
frames the server's reason (new key send_failed_with_error_restored,
13 locales + en-XA) instead of showing it bare.
Draft/recovery hardening surfaced by review (per-slot draft parking,
cross-member/unmount recovery routing, upload-failure attribution,
demoted-steer queue binding) landed via the stacked PR #8942 and rides
in this commit. In steer-only, `hideSteerBadge` also silences main's
"Steering..." pending pulse and requeued note -- same vocabulary.
The steer-only `user` row reuses ChatPageMessageContent.renderUserContent
(main's single owner of user-bubble content since #9433).
Tests (CI-run): ChatInput steer-only pins, ChatPane.steerOnly (busy DM
steers, idle sends plain, default pane keeps the split, refused steer
restores, badge hidden only in steer-only, framed refusal copy),
MembersPage asserts the host asks for steer-only. Capture harness,
frames and idle->busy recordings under temp-screenshots/members-steer-only.
A member DM is a conversation with one named member, not an operator
console. Talking to a person has no "queue this until they finish" step,
so while the member is working a send now goes straight into its running
turn: the composer keeps the plain Send button (no Steer/Queue split, no
queue stack) and a confirmed steer renders as an ordinary message (no
"Steered into the running turn" badge). The main chat keeps its split
button; split view (Cmd+D) gains it (it was queue-only, the same missing
props as the DM).
ChatInput gains `busyMode: 'split' | 'steer-only'` (default split).
steer-only renders the idle send button while busy and ignores the
persisted per-slot Queue preference, since there is no Queue to pick.
ChatPane wires the chat-core steer path (`sendTurn({ steer: true })`,
slot-scoped optimistic steer bubble, ChatPage's receipt policy) and
passes `canSteer`/`onSteer`/`busyMode` through; a sub-agents-only busy
pane sends with the steer flag like ChatPage does. transcriptRenderers
gains `hideSteerBadge`, wired by ChatPane from busyMode. MembersPage
mounts the pane with `busyMode="steer-only"`. A refused pane send
frames the server's reason (new key send_failed_with_error_restored,
13 locales + en-XA) instead of showing it bare.
Draft/recovery hardening surfaced by review (per-slot draft parking,
cross-member/unmount recovery routing, upload-failure attribution,
demoted-steer queue binding) landed via the stacked PR #8942 and rides
in this commit. In steer-only, `hideSteerBadge` also silences main's
"Steering..." pending pulse and requeued note -- same vocabulary.
The steer-only `user` row reuses ChatPageMessageContent.renderUserContent
(main's single owner of user-bubble content since #9433).
Tests (CI-run): ChatInput steer-only pins, ChatPane.steerOnly (busy DM
steers, idle sends plain, default pane keeps the split, refused steer
restores, badge hidden only in steer-only, framed refusal copy),
MembersPage asserts the host asks for steer-only. Capture harness,
frames and idle->busy recordings under temp-screenshots/members-steer-only.
A member DM is a conversation with one named member, not an operator
console. Talking to a person has no "queue this until they finish" step,
so while the member is working a send now goes straight into its running
turn: the composer keeps the plain Send button (no Steer/Queue split, no
queue stack) and a confirmed steer renders as an ordinary message (no
"Steered into the running turn" badge). The main chat keeps its split
button; split view (Cmd+D) gains it (it was queue-only, the same missing
props as the DM).
ChatInput gains `busyMode: 'split' | 'steer-only'` (default split).
steer-only renders the idle send button while busy and ignores the
persisted per-slot Queue preference, since there is no Queue to pick.
ChatPane wires the chat-core steer path (`sendTurn({ steer: true })`,
slot-scoped optimistic steer bubble, ChatPage's receipt policy) and
passes `canSteer`/`onSteer`/`busyMode` through; a sub-agents-only busy
pane sends with the steer flag like ChatPage does. transcriptRenderers
gains `hideSteerBadge`, wired by ChatPane from busyMode. MembersPage
mounts the pane with `busyMode="steer-only"`. A refused pane send
frames the server's reason (new key send_failed_with_error_restored,
13 locales + en-XA) instead of showing it bare.
Draft/recovery hardening surfaced by review (per-slot draft parking,
cross-member/unmount recovery routing, upload-failure attribution,
demoted-steer queue binding) landed via the stacked PR #8942 and rides
in this commit. In steer-only, `hideSteerBadge` also silences main's
"Steering..." pending pulse and requeued note -- same vocabulary.
The steer-only `user` row reuses ChatPageMessageContent.renderUserContent
(main's single owner of user-bubble content since #9433).
Tests (CI-run): ChatInput steer-only pins, ChatPane.steerOnly (busy DM
steers, idle sends plain, default pane keeps the split, refused steer
restores, badge hidden only in steer-only, framed refusal copy),
MembersPage asserts the host asks for steer-only. Capture harness,
frames and idle->busy recordings under temp-screenshots/members-steer-only.
A member DM is a conversation with one named member, not an operator
console. Talking to a person has no "queue this until they finish" step,
so while the member is working a send now goes straight into its running
turn: the composer keeps the plain Send button (no Steer/Queue split, no
queue stack) and a confirmed steer renders as an ordinary message (no
"Steered into the running turn" badge). The main chat keeps its split
button; split view (Cmd+D) gains it (it was queue-only, the same missing
props as the DM).
ChatInput gains `busyMode: 'split' | 'steer-only'` (default split).
steer-only renders the idle send button while busy and ignores the
persisted per-slot Queue preference, since there is no Queue to pick.
ChatPane wires the chat-core steer path (`sendTurn({ steer: true })`,
slot-scoped optimistic steer bubble, ChatPage's receipt policy) and
passes `canSteer`/`onSteer`/`busyMode` through; a sub-agents-only busy
pane sends with the steer flag like ChatPage does. transcriptRenderers
gains `hideSteerBadge`, wired by ChatPane from busyMode. MembersPage
mounts the pane with `busyMode="steer-only"`. A refused pane send
frames the server's reason (new key send_failed_with_error_restored,
13 locales + en-XA) instead of showing it bare.
Draft/recovery hardening surfaced by review (per-slot draft parking,
cross-member/unmount recovery routing, upload-failure attribution,
demoted-steer queue binding) landed via the stacked PR #8942 and rides
in this commit. In steer-only, `hideSteerBadge` also silences main's
"Steering..." pending pulse and requeued note -- same vocabulary.
The steer-only `user` row reuses ChatPageMessageContent.renderUserContent
(main's single owner of user-bubble content since #9433).
Tests (CI-run): ChatInput steer-only pins, ChatPane.steerOnly (busy DM
steers, idle sends plain, default pane keeps the split, refused steer
restores, badge hidden only in steer-only, framed refusal copy),
MembersPage asserts the host asks for steer-only. Capture harness,
frames and idle->busy recordings under temp-screenshots/members-steer-only.
A member DM is a conversation with one named member, not an operator
console. Talking to a person has no "queue this until they finish" step,
so while the member is working a send now goes straight into its running
turn: the composer keeps the plain Send button (no Steer/Queue split, no
queue stack) and a confirmed steer renders as an ordinary message (no
"Steered into the running turn" badge). The main chat keeps its split
button; split view (Cmd+D) gains it (it was queue-only, the same missing
props as the DM).
ChatInput gains `busyMode: 'split' | 'steer-only'` (default split).
steer-only renders the idle send button while busy and ignores the
persisted per-slot Queue preference, since there is no Queue to pick.
ChatPane wires the chat-core steer path (`sendTurn({ steer: true })`,
slot-scoped optimistic steer bubble, ChatPage's receipt policy) and
passes `canSteer`/`onSteer`/`busyMode` through; a sub-agents-only busy
pane sends with the steer flag like ChatPage does. transcriptRenderers
gains `hideSteerBadge`, wired by ChatPane from busyMode. MembersPage
mounts the pane with `busyMode="steer-only"`. A refused pane send
frames the server's reason (new key send_failed_with_error_restored,
13 locales + en-XA) instead of showing it bare.
Draft/recovery hardening surfaced by review (per-slot draft parking,
cross-member/unmount recovery routing, upload-failure attribution,
demoted-steer queue binding) landed via the stacked PR #8942 and rides
in this commit. In steer-only, `hideSteerBadge` also silences main's
"Steering..." pending pulse and requeued note -- same vocabulary.
The steer-only `user` row reuses ChatPageMessageContent.renderUserContent
(main's single owner of user-bubble content since #9433).
Tests (CI-run): ChatInput steer-only pins, ChatPane.steerOnly (busy DM
steers, idle sends plain, default pane keeps the split, refused steer
restores, badge hidden only in steer-only, framed refusal copy),
MembersPage asserts the host asks for steer-only. Capture harness,
frames and idle->busy recordings under temp-screenshots/members-steer-only.
A member DM is a conversation with one named member, not an operator
console. Talking to a person has no "queue this until they finish" step,
so while the member is working a send now goes straight into its running
turn: the composer keeps the plain Send button (no Steer/Queue split, no
queue stack) and a confirmed steer renders as an ordinary message (no
"Steered into the running turn" badge). The main chat keeps its split
button; split view (Cmd+D) gains it (it was queue-only, the same missing
props as the DM).
ChatInput gains `busyMode: 'split' | 'steer-only'` (default split).
steer-only renders the idle send button while busy and ignores the
persisted per-slot Queue preference, since there is no Queue to pick.
ChatPane wires the chat-core steer path (`sendTurn({ steer: true })`,
slot-scoped optimistic steer bubble, ChatPage's receipt policy) and
passes `canSteer`/`onSteer`/`busyMode` through; a sub-agents-only busy
pane sends with the steer flag like ChatPage does. transcriptRenderers
gains `hideSteerBadge`, wired by ChatPane from busyMode. MembersPage
mounts the pane with `busyMode="steer-only"`. A refused pane send
frames the server's reason (new key send_failed_with_error_restored,
13 locales + en-XA) instead of showing it bare.
Draft/recovery hardening surfaced by review (per-slot draft parking,
cross-member/unmount recovery routing, upload-failure attribution,
demoted-steer queue binding) landed via the stacked PR #8942 and rides
in this commit. In steer-only, `hideSteerBadge` also silences main's
"Steering..." pending pulse and requeued note -- same vocabulary.
The steer-only `user` row reuses ChatPageMessageContent.renderUserContent
(main's single owner of user-bubble content since #9433).
Tests (CI-run): ChatInput steer-only pins, ChatPane.steerOnly (busy DM
steers, idle sends plain, default pane keeps the split, refused steer
restores, badge hidden only in steer-only, framed refusal copy),
MembersPage asserts the host asks for steer-only. Capture harness,
frames and idle->busy recordings under temp-screenshots/members-steer-only.
Summary
Draft / recovery hardening for
ChatPane, split out of #8852 (stacked onfeat/members-chat-steer-only) so the steer-only product change stays minimal and this robustness work can converge on its own.ChatPane's composer is pane-local state, while its host can rebind the same instance to another slot (the Members page switchesslotKeyon member click, nokey) or unmount it with sends and uploads still in flight. Anything that resolves late — a refused or unconfirmed send, a finished or failed upload, a steer the server demoted to the queue — then lands on whatever is on screen, or nowhere. This PR routes every late result to the slot it belongs to: its live composer, its parked draft, or its transcript.What the review found (GPT 5.6 on #8852, quoted)
Round 1:
Round 2:
Round 4:
Round 5:
Round 7:
Round 8:
Round 9:
Round 10:
First Principles (round 6) additionally noted that an in-memory per-slot Map loses the parked drafts on unmount; the store below is the answer.
What changed
utils/chatPaneDrafts.ts(new) — the pane's per-slot draft store, an instance of the repo'screateSlotDraftStoreon pane-owned keys (mc-pane-drafts/mc-pane-file-drafts, both sessionStorage with a 256 KiB cap — parking is a within-session hand-off, and staying out of localStorage keeps it clear of the quota ChatPage's two 2 MiB stores share). Every parked draft is also mirrored in memory (write-through), so a persist the browser refuses still hands the draft back for the life of the tab. Separate keys, notchatDrafts/chatFileDrafts: ChatPage holds those maps in memory and persists them wholesale on a debounce, so a second writer on the same key would be overwritten and never read until a reload. The pane does read-modify-write on every access, so split-view panes can share the keys.takePaneDraft(read + clear) andsubscribePaneDraftlet a pane that shows a slot own the one live copy and receive late arrivals for it.ChatPane—restoreIntoComposer(text, files, forSlot): a recovery for a slot no longer on screen (or a pane no longer mounted) merges into that slot's parked draft; every recovery site passes the sending slot;sendIdcounts as delivered (queue cards carry none — text-matching cannot tell this send's card from anyone's identical "ok");ErrorNoticeonmain) is fed from the per-slot failure map, so a failure never shows over another member's thread;{ raw, files, sent }to itsqueue_idinqueuedSendStash, so cancelling the card restores typed text and re-stages files, exactly asdoSenddoes.Tests (run in CI) —
src/test/ChatPane.draftRecovery.test.tsx(11 cases) +src/test/chatPaneDrafts.test.ts(4 cases: park/take, sessionStorage not localStorage, mirror survives a refused write, subscriber scoping)Sub-agents-only busy + response-late restores the draft and warns; an identical queue card does not stand in for the unconfirmed send; a demoted steer binds pre-send state to its queue card; rebinding parks the draft; a refusal after a member switch restores into the SENDING member; a late upload after a switch stages for the member it was picked in; a late upload FAILURE after a switch lands in that member's transcript; the on-screen failure renders through
ErrorNoticeand is dismissible; a refusal after close + REOPEN reaches the new pane and survives its next park; an upload failure after unmount lands in the transcript; a draft survives unmount plus a refusal that lands while the pane is gone.Local gates:
tsc -b,eslint,i18n-check,jscpdclean.Screenshots
ErrorNoticebanner, draft intactPer-member draft parking (webm): 12-member-switch-draft-parking.webm — type for radar, switch to fixer (empty composer), type for fixer, back to radar (radar's text is back).
Pattern harvest
Rule candidate: review lens — an async callback that writes component state must carry the identity it was started for. Every finding above has the same shape: a promise started for slot A (
sendTurn,uploadFiles) resolves after the pane was rebound to slot B or unmounted, and its.thenwritessetInput/setPendingFiles/setUploadErroragainst whatever is current. The general fix is what this PR does — capture the target (slotKey) at call time, route the result through a slot-keyed store or the store-backed transcript when the target is no longer on screen, and make the live composer the single copy (take + subscribe). Candidate mechanical check: flaguseMutation/.thencallbacks in a component whose props include a slot/session id but whose closure does not reference it.Follow-ups
Pre-existing (main,
doSendand ChatPagesend):queuedSendStashis bound after the HTTP receipt, so aqueue_pushthat arrives first exposes a cancel that restores text without attachments. Closing it needsqueue_pushto carry the send'ssendId(backend).doSteer/ChatPage.steerMutationreceipt policy and the two draft stores are candidates for one chat-core consolidation with the ChatPage side (P5).Server refusal reasons are backend-owned strings; plain-language variants are a backend copy task.