feat(chat-core): route ChatPage's send and steer through the transport - #8689
Conversation
UX Review (Fable 5) — ✅ PASSUX-level review of UX-Verdict: PASS Failed and unconfirmed steers now visibly fail, hand the text back, and say what to do next — previously they died in the console. [UX-REVIEWED] 935f280 |
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Additive transport flags plus per-surface receipt policy is the established series shape; the steer gains a deadline and a recovery path it never had, with each new window reasoned and tested. [DESIGN-REVIEWED] 935f280 |
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: |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of All mechanical checks done. Verified: First-Principles-Verdict: PASS Migrates the last main composer's send and steer onto the existing transport, deleting the bespoke steer POST; every rider is declared with a named defect. What this change shipsIntent: make ChatPage's send and mid-turn steer go through the shared chat-core transport so the send contract lives in one place. FIX-shaped consolidation (deletes duplicated transport logic) with declared behavior fixes riding along.
Watch
[FIRST-PRINCIPLES-REVIEWED] 935f280 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsNo findings. The single candidate — [OPUS-REVIEWED] 935f280 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
0e79150 to
05a7d0d
Compare
|
GPT — BLOCKING: the deadline path can discard a steer that never reached the gateway ( Accepted — this PR gives the steer a deadline it never had, so a stalled socket the abort kills is a new loss window on text the composer already cleared, and the optimistic bubble is not persisted. Fixed with the receipt policy the rest of the series uses rather than a retain-until-echo state machine (which is #6825's bubble-fate work): |
|
First Principles — Subtraction: delete Agreed and recorded: |
05a7d0d to
cd8ef7d
Compare
|
GPT — BLOCKING: failed steers are restored into the wrong session / Opus — FINDING: same / UX — Suggestion: same ( Accepted (three lanes, one gap). The steer's |
|
UX — Watch: "Check the transcript" cannot be acted on (the optimistic bubble reads as delivered) ( Accepted. On |
|
UX — Watch: decision-critical copy rendered as routine (info tone, nothing announced) ( Accepted. The dispatch prefixes |
|
UX — Watch: a refused steer says nothing at all ( Accepted. |
|
GPT — BLOCKING: transport rejection does not prove the steer never arrived; treat The transport contract ( |
|
Design — Suggestion: migrate Agreed — recorded on the remaining-sites list (its plain send and its steer both move onto |
|
/ai-review override gpt cd8ef7d: transport-error is defined by the sendTurn contract (pinned by chatCoreTransport.contract.test.ts) as never-left/restore-safe, and every caller in the series incl. send() in this file treats it so; a per-caller reclassification would make the steer the one path disagreeing with the contract. The branch now also appends the connection error row, so the outcome is visible. Changing the vocabulary belongs to a transport PR. |
|
/ai-review override gpt cd8ef7d: transport-error is defined by the sendTurn contract (pinned by chatCoreTransport.contract.test.ts) as never-left/restore-safe, and every caller in the series incl. send() in this file treats it so; a per-caller reclassification would make the steer the one path disagreeing with the contract. The branch now also appends the connection error row, so the outcome is visible. Changing the vocabulary belongs to a transport PR. |
663bc3a to
5f35ccc
Compare
|
GPT — BLOCKING: late send failures corrupt the newly active session ( Accepted. The old |
ChatPage's composer send and its mid-turn steer each hand-rolled the POST to /api/chat: an AbortController and 10 s timer, the resolves-not-rejects trap, readSendReceipt, and a catch that had to tell an abort from a network failure. The steer path was worse -- a separate api.steerChat helper with no deadline whose failure was only a console line. Both now call sendTurn (chat-core transport) and branch on receipt.status; the receipt policy (error row, composer restore, optimistic confirm, card retirement, ask resolution) is unchanged and stays on the surface. sendTurn gains the two flags this endpoint takes that ChatPage sets: steer (inject into the running turn / skip the sub-agent hold) and colorTheme. api.steerChat has no send-path consumer left on this surface (useSceneInteraction still uses it; its own P2 slice). RFC chat-core extraction, P2 (transport), third slice: after ChatPane (#5909), ChatEmbed (#8599) and SideChat (#8655), every user-facing composer send is on sendTurn.
5f35ccc to
935f280
Compare
chenmingwei23
left a comment
There was a problem hiding this comment.
Approved: checks green at head, readiness passed, no blocking reviewer verdict outstanding.
Problem / Motivation
ChatPage— the main chat surface — was the last user-facing composer still hand-rolling its send (App.tsx's feedback send also hand-rollsapi.sendChat+readSendReceipt, with no deadline; it is not a composer and is listed below as its own slice).send()owned anAbortControllerand a 10 s timer, calledapi.sendChat, read the body throughreadSendReceipt, and had acatchthat told anAbortErrorfrom a network failure by hand. Its mid-turn steer was a second, worse copy: a separateapi.steerChathelper posting to the same/api/chat?ws=1endpoint withsteer: true, no deadline at all, and a failure that surfaced only as a console line. Every rule the transport layer exists to own — the resolves-not-rejects trap, the deadline, the unreadable-2xx classification — was re-learned here, twice.Why it matters
This is RFC chat-core extraction P2 (transport), third and last composer slice. With ChatPane (#5909, merged) and this PR, both
main-based composer sends go throughwebsite/src/chat-core/transport/sendTurn.ts; ChatEmbed (#8599) and SideChat (#8655) are in review on their own branches, and once those land every user-facing composer send is on the transport, so a change to the send contract is made once. Merge order among the three is free — this PR touchessendTurn.tsonly additively (two optional flags). It also removes the last bespoke steer POST, so a steer is classified like any send —dispatched,queued(demoted) orrefused— instead of "resolved or threw".What changed (motivation → approach → change)
Transport (
sendTurn.ts).SendTurnOptionsgains the two flags this endpoint takes that ChatPage sets:steer("act on this now" — inject into the running turn, or on an idle slot skip the hold that parks a message behind still-running sub-agents; a flag of the endpoint, not a new receipt shape —body.steeredsays which happened) andcolorTheme(so a widget the turn renders inherits the active theme; only a surface that owns a theme sends it). Both are forwarded toapi.sendChatand absent from the wire when unset. Contract test +1.ChatPage
send(). The controller/timer/api.sendChat/readSendReceipt/try-catchblock is replaced by oneawait sendTurn({ message, slot, meta, steer, colorTheme })and a status switch that maps 1:1 onto the previous branches:transport-error→ the old catch branch (connection error row, running cleared, composer restored) — with both failure branches now addressed to the sending slot: a new slot-keyedendLocalTurn(slot)(the inverse of thestartLocalTurn(slot)the send dispatched) replacessetSlotRunning(false), and the error row goes throughappendSlotMessage({ slot }), so a failure that lands after the user switched to a running session neither clears that session's running state nor drops its error there (the old code did both);response-late→ the oldAbortErrorbranch (nothing: the message was received, the WS delivers, the bubble's delivery indicator says "unconfirmed");refused→ the old refused branch, withreceipt.reasonnow framed throughpages.chatPage.send_failed_with_error("Send failed: …") like the steer's and ChatEmbed's refusals, instead of the raw backend reason;unknown→ falls through exactly as before (the body-driven steps below readok/queuedand are no-ops on an empty body);dispatched/queued→ the oldacceptedpath. The queued-send stash, steer-echo append,confirmOptimisticSend, stateless-card and folder-card retirement, andresolveAskAfterSendare byte-for-byte the previous code, now one indentation level shallower.ChatPage steer.
steerMutation'smutationFnissendTurn({ message, slot, steer: true, meta: { sendId } });onSuccessreads the receipt. The composer is cleared at submit and the optimistic bubble is not persisted (the next transcript rebuild drops it), so a steer that did not provably reach the gateway hands its text back — merged into the draft of the slot it was sent from (mergeRecoveredDraft, the helper every recovery site in this file uses), and every row it appends goes throughappendSlotMessage({ slot })— so a receipt that lands after the user switched sessions restores into the originating session's draft and transcript, never the one on screen. "On screen" is decided bycomposerSlotRef(which slot the live composer state belongs to), notactiveSlotRef: during a switch the active slot flips before the composer flushes the outgoing slot's text, and a draft written in that window would be overwritten by that flush — updating the live input instead is what the flush then persists.send()'srestoreComposerAfterFailedSendtakes the same ownership test in this PR, closing the same window there. Before this PR a failed steer's text was simply gone; attachments and pastes still are, as documented at the call site.refused/transport-error→ the optimistic bubble is dropped (the reducer's drop arm; standing, it would be a third, false representation of the same text next to the error row and the refilled composer), then an error row (the server's reason framed throughsend_failed_with_error, or the cause-stating connection copy that names the restore —pages.chatPage.send_failed_connection, "Couldn't send — check your connection and try again.", new here with the same copy #8655 promotes to this key;send()'s own transport failure takes it too instead of a bare "Connection error", and so does ChatPane's reason-less transport failure — the one already-on-transport sibling that still said bare "Send failed"; the same hunk #8655 carries, so the branches merge cleanly) + restore;response-late→ if the server's echo has already reconciled the bubble the steer landed and nothing happens; otherwise the optimistic bubble is removed throughresolveOptimisticSteer's existing drop arm (queued; standing, the bubble would read as delivered and make "check the transcript" unanswerable — a late echo re-creates the row from the server's copy), the text is restored, and a warn-tonenoticerow (⚠️lead glyph =NoticeCard's tone selector and its "Warning" screen-reader label; the glyph is added at the dispatch so the catalog string stays shared) says "Delivery not confirmed — your text is back in the composer. Check the transcript before sending it again." (new keypages.chatPage.delivery_unconfirmed, 12 locales + en-XA, the same copy #8655 promotes to this key) — the steer never had a deadline before this transport, so a stalled socket the abort kills is a new way for the text to be lost, and the notice makes a duplicate visible rather than a loss silent;unknown→ nothing (accepted, receipt unreadable);steered→ keep the badge; otherwiseresolveOptimisticSteertoqueuedorturnas before.Not in this PR (by design). No render-chain change — the 29
role ===branches, the composer, the virtualizer are untouched (P5-a is the next slice).api.steerChatstays in the client:hooks/useSceneInteraction.tsxstill calls it and is its own P2 item.mintSendIdstays local to ChatPage here; #8599 hoists it to chat-core and this branch takes that on rebase.Tests
chatCoreTransport.contract.test.ts(+1):steerandcolor_themeride the wire when set and are absent when not; a steered acceptance is classifieddispatchedwithbody.steered.chatSlice.test.ts(+3):endLocalTurnclears the active slot's footer and pending mark; leaves a switched-to running session alone; leaves another slot's pending mark alone.ChatPane.dirSend.test.tsx(updated): a reason-less transport failure on the pane pins the connection copy.ChatPage.steerQueuedReceipt.test.tsx(+4) /ChatPage.steerWithSubagents.test.tsx/SideSlashCommand.steer.test.tsx(updated): the mid-turn steer now assertsapi.sendChatwith the steer flag and ameta.sendIdand no theme, and thatapi.steerChatis never called; the idle steer-flagged send asserts the theme is attached (the two paths stay distinguishable on the wire). New: a refused steer drops its bubble, restores the text and appends "Send failed: "; a never-left steer drops its bubble and restores with the cause-stating connection copy; a deadline-aborted steer removes the unconfirmed bubble, restores, and appends theChatPage*test files (578 tests across all 63, plus the 22 non-ChatPage suites that mockapi.sendChat) pass as-is, including every send-failure, send-confirm, knowledge-merge, create-failure and dictation suite that mocksapi.sendChatwith a Response-shaped value — the transport calls the same client method with the same arguments.Local gates:
tsc -b, eslint on changed files,check-i18n-strings(0 added), allChatPage*+ transport contract tests. The full vitest suite runs in CI.Manual verification
N/A — pure transport collapse with 1:1 branch mapping and deterministic coverage; the one new visible element is the existing
noticerow (NoticeCard, already rendered by this surface) carrying one new sentence, on a path (a steer whose POST the deadline aborted) that needs a stalled gateway to reach.Why no screenshot: no new component, style, layout, motion or affordance — the deadline-aborted steer reuses the surface's existing
noticerow renderer with new copy; everything else maps each receipt status onto the branch the surface already had.Related Issues
sendTurnafter this, each its own slice:App.tsx:2723feedback send (hand-rolledapi.sendChat+readSendReceipt, no deadline),hooks/useSceneInteraction.tsx:355(+ the lastapi.steerChatcaller at:347, whose slice deletes the helper),apps/issue-radar/lib/agentSession.ts:283andapps/auto-improvement/lib/agentSession.ts:240(hand-rolled classification, no deadline; non-interactive, so they need the receipt classification, not the composer-recovery policy),apps/mochi/panel/panelBridge.ts,apps/design-tweak/api.ts,apps/design-critique/api.ts.markSendFailed) re-bases onto transport callers after this lands; not imported here.Checklist
pages.chatPage.delivery_unconfirmed,pages.chatPage.send_failed_connection), 12 locales + en-XA, style gates pass; both share their copy with feat(chat-core): route SideChat's send through the transport over a side wire #8655 so the branches convergeContribution License Agreement
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.