feat(chat-core): mount the real ChatInput composer in ChatEmbed - #8631
Conversation
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Sound consolidation onto the shared composer, but it spends app-sdk's store-free invariant on a public-barrel export — a human must own that trade. Watch
[DESIGN-REVIEWED] 3ab2982 |
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) — 🟡 CONCERNSPremise-level review of All counts verified. The description's claims hold: SideChat never used the deleted hook members, the First-Principles-Verdict: CONCERNS One item needs a human's eyes: a public export's documented "No Redux dependency" contract is dropped, with the store-free alternative deferred rather than weighed here. What this change shipsIntent: make the app-sdk embed render the one shared composer instead of its own bare input (chat-core RFC P3) — an ADDITION (convergence step).
Watch
[FIRST-PRINCIPLES-REVIEWED] 3ab2982 |
UX Review (Fable 5) — ✅ PASSUX-level review of UX-Verdict: PASS The embed inherits the real composer's full contract — IME safety, send-key setting, multi-line drafts, and a spinner-acknowledged send — with app-only chrome cleanly absent. [UX-REVIEWED] 3ab2982 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsNo findings. The sole candidate (ChatEmbed now requiring a Redux store) is a deliberate, documented contract change, not a defect: both shipping in-tree consumers ( [OPUS-REVIEWED] 3ab2982 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
023e31e to
7e2c87c
Compare
|
Design — Watch: Accepted, fixed. Audited every dashboard-client call inside |
|
Design — Suggestion: state which Implemented. The |
|
First Principles — Subtraction: delete Applied. |
|
First Principles — Subtraction: delete the false "No Redux dependency" header sentence ( Applied. The header now reads: transcript and send state live in |
|
Design — Watch/Suggestion: pin the invariant, not the instance ( Implemented as prescribed. New |
7e2c87c to
770b5c9
Compare
770b5c9 to
c587610
Compare
|
Design — Watch/Suggestion: capability props default on; give the embed a fail-closed preset ( Implemented as suggested. |
|
Design — Watch: "must mount under the dashboard store" narrows where ChatEmbed can live once the SDK publishes standalone ( Implemented. The |
|
First Principles — Watch: the prefetch gate also changes SideChat ( Confirmed and now stated in the body. SideChat passes |
|
First Principles — Watch: public-export contract flip not in the description ( Fixed. The body now has a "Contract change on a public export" paragraph naming the old "No Redux dependency" sentence, the new requirement, the two in-tree hosts that already satisfy it, and the consequence for an out-of-tree host; the SDK barrel carries the same note for the publish plan. |
|
UX — Suggestion: forward the user's send-key setting ( Implemented. ChatEmbed reads |
ChatEmbed's bare <input> + send button becomes the native ChatInput inside a SlotProvider for the embedded slot, narrowed by omission: no upload, voice, agent/model/project chrome, typed command menus, prompt optimizer or slot-approval chrome. Enter/IME handling, autosize, the running-state send affordance and the composer's a11y contract are now the shared ones. The single-line space-flatten on recovered drafts goes away with the input it existed for; the shared paragraph-break join renders in the textarea. Two now-unreferenced app-sdk keys (send, send_message) are removed from every catalog. The flex-input-min-w-0 harness drops its ChatEmbed replica scene.
ChatEmbed's bare <input> + send button becomes the native ChatInput inside a SlotProvider for the embedded slot, narrowed by omission: no upload, voice, agent/model/project chrome, typed command menus, prompt optimizer or slot-approval chrome. Enter/IME handling, autosize, the running-state send affordance and the composer's a11y contract are now the shared ones. The single-line space-flatten on recovered drafts goes away with the input it existed for; the shared paragraph-break join renders in the textarea. Two now-unreferenced app-sdk keys (send, send_message) are removed from every catalog. The flex-input-min-w-0 harness drops its ChatEmbed replica scene.
ChatEmbed's bare <input> + send button becomes the native ChatInput inside a SlotProvider for the embedded slot, narrowed by omission: no upload, voice, agent/model/project chrome, typed command menus, prompt optimizer or slot-approval chrome. Enter/IME handling, autosize, the running-state send affordance and the composer's a11y contract are now the shared ones. The single-line space-flatten on recovered drafts goes away with the input it existed for; the shared paragraph-break join renders in the textarea. Two now-unreferenced app-sdk keys (send, send_message) are removed from every catalog. The flex-input-min-w-0 harness drops its ChatEmbed replica scene.
ChatEmbed's bare <input> + send button becomes the native ChatInput inside a SlotProvider for the embedded slot, narrowed by omission: no upload, voice, agent/model/project chrome, typed command menus, prompt optimizer or slot-approval chrome. Enter/IME handling, autosize, the running-state send affordance and the composer's a11y contract are now the shared ones. The single-line space-flatten on recovered drafts goes away with the input it existed for; the shared paragraph-break join renders in the textarea. Two now-unreferenced app-sdk keys (send, send_message) are removed from every catalog. The flex-input-min-w-0 harness drops its ChatEmbed replica scene.
ChatEmbed's bare <input> + send button becomes the native ChatInput inside a SlotProvider for the embedded slot, narrowed by omission: no upload, voice, agent/model/project chrome, typed command menus, prompt optimizer or slot-approval chrome. Enter/IME handling, autosize, the running-state send affordance and the composer's a11y contract are now the shared ones. The single-line space-flatten on recovered drafts goes away with the input it existed for; the shared paragraph-break join renders in the textarea. Two now-unreferenced app-sdk keys (send, send_message) are removed from every catalog. The flex-input-min-w-0 harness drops its ChatEmbed replica scene.
ChatEmbed's bare <input> + send button becomes the native ChatInput inside a SlotProvider for the embedded slot, narrowed by omission: no upload, voice, agent/model/project chrome, typed command menus, prompt optimizer or slot-approval chrome. Enter/IME handling, autosize, the running-state send affordance and the composer's a11y contract are now the shared ones. The single-line space-flatten on recovered drafts goes away with the input it existed for; the shared paragraph-break join renders in the textarea. Two now-unreferenced app-sdk keys (send, send_message) are removed from every catalog. The flex-input-min-w-0 harness drops its ChatEmbed replica scene.
ChatEmbed (the app-sdk's embeddable chat) posted to the bare /api/chat, read its SSE stream as JSON, and called the resulting SyntaxError success; a refused POST rejected the mutation nothing read. Either way the composer had already been cleared, so a failed send looked sent and the text was gone. sendTurn gains an injectable wire (the fetch seam: one POST that resolves on every HTTP status and rejects only when the request never left or the deadline fired); dashboardSendWire is the default, so ChatPane and ChatPage are unchanged. appSendWire re-expresses the scoped AppApi outcomes in that shape (2xx JSON -> ok; 2xx non-JSON -> unknown; non-2xx -> refused with the server's reason via a typed AppApiError; permission denial -> refused with a human sentence; rejected fetch -> transport-error; deadline -> response-late). ChatEmbed sends through sendTurn over that wire. refused / transport-error render an error row and hand the draft back as typed; response-late renders a "Delivery not confirmed" notice and hands the draft back, retired only when the poll shows THIS send's own user row past the send-start point -- by identity only (meta.sendId, or membership in a merged row's meta.sendIds), never by text. The server keeps the id on every path a send can take: a dispatched send persists it on its row, and a send queued behind a busy slot carries it through the queue entry onto the drained row (#8853), so there is no text-matching fallback to false-retire on. unknown does nothing; dispatched / queued let the poll render. mintSendId now lives once in chat-core/transport; ChatPane's and ChatPage's inline minters use it. useChatSession's seed send moves off the bare SSE endpoint onto the same wire (fire-and-forget; the slot is the deliverable). The composer swap (#8631): ChatEmbed's bare <input> + send button becomes the native ChatInput inside a SlotProvider for the embedded slot, narrowed by omission (no upload, voice, agent/model/project chrome, typed command menus, prompt optimizer or slot-approval chrome) with a fail-closed `embedded` preset, a `sending` prop, and sendOnEnter via the new useChatConfig hook. Enter/IME handling, autosize, the running-state send affordance and the composer's a11y contract are now the shared ones; recovered drafts keep their paragraph breaks. Two now-unreferenced app-sdk keys (send, send_message) are removed from every catalog.
ChatEmbed (the app-sdk's embeddable chat) posted to the bare /api/chat, read its SSE stream as JSON, and called the resulting SyntaxError success; a refused POST rejected the mutation nothing read. Either way the composer had already been cleared, so a failed send looked sent and the text was gone. sendTurn gains an injectable wire (the fetch seam: one POST that resolves on every HTTP status and rejects only when the request never left or the deadline fired); dashboardSendWire is the default, so ChatPane and ChatPage are unchanged. appSendWire re-expresses the scoped AppApi outcomes in that shape (2xx JSON -> ok; 2xx non-JSON -> unknown; non-2xx -> refused with the server's reason via a typed AppApiError; permission denial -> refused with a human sentence; rejected fetch -> transport-error; deadline -> response-late). ChatEmbed sends through sendTurn over that wire. refused / transport-error render an error row and hand the draft back as typed; response-late renders a "Delivery not confirmed" notice and hands the draft back, retired only when the poll shows THIS send's own user row past the send-start point -- by identity only (meta.sendId, or membership in a merged row's meta.sendIds), never by text. The server keeps the id on every path a send can take: a dispatched send persists it on its row, and a send queued behind a busy slot carries it through the queue entry onto the drained row (#8853), so there is no text-matching fallback to false-retire on. unknown does nothing; dispatched / queued let the poll render. mintSendId now lives once in chat-core/transport; ChatPane's and ChatPage's inline minters use it. useChatSession's seed send moves off the bare SSE endpoint onto the same wire (fire-and-forget; the slot is the deliverable). The composer swap (#8631): ChatEmbed's bare <input> + send button becomes the native ChatInput inside a SlotProvider for the embedded slot, narrowed by omission (no upload, voice, agent/model/project chrome, typed command menus, prompt optimizer or slot-approval chrome) with a fail-closed `embedded` preset, a `sending` prop, and sendOnEnter via the new useChatConfig hook. Enter/IME handling, autosize, the running-state send affordance and the composer's a11y contract are now the shared ones; recovered drafts keep their paragraph breaks. Two now-unreferenced app-sdk keys (send, send_message) are removed from every catalog.
ChatEmbed (the app-sdk's embeddable chat) posted to the bare /api/chat, read its SSE stream as JSON, and called the resulting SyntaxError success; a refused POST rejected the mutation nothing read. Either way the composer had already been cleared, so a failed send looked sent and the text was gone. sendTurn gains an injectable wire (the fetch seam: one POST that resolves on every HTTP status and rejects only when the request never left or the deadline fired); dashboardSendWire is the default, so ChatPane and ChatPage are unchanged. appSendWire re-expresses the scoped AppApi outcomes in that shape (2xx JSON -> ok; 2xx non-JSON -> unknown; non-2xx -> refused with the server's reason via a typed AppApiError; permission denial -> refused with a human sentence; rejected fetch -> transport-error; deadline -> response-late). ChatEmbed sends through sendTurn over that wire. refused / transport-error render an error row and hand the draft back as typed; response-late renders a "Delivery not confirmed" notice and hands the draft back, retired only when the poll shows THIS send's own user row past the send-start point -- by identity only (meta.sendId, or membership in a merged row's meta.sendIds), never by text. The server keeps the id on every path a send can take: a dispatched send persists it on its row, and a send queued behind a busy slot carries it through the queue entry onto the drained row (#8853), so there is no text-matching fallback to false-retire on. unknown does nothing; dispatched / queued let the poll render. mintSendId now lives once in chat-core/transport; ChatPane's and ChatPage's inline minters use it. useChatSession's seed send moves off the bare SSE endpoint onto the same wire (fire-and-forget; the slot is the deliverable). The composer swap (#8631): ChatEmbed's bare <input> + send button becomes the native ChatInput inside a SlotProvider for the embedded slot, narrowed by omission (no upload, voice, agent/model/project chrome, typed command menus, prompt optimizer or slot-approval chrome) with a fail-closed `embedded` preset, a `sending` prop, and sendOnEnter via the new useChatConfig hook. Enter/IME handling, autosize, the running-state send affordance and the composer's a11y contract are now the shared ones; recovered drafts keep their paragraph breaks. Two now-unreferenced app-sdk keys (send, send_message) are removed from every catalog.
ChatEmbed (the app-sdk's embeddable chat) posted to the bare /api/chat, read its SSE stream as JSON, and called the resulting SyntaxError success; a refused POST rejected the mutation nothing read. Either way the composer had already been cleared, so a failed send looked sent and the text was gone. sendTurn gains an injectable wire (the fetch seam: one POST that resolves on every HTTP status and rejects only when the request never left or the deadline fired); dashboardSendWire is the default, so ChatPane and ChatPage are unchanged. appSendWire re-expresses the scoped AppApi outcomes in that shape (2xx JSON -> ok; 2xx non-JSON -> unknown; non-2xx -> refused with the server's reason via a typed AppApiError; permission denial -> refused with a human sentence; rejected fetch -> transport-error; deadline -> response-late). ChatEmbed sends through sendTurn over that wire. refused / transport-error render an error row and hand the draft back as typed; response-late renders a "Delivery not confirmed" notice and hands the draft back, retired only when the poll shows THIS send's own user row past the send-start point -- by identity only (meta.sendId, or membership in a merged row's meta.sendIds), never by text. The server keeps the id on every path a send can take: a dispatched send persists it on its row, and a send queued behind a busy slot carries it through the queue entry onto the drained row (#8853), so there is no text-matching fallback to false-retire on. unknown does nothing; dispatched / queued let the poll render. mintSendId now lives once in chat-core/transport; ChatPane's and ChatPage's inline minters use it. useChatSession's seed send moves off the bare SSE endpoint onto the same wire (fire-and-forget; the slot is the deliverable). The composer swap (#8631): ChatEmbed's bare <input> + send button becomes the native ChatInput inside a SlotProvider for the embedded slot, narrowed by omission (no upload, voice, agent/model/project chrome, typed command menus, prompt optimizer or slot-approval chrome) with a fail-closed `embedded` preset, a `sending` prop, and sendOnEnter via the new useChatConfig hook. Enter/IME handling, autosize, the running-state send affordance and the composer's a11y contract are now the shared ones; recovered drafts keep their paragraph breaks. Two now-unreferenced app-sdk keys (send, send_message) are removed from every catalog.
ChatEmbed (the app-sdk's embeddable chat) posted to the bare /api/chat, read its SSE stream as JSON, and called the resulting SyntaxError success; a refused POST rejected the mutation nothing read. Either way the composer had already been cleared, so a failed send looked sent and the text was gone. sendTurn gains an injectable wire (the fetch seam: one POST that resolves on every HTTP status and rejects only when the request never left or the deadline fired); dashboardSendWire is the default, so ChatPane and ChatPage are unchanged. appSendWire re-expresses the scoped AppApi outcomes in that shape (2xx JSON -> ok; 2xx non-JSON -> unknown; non-2xx -> refused with the server's reason via a typed AppApiError; permission denial -> refused with a human sentence; rejected fetch -> transport-error; deadline -> response-late). ChatEmbed sends through sendTurn over that wire. refused / transport-error render an error row and hand the draft back as typed; response-late renders a "Delivery not confirmed" notice and hands the draft back, retired only when the poll shows THIS send's own user row past the send-start point -- by identity only (meta.sendId, or membership in a merged row's meta.sendIds), never by text. The server keeps the id on every path a send can take: a dispatched send persists it on its row, and a send queued behind a busy slot carries it through the queue entry onto the drained row (#8853), so there is no text-matching fallback to false-retire on. unknown does nothing; dispatched / queued let the poll render. mintSendId now lives once in chat-core/transport; ChatPane's and ChatPage's inline minters use it. useChatSession's seed send moves off the bare SSE endpoint onto the same wire (fire-and-forget; the slot is the deliverable). The composer swap (#8631): ChatEmbed's bare <input> + send button becomes the native ChatInput inside a SlotProvider for the embedded slot, narrowed by omission (no upload, voice, agent/model/project chrome, typed command menus, prompt optimizer or slot-approval chrome) with a fail-closed `embedded` preset, a `sending` prop, and sendOnEnter via the new useChatConfig hook. Enter/IME handling, autosize, the running-state send affordance and the composer's a11y contract are now the shared ones; recovered drafts keep their paragraph breaks. Two now-unreferenced app-sdk keys (send, send_message) are removed from every catalog.
ChatEmbed (the app-sdk's embeddable chat) posted to the bare /api/chat, read its SSE stream as JSON, and called the resulting SyntaxError success; a refused POST rejected the mutation nothing read. Either way the composer had already been cleared, so a failed send looked sent and the text was gone. sendTurn gains an injectable wire (the fetch seam: one POST that resolves on every HTTP status and rejects only when the request never left or the deadline fired); dashboardSendWire is the default, so ChatPane and ChatPage are unchanged. appSendWire re-expresses the scoped AppApi outcomes in that shape (2xx JSON -> ok; 2xx non-JSON -> unknown; non-2xx -> refused with the server's reason via a typed AppApiError; permission denial -> refused with a human sentence; rejected fetch -> transport-error; deadline -> response-late). ChatEmbed sends through sendTurn over that wire. refused / transport-error render an error row and hand the draft back as typed; response-late renders a "Delivery not confirmed" notice and hands the draft back, retired only when the poll shows THIS send's own user row past the send-start point -- by identity only (meta.sendId, or membership in a merged row's meta.sendIds), never by text. The server keeps the id on every path a send can take: a dispatched send persists it on its row, and a send queued behind a busy slot carries it through the queue entry onto the drained row (#8853), so there is no text-matching fallback to false-retire on. unknown does nothing; dispatched / queued let the poll render. mintSendId now lives once in chat-core/transport; ChatPane's and ChatPage's inline minters use it. useChatSession's seed send moves off the bare SSE endpoint onto the same wire (fire-and-forget; the slot is the deliverable). The composer swap (#8631): ChatEmbed's bare <input> + send button becomes the native ChatInput inside a SlotProvider for the embedded slot, narrowed by omission (no upload, voice, agent/model/project chrome, typed command menus, prompt optimizer or slot-approval chrome) with a fail-closed `embedded` preset, a `sending` prop, and sendOnEnter via the new useChatConfig hook. Enter/IME handling, autosize, the running-state send affordance and the composer's a11y contract are now the shared ones; recovered drafts keep their paragraph breaks. Two now-unreferenced app-sdk keys (send, send_message) are removed from every catalog.
ChatEmbed (the app-sdk's embeddable chat) posted to the bare /api/chat, read its SSE stream as JSON, and called the resulting SyntaxError success; a refused POST rejected the mutation nothing read. Either way the composer had already been cleared, so a failed send looked sent and the text was gone. sendTurn gains an injectable wire (the fetch seam: one POST that resolves on every HTTP status and rejects only when the request never left or the deadline fired); dashboardSendWire is the default, so ChatPane and ChatPage are unchanged. appSendWire re-expresses the scoped AppApi outcomes in that shape (2xx JSON -> ok; 2xx non-JSON -> unknown; non-2xx -> refused with the server's reason via a typed AppApiError; permission denial -> refused with a human sentence; rejected fetch -> transport-error; deadline -> response-late). ChatEmbed sends through sendTurn over that wire. refused / transport-error render an error row and hand the draft back as typed; response-late renders a "Delivery not confirmed" notice and hands the draft back, retired only when the poll shows THIS send's own user row past the send-start point -- by identity only (meta.sendId, or membership in a merged row's meta.sendIds), never by text. The server keeps the id on every path a send can take: a dispatched send persists it on its row, and a send queued behind a busy slot carries it through the queue entry onto the drained row (#8853), so there is no text-matching fallback to false-retire on. unknown does nothing; dispatched / queued let the poll render. mintSendId now lives once in chat-core/transport; ChatPane's and ChatPage's inline minters use it. useChatSession's seed send moves off the bare SSE endpoint onto the same wire (fire-and-forget; the slot is the deliverable). The composer swap (#8631): ChatEmbed's bare <input> + send button becomes the native ChatInput inside a SlotProvider for the embedded slot, narrowed by omission (no upload, voice, agent/model/project chrome, typed command menus, prompt optimizer or slot-approval chrome) with a fail-closed `embedded` preset, a `sending` prop, and sendOnEnter via the new useChatConfig hook. Enter/IME handling, autosize, the running-state send affordance and the composer's a11y contract are now the shared ones; recovered drafts keep their paragraph breaks. Two now-unreferenced app-sdk keys (send, send_message) are removed from every catalog.
ChatEmbed (the app-sdk's embeddable chat) posted to the bare /api/chat, read its SSE stream as JSON, and called the resulting SyntaxError success; a refused POST rejected the mutation nothing read. Either way the composer had already been cleared, so a failed send looked sent and the text was gone. sendTurn gains an injectable wire (the fetch seam: one POST that resolves on every HTTP status and rejects only when the request never left or the deadline fired); dashboardSendWire is the default, so ChatPane and ChatPage are unchanged. appSendWire re-expresses the scoped AppApi outcomes in that shape (2xx JSON -> ok; 2xx non-JSON -> unknown; non-2xx -> refused with the server's reason via a typed AppApiError; permission denial -> refused with a human sentence; rejected fetch -> transport-error; deadline -> response-late). ChatEmbed sends through sendTurn over that wire. refused / transport-error render an error row and hand the draft back as typed; response-late renders a "Delivery not confirmed" notice and hands the draft back, retired only when the poll shows THIS send's own user row past the send-start point -- by identity only (meta.sendId, or membership in a merged row's meta.sendIds), never by text. The server keeps the id on every path a send can take: a dispatched send persists it on its row, and a send queued behind a busy slot carries it through the queue entry onto the drained row (#8853), so there is no text-matching fallback to false-retire on. unknown does nothing; dispatched / queued let the poll render. mintSendId now lives once in chat-core/transport; ChatPane's and ChatPage's inline minters use it. useChatSession's seed send moves off the bare SSE endpoint onto the same wire (fire-and-forget; the slot is the deliverable). The composer swap (#8631): ChatEmbed's bare <input> + send button becomes the native ChatInput inside a SlotProvider for the embedded slot, narrowed by omission (no upload, voice, agent/model/project chrome, typed command menus, prompt optimizer or slot-approval chrome) with a fail-closed `embedded` preset, a `sending` prop, and sendOnEnter via the new useChatConfig hook. Enter/IME handling, autosize, the running-state send affordance and the composer's a11y contract are now the shared ones; recovered drafts keep their paragraph breaks. Two now-unreferenced app-sdk keys (send, send_message) are removed from every catalog.
ChatEmbed (the app-sdk's embeddable chat) posted to the bare /api/chat, read its SSE stream as JSON, and called the resulting SyntaxError success; a refused POST rejected the mutation nothing read. Either way the composer had already been cleared, so a failed send looked sent and the text was gone. sendTurn gains an injectable wire (the fetch seam: one POST that resolves on every HTTP status and rejects only when the request never left or the deadline fired); dashboardSendWire is the default, so ChatPane and ChatPage are unchanged. appSendWire re-expresses the scoped AppApi outcomes in that shape (2xx JSON -> ok; 2xx non-JSON -> unknown; non-2xx -> refused with the server's reason via a typed AppApiError; permission denial -> refused with a human sentence; rejected fetch -> transport-error; deadline -> response-late). ChatEmbed sends through sendTurn over that wire. refused / transport-error render an error row and hand the draft back as typed; response-late renders a "Delivery not confirmed" notice and hands the draft back, retired only when the poll shows THIS send's own user row past the send-start point -- by identity only (meta.sendId, or membership in a merged row's meta.sendIds), never by text. The server keeps the id on every path a send can take: a dispatched send persists it on its row, and a send queued behind a busy slot carries it through the queue entry onto the drained row (#8853), so there is no text-matching fallback to false-retire on. unknown does nothing; dispatched / queued let the poll render. mintSendId now lives once in chat-core/transport; ChatPane's and ChatPage's inline minters use it. useChatSession's seed send moves off the bare SSE endpoint onto the same wire (fire-and-forget; the slot is the deliverable). The composer swap (#8631): ChatEmbed's bare <input> + send button becomes the native ChatInput inside a SlotProvider for the embedded slot, narrowed by omission (no upload, voice, agent/model/project chrome, typed command menus, prompt optimizer or slot-approval chrome) with a fail-closed `embedded` preset, a `sending` prop, and sendOnEnter via the new useChatConfig hook. Enter/IME handling, autosize, the running-state send affordance and the composer's a11y contract are now the shared ones; recovered drafts keep their paragraph breaks. Two now-unreferenced app-sdk keys (send, send_message) are removed from every catalog.
ChatEmbed (the app-sdk's embeddable chat) posted to the bare /api/chat, read its SSE stream as JSON, and called the resulting SyntaxError success; a refused POST rejected the mutation nothing read. Either way the composer had already been cleared, so a failed send looked sent and the text was gone. sendTurn gains an injectable wire (the fetch seam: one POST that resolves on every HTTP status and rejects only when the request never left or the deadline fired); dashboardSendWire is the default, so ChatPane and ChatPage are unchanged. appSendWire re-expresses the scoped AppApi outcomes in that shape (2xx JSON -> ok; 2xx non-JSON -> unknown; non-2xx -> refused with the server's reason via a typed AppApiError; permission denial -> refused with a human sentence; rejected fetch -> transport-error; deadline -> response-late). ChatEmbed sends through sendTurn over that wire. refused / transport-error render an error row and hand the draft back as typed; response-late renders a "Delivery not confirmed" notice and hands the draft back, retired only when the poll shows THIS send's own user row past the send-start point -- by identity only (meta.sendId, or membership in a merged row's meta.sendIds), never by text. The server keeps the id on every path a send can take: a dispatched send persists it on its row, and a send queued behind a busy slot carries it through the queue entry onto the drained row (#8853), so there is no text-matching fallback to false-retire on. unknown does nothing; dispatched / queued let the poll render. mintSendId now lives once in chat-core/transport; ChatPane's and ChatPage's inline minters use it. useChatSession's seed send moves off the bare SSE endpoint onto the same wire (fire-and-forget; the slot is the deliverable). The composer swap (#8631): ChatEmbed's bare <input> + send button becomes the native ChatInput inside a SlotProvider for the embedded slot, narrowed by omission (no upload, voice, agent/model/project chrome, typed command menus, prompt optimizer or slot-approval chrome) with a fail-closed `embedded` preset, a `sending` prop, and sendOnEnter via the new useChatConfig hook. Enter/IME handling, autosize, the running-state send affordance and the composer's a11y contract are now the shared ones; recovered drafts keep their paragraph breaks. Two now-unreferenced app-sdk keys (send, send_message) are removed from every catalog.
…ransport wire ChatEmbed (the app-sdk's embeddable chat) posted to the bare /api/chat, read its SSE stream as JSON, and called the resulting SyntaxError success; a refused POST rejected the mutation nothing read. Either way the composer had already been cleared, so a failed send looked sent and the text was gone. sendTurn gains an injectable wire (the fetch seam: one POST that resolves on every HTTP status and rejects only when the request never left or the deadline fired); dashboardSendWire is the default, so ChatPane and ChatPage are unchanged. appSendWire re-expresses the scoped AppApi outcomes in that shape (2xx JSON -> ok; 2xx non-JSON -> unknown; non-2xx -> refused with the server's reason via a typed AppApiError; permission denial -> refused with a human sentence; rejected fetch -> transport-error; deadline -> response-late). ChatEmbed sends through sendTurn over that wire. refused / transport-error render an error row and hand the draft back as typed; response-late renders a "Delivery not confirmed" notice and hands the draft back, retired only when the poll shows THIS send's own user row past the send-start point -- by identity only (meta.sendId, or membership in a merged row's meta.sendIds), never by text. The server keeps the id on every path a send can take: a dispatched send persists it on its row, and a send queued behind a busy slot carries it through the queue entry onto the drained row (#8853), so there is no text-matching fallback to false-retire on. unknown does nothing; dispatched / queued let the poll render. mintSendId now lives once in chat-core/transport; ChatPane's and ChatPage's inline minters use it. useChatSession's seed send moves off the bare SSE endpoint onto the same wire (fire-and-forget; the slot is the deliverable). The composer swap (#8631): ChatEmbed's bare <input> + send button becomes the native ChatInput inside a SlotProvider for the embedded slot, narrowed by omission (no upload, voice, agent/model/project chrome, typed command menus, prompt optimizer or slot-approval chrome) with a fail-closed `embedded` preset, a `sending` prop, and sendOnEnter via the new useChatConfig hook. Enter/IME handling, autosize, the running-state send affordance and the composer's a11y contract are now the shared ones; recovered drafts keep their paragraph breaks. Two now-unreferenced app-sdk keys (send, send_message) are removed from every catalog. SideChat (#8655, merged into this branch): the one surface on a different endpoint family (/side/open + /side/turn) with the opposite receipt semantics -- the client helpers resolve JSON on 2xx and reject with an ApiError on non-2xx. A per-surface side wire re-expresses that in the fetch seam's shape, so sendTurn classifies the side panel's send by the same rule as every other surface (deadline, refused vs unreadable vs transport failure) while the acceptance body (run_id, queue_id, steer_id, pending, demoted, still_queued) passes through untouched and its handling is unchanged. Receipt policy: refused / transport-error roll back the optimistic bubble and hand the text back merged, with the server's reason when there is one; response-late hands the text back under an unconfirmed notice only when no bubble holds a copy (steer / queue), and leaves an idle send's bubble alone; unknown does nothing. The delivery-unconfirmed and reason-less transport-failure copy moves from appSdk.chatEmbed to shared pages.chatPage keys so ChatEmbed, ChatPane and SideChat state the same outcome in the same words. Every existing SideChat test passes unmodified.
…ransport wire ChatEmbed (the app-sdk's embeddable chat) posted to the bare /api/chat, read its SSE stream as JSON, and called the resulting SyntaxError success; a refused POST rejected the mutation nothing read. Either way the composer had already been cleared, so a failed send looked sent and the text was gone. sendTurn gains an injectable wire (the fetch seam: one POST that resolves on every HTTP status and rejects only when the request never left or the deadline fired); dashboardSendWire is the default, so ChatPane and ChatPage are unchanged. appSendWire re-expresses the scoped AppApi outcomes in that shape (2xx JSON -> ok; 2xx non-JSON -> unknown; non-2xx -> refused with the server's reason via a typed AppApiError; permission denial -> refused with a human sentence; rejected fetch -> transport-error; deadline -> response-late). ChatEmbed sends through sendTurn over that wire. refused / transport-error render an error row and hand the draft back as typed; response-late renders a "Delivery not confirmed" notice and hands the draft back, retired only when the poll shows THIS send's own user row past the send-start point -- by identity only (meta.sendId, or membership in a merged row's meta.sendIds), never by text. The server keeps the id on every path a send can take: a dispatched send persists it on its row, and a send queued behind a busy slot carries it through the queue entry onto the drained row (#8853), so there is no text-matching fallback to false-retire on. unknown does nothing; dispatched / queued let the poll render. mintSendId now lives once in chat-core/transport; ChatPane's and ChatPage's inline minters use it. useChatSession's seed send moves off the bare SSE endpoint onto the same wire (fire-and-forget; the slot is the deliverable). The composer swap (#8631): ChatEmbed's bare <input> + send button becomes the native ChatInput inside a SlotProvider for the embedded slot, narrowed by omission (no upload, voice, agent/model/project chrome, typed command menus, prompt optimizer or slot-approval chrome) with a fail-closed `embedded` preset, a `sending` prop, and sendOnEnter via the new useChatConfig hook. Enter/IME handling, autosize, the running-state send affordance and the composer's a11y contract are now the shared ones; recovered drafts keep their paragraph breaks. Two now-unreferenced app-sdk keys (send, send_message) are removed from every catalog. SideChat (#8655, merged into this branch): the one surface on a different endpoint family (/side/open + /side/turn) with the opposite receipt semantics -- the client helpers resolve JSON on 2xx and reject with an ApiError on non-2xx. A per-surface side wire re-expresses that in the fetch seam's shape, so sendTurn classifies the side panel's send by the same rule as every other surface (deadline, refused vs unreadable vs transport failure) while the acceptance body (run_id, queue_id, steer_id, pending, demoted, still_queued) passes through untouched and its handling is unchanged. Receipt policy: refused / transport-error roll back the optimistic bubble and hand the text back merged, with the server's reason when there is one; response-late hands the text back under an unconfirmed notice only when no bubble holds a copy (steer / queue), and leaves an idle send's bubble alone; unknown does nothing. The delivery-unconfirmed and reason-less transport-failure copy moves from appSdk.chatEmbed to shared pages.chatPage keys so ChatEmbed, ChatPane and SideChat state the same outcome in the same words. Every existing SideChat test passes unmodified.
…ransport wire ChatEmbed (the app-sdk's embeddable chat) posted to the bare /api/chat, read its SSE stream as JSON, and called the resulting SyntaxError success; a refused POST rejected the mutation nothing read. Either way the composer had already been cleared, so a failed send looked sent and the text was gone. sendTurn gains an injectable wire (the fetch seam: one POST that resolves on every HTTP status and rejects only when the request never left or the deadline fired); dashboardSendWire is the default, so ChatPane and ChatPage are unchanged. appSendWire re-expresses the scoped AppApi outcomes in that shape (2xx JSON -> ok; 2xx non-JSON -> unknown; non-2xx -> refused with the server's reason via a typed AppApiError; permission denial -> refused with a human sentence; rejected fetch -> transport-error; deadline -> response-late). ChatEmbed sends through sendTurn over that wire. refused / transport-error render an error row and hand the draft back as typed; response-late renders a "Delivery not confirmed" notice and hands the draft back, retired only when the poll shows THIS send's own user row past the send-start point -- by identity only (meta.sendId, or membership in a merged row's meta.sendIds), never by text. The server keeps the id on every path a send can take: a dispatched send persists it on its row, and a send queued behind a busy slot carries it through the queue entry onto the drained row (#8853), so there is no text-matching fallback to false-retire on. unknown does nothing; dispatched / queued let the poll render. mintSendId now lives once in chat-core/transport; ChatPane's and ChatPage's inline minters use it. useChatSession's seed send moves off the bare SSE endpoint onto the same wire (fire-and-forget; the slot is the deliverable). The composer swap (#8631): ChatEmbed's bare <input> + send button becomes the native ChatInput inside a SlotProvider for the embedded slot, narrowed by omission (no upload, voice, agent/model/project chrome, typed command menus, prompt optimizer or slot-approval chrome) with a fail-closed `embedded` preset, a `sending` prop, and sendOnEnter via the new useChatConfig hook. Enter/IME handling, autosize, the running-state send affordance and the composer's a11y contract are now the shared ones; recovered drafts keep their paragraph breaks. Two now-unreferenced app-sdk keys (send, send_message) are removed from every catalog. SideChat (#8655, merged into this branch): the one surface on a different endpoint family (/side/open + /side/turn) with the opposite receipt semantics -- the client helpers resolve JSON on 2xx and reject with an ApiError on non-2xx. A per-surface side wire re-expresses that in the fetch seam's shape, so sendTurn classifies the side panel's send by the same rule as every other surface (deadline, refused vs unreadable vs transport failure) while the acceptance body (run_id, queue_id, steer_id, pending, demoted, still_queued) passes through untouched and its handling is unchanged. Receipt policy: refused / transport-error roll back the optimistic bubble and hand the text back merged, with the server's reason when there is one; response-late hands the text back under an unconfirmed notice only when no bubble holds a copy (steer / queue), and leaves an idle send's bubble alone; unknown does nothing. The delivery-unconfirmed and reason-less transport-failure copy moves from appSdk.chatEmbed to shared pages.chatPage keys so ChatEmbed, ChatPane and SideChat state the same outcome in the same words. Every existing SideChat test passes unmodified.
…ransport wire ChatEmbed (the app-sdk's embeddable chat) posted to the bare /api/chat, read its SSE stream as JSON, and called the resulting SyntaxError success; a refused POST rejected the mutation nothing read. Either way the composer had already been cleared, so a failed send looked sent and the text was gone. sendTurn gains an injectable wire (the fetch seam: one POST that resolves on every HTTP status and rejects only when the request never left or the deadline fired); dashboardSendWire is the default, so ChatPane and ChatPage are unchanged. appSendWire re-expresses the scoped AppApi outcomes in that shape (2xx JSON -> ok; 2xx non-JSON -> unknown; non-2xx -> refused with the server's reason via a typed AppApiError; permission denial -> refused with a human sentence; rejected fetch -> transport-error; deadline -> response-late). ChatEmbed sends through sendTurn over that wire. refused / transport-error render an error row and hand the draft back as typed; response-late renders a "Delivery not confirmed" notice and hands the draft back, retired only when the poll shows THIS send's own user row past the send-start point -- by identity only (meta.sendId, or membership in a merged row's meta.sendIds), never by text. The server keeps the id on every path a send can take: a dispatched send persists it on its row, and a send queued behind a busy slot carries it through the queue entry onto the drained row (#8853), so there is no text-matching fallback to false-retire on. unknown does nothing; dispatched / queued let the poll render. mintSendId now lives once in chat-core/transport; ChatPane's and ChatPage's inline minters use it. useChatSession's seed send moves off the bare SSE endpoint onto the same wire (fire-and-forget; the slot is the deliverable). The composer swap (#8631): ChatEmbed's bare <input> + send button becomes the native ChatInput inside a SlotProvider for the embedded slot, narrowed by omission (no upload, voice, agent/model/project chrome, typed command menus, prompt optimizer or slot-approval chrome) with a fail-closed `embedded` preset, a `sending` prop, and sendOnEnter via the new useChatConfig hook. Enter/IME handling, autosize, the running-state send affordance and the composer's a11y contract are now the shared ones; recovered drafts keep their paragraph breaks. Two now-unreferenced app-sdk keys (send, send_message) are removed from every catalog. SideChat (#8655, merged into this branch): the one surface on a different endpoint family (/side/open + /side/turn) with the opposite receipt semantics -- the client helpers resolve JSON on 2xx and reject with an ApiError on non-2xx. A per-surface side wire re-expresses that in the fetch seam's shape, so sendTurn classifies the side panel's send by the same rule as every other surface (deadline, refused vs unreadable vs transport failure) while the acceptance body (run_id, queue_id, steer_id, pending, demoted, still_queued) passes through untouched and its handling is unchanged. Receipt policy: refused / transport-error roll back the optimistic bubble and hand the text back merged, with the server's reason when there is one; response-late hands the text back under an unconfirmed notice only when no bubble holds a copy (steer / queue), and leaves an idle send's bubble alone; unknown does nothing. The delivery-unconfirmed and reason-less transport-failure copy moves from appSdk.chatEmbed to shared pages.chatPage keys so ChatEmbed, ChatPane and SideChat state the same outcome in the same words. Every existing SideChat test passes unmodified.
…ransport wire ChatEmbed (the app-sdk's embeddable chat) posted to the bare /api/chat, read its SSE stream as JSON, and called the resulting SyntaxError success; a refused POST rejected the mutation nothing read. Either way the composer had already been cleared, so a failed send looked sent and the text was gone. sendTurn gains an injectable wire (the fetch seam: one POST that resolves on every HTTP status and rejects only when the request never left or the deadline fired); dashboardSendWire is the default, so ChatPane and ChatPage are unchanged. appSendWire re-expresses the scoped AppApi outcomes in that shape (2xx JSON -> ok; 2xx non-JSON -> unknown; non-2xx -> refused with the server's reason via a typed AppApiError; permission denial -> refused with a human sentence; rejected fetch -> transport-error; deadline -> response-late). ChatEmbed sends through sendTurn over that wire. refused / transport-error render an error row and hand the draft back as typed; response-late renders a "Delivery not confirmed" notice and hands the draft back, retired only when the poll shows THIS send's own user row past the send-start point -- by identity only (meta.sendId, or membership in a merged row's meta.sendIds), never by text. The server keeps the id on every path a send can take: a dispatched send persists it on its row, and a send queued behind a busy slot carries it through the queue entry onto the drained row (#8853), so there is no text-matching fallback to false-retire on. unknown does nothing; dispatched / queued let the poll render. mintSendId now lives once in chat-core/transport; ChatPane's and ChatPage's inline minters use it. useChatSession's seed send moves off the bare SSE endpoint onto the same wire (fire-and-forget; the slot is the deliverable). The composer swap (#8631): ChatEmbed's bare <input> + send button becomes the native ChatInput inside a SlotProvider for the embedded slot, narrowed by omission (no upload, voice, agent/model/project chrome, typed command menus, prompt optimizer or slot-approval chrome) with a fail-closed `embedded` preset, a `sending` prop, and sendOnEnter via the new useChatConfig hook. Enter/IME handling, autosize, the running-state send affordance and the composer's a11y contract are now the shared ones; recovered drafts keep their paragraph breaks. Two now-unreferenced app-sdk keys (send, send_message) are removed from every catalog. SideChat (#8655, merged into this branch): the one surface on a different endpoint family (/side/open + /side/turn) with the opposite receipt semantics -- the client helpers resolve JSON on 2xx and reject with an ApiError on non-2xx. A per-surface side wire re-expresses that in the fetch seam's shape, so sendTurn classifies the side panel's send by the same rule as every other surface (deadline, refused vs unreadable vs transport failure) while the acceptance body (run_id, queue_id, steer_id, pending, demoted, still_queued) passes through untouched and its handling is unchanged. Receipt policy: refused / transport-error roll back the optimistic bubble and hand the text back merged, with the server's reason when there is one; response-late hands the text back under an unconfirmed notice only when no bubble holds a copy (steer / queue), and leaves an idle send's bubble alone; unknown does nothing. The delivery-unconfirmed and reason-less transport-failure copy moves from appSdk.chatEmbed to shared pages.chatPage keys so ChatEmbed, ChatPane and SideChat state the same outcome in the same words. Every existing SideChat test passes unmodified.
…ransport wire ChatEmbed (the app-sdk's embeddable chat) posted to the bare /api/chat, read its SSE stream as JSON, and called the resulting SyntaxError success; a refused POST rejected the mutation nothing read. Either way the composer had already been cleared, so a failed send looked sent and the text was gone. sendTurn gains an injectable wire (the fetch seam: one POST that resolves on every HTTP status and rejects only when the request never left or the deadline fired); dashboardSendWire is the default, so ChatPane and ChatPage are unchanged. appSendWire re-expresses the scoped AppApi outcomes in that shape (2xx JSON -> ok; 2xx non-JSON -> unknown; non-2xx -> refused with the server's reason via a typed AppApiError; permission denial -> refused with a human sentence; rejected fetch -> transport-error; deadline -> response-late). ChatEmbed sends through sendTurn over that wire. refused / transport-error render an error row and hand the draft back as typed; response-late renders a "Delivery not confirmed" notice and hands the draft back, retired only when the poll shows THIS send's own user row past the send-start point -- by identity only (meta.sendId, or membership in a merged row's meta.sendIds), never by text. The server keeps the id on every path a send can take: a dispatched send persists it on its row, and a send queued behind a busy slot carries it through the queue entry onto the drained row (#8853), so there is no text-matching fallback to false-retire on. unknown does nothing; dispatched / queued let the poll render. mintSendId now lives once in chat-core/transport; ChatPane's and ChatPage's inline minters use it. useChatSession's seed send moves off the bare SSE endpoint onto the same wire (fire-and-forget; the slot is the deliverable). The composer swap (#8631): ChatEmbed's bare <input> + send button becomes the native ChatInput inside a SlotProvider for the embedded slot, narrowed by omission (no upload, voice, agent/model/project chrome, typed command menus, prompt optimizer or slot-approval chrome) with a fail-closed `embedded` preset, a `sending` prop, and sendOnEnter via the new useChatConfig hook. Enter/IME handling, autosize, the running-state send affordance and the composer's a11y contract are now the shared ones; recovered drafts keep their paragraph breaks. Two now-unreferenced app-sdk keys (send, send_message) are removed from every catalog. SideChat (#8655, merged into this branch): the one surface on a different endpoint family (/side/open + /side/turn) with the opposite receipt semantics -- the client helpers resolve JSON on 2xx and reject with an ApiError on non-2xx. A per-surface side wire re-expresses that in the fetch seam's shape, so sendTurn classifies the side panel's send by the same rule as every other surface (deadline, refused vs unreadable vs transport failure) while the acceptance body (run_id, queue_id, steer_id, pending, demoted, still_queued) passes through untouched and its handling is unchanged. Receipt policy: refused / transport-error roll back the optimistic bubble and hand the text back merged, with the server's reason when there is one; response-late hands the text back under an unconfirmed notice only when no bubble holds a copy (steer / queue), and leaves an idle send's bubble alone; unknown does nothing. The delivery-unconfirmed and reason-less transport-failure copy moves from appSdk.chatEmbed to shared pages.chatPage keys so ChatEmbed, ChatPane and SideChat state the same outcome in the same words. Every existing SideChat test passes unmodified. Folds stacked PR #9587 (design-critique, design-tweak and mochi sends through sendTurn), which merged into this branch. Error.name markers spelled as machine tokens (send-refused / send-unconfirmed) to satisfy the i18n all-caps-const check main added.
…ransport wire ChatEmbed (the app-sdk's embeddable chat) posted to the bare /api/chat, read its SSE stream as JSON, and called the resulting SyntaxError success; a refused POST rejected the mutation nothing read. Either way the composer had already been cleared, so a failed send looked sent and the text was gone. sendTurn gains an injectable wire (the fetch seam: one POST that resolves on every HTTP status and rejects only when the request never left or the deadline fired); dashboardSendWire is the default, so ChatPane and ChatPage are unchanged. appSendWire re-expresses the scoped AppApi outcomes in that shape (2xx JSON -> ok; 2xx non-JSON -> unknown; non-2xx -> refused with the server's reason via a typed AppApiError; permission denial -> refused with a human sentence; rejected fetch -> transport-error; deadline -> response-late). ChatEmbed sends through sendTurn over that wire. refused / transport-error render an error row and hand the draft back as typed; response-late renders a "Delivery not confirmed" notice and hands the draft back, retired only when the poll shows THIS send's own user row past the send-start point -- by identity only (meta.sendId, or membership in a merged row's meta.sendIds), never by text. The server keeps the id on every path a send can take: a dispatched send persists it on its row, and a send queued behind a busy slot carries it through the queue entry onto the drained row (#8853), so there is no text-matching fallback to false-retire on. unknown does nothing; dispatched / queued let the poll render. mintSendId now lives once in chat-core/transport; ChatPane's and ChatPage's inline minters use it. useChatSession's seed send moves off the bare SSE endpoint onto the same wire (fire-and-forget; the slot is the deliverable). The composer swap (#8631): ChatEmbed's bare <input> + send button becomes the native ChatInput inside a SlotProvider for the embedded slot, narrowed by omission (no upload, voice, agent/model/project chrome, typed command menus, prompt optimizer or slot-approval chrome) with a fail-closed `embedded` preset, a `sending` prop, and sendOnEnter via the new useChatConfig hook. Enter/IME handling, autosize, the running-state send affordance and the composer's a11y contract are now the shared ones; recovered drafts keep their paragraph breaks. Two now-unreferenced app-sdk keys (send, send_message) are removed from every catalog. SideChat (#8655, merged into this branch): the one surface on a different endpoint family (/side/open + /side/turn) with the opposite receipt semantics -- the client helpers resolve JSON on 2xx and reject with an ApiError on non-2xx. A per-surface side wire re-expresses that in the fetch seam's shape, so sendTurn classifies the side panel's send by the same rule as every other surface (deadline, refused vs unreadable vs transport failure) while the acceptance body (run_id, queue_id, steer_id, pending, demoted, still_queued) passes through untouched and its handling is unchanged. Receipt policy: refused / transport-error roll back the optimistic bubble and hand the text back merged, with the server's reason when there is one; response-late hands the text back under an unconfirmed notice only when no bubble holds a copy (steer / queue), and leaves an idle send's bubble alone; unknown does nothing. The delivery-unconfirmed and reason-less transport-failure copy moves from appSdk.chatEmbed to shared pages.chatPage keys so ChatEmbed, ChatPane and SideChat state the same outcome in the same words. Every existing SideChat test passes unmodified. Folds stacked PR #9587 (design-critique, design-tweak and mochi sends through sendTurn), which merged into this branch. Error.name markers spelled as machine tokens (send-refused / send-unconfirmed) to satisfy the i18n all-caps-const check main added. Review round 1: an override (follow-up chip) send no longer clears the send tails, so a late-accepted send's notice can still be retired by proof and its restored text taken back (GPT F1, test-pinned). Mochi bridge tests mock the chat POST with an `ok: true` receipt body, which sendTurn requires.
…ransport wire ChatEmbed (the app-sdk's embeddable chat) posted to the bare /api/chat, read its SSE stream as JSON, and called the resulting SyntaxError success; a refused POST rejected the mutation nothing read. Either way the composer had already been cleared, so a failed send looked sent and the text was gone. sendTurn gains an injectable wire (the fetch seam: one POST that resolves on every HTTP status and rejects only when the request never left or the deadline fired); dashboardSendWire is the default, so ChatPane and ChatPage are unchanged. appSendWire re-expresses the scoped AppApi outcomes in that shape (2xx JSON -> ok; 2xx non-JSON -> unknown; non-2xx -> refused with the server's reason via a typed AppApiError; permission denial -> refused with a human sentence; rejected fetch -> transport-error; deadline -> response-late). ChatEmbed sends through sendTurn over that wire. refused / transport-error render an error row and hand the draft back as typed; response-late renders a "Delivery not confirmed" notice and hands the draft back, retired only when the poll shows THIS send's own user row past the send-start point -- by identity only (meta.sendId, or membership in a merged row's meta.sendIds), never by text. The server keeps the id on every path a send can take: a dispatched send persists it on its row, and a send queued behind a busy slot carries it through the queue entry onto the drained row (#8853), so there is no text-matching fallback to false-retire on. unknown does nothing; dispatched / queued let the poll render. mintSendId now lives once in chat-core/transport; ChatPane's and ChatPage's inline minters use it. useChatSession's seed send moves off the bare SSE endpoint onto the same wire (fire-and-forget; the slot is the deliverable). The composer swap (#8631): ChatEmbed's bare <input> + send button becomes the native ChatInput inside a SlotProvider for the embedded slot, narrowed by omission (no upload, voice, agent/model/project chrome, typed command menus, prompt optimizer or slot-approval chrome) with a fail-closed `embedded` preset, a `sending` prop, and sendOnEnter via the new useChatConfig hook. Enter/IME handling, autosize, the running-state send affordance and the composer's a11y contract are now the shared ones; recovered drafts keep their paragraph breaks. Two now-unreferenced app-sdk keys (send, send_message) are removed from every catalog. SideChat (#8655, merged into this branch): the one surface on a different endpoint family (/side/open + /side/turn) with the opposite receipt semantics -- the client helpers resolve JSON on 2xx and reject with an ApiError on non-2xx. A per-surface side wire re-expresses that in the fetch seam's shape, so sendTurn classifies the side panel's send by the same rule as every other surface (deadline, refused vs unreadable vs transport failure) while the acceptance body (run_id, queue_id, steer_id, pending, demoted, still_queued) passes through untouched and its handling is unchanged. Receipt policy: refused / transport-error roll back the optimistic bubble and hand the text back merged, with the server's reason when there is one; response-late hands the text back under an unconfirmed notice only when no bubble holds a copy (steer / queue), and leaves an idle send's bubble alone; unknown does nothing. The delivery-unconfirmed and reason-less transport-failure copy moves from appSdk.chatEmbed to shared pages.chatPage keys so ChatEmbed, ChatPane and SideChat state the same outcome in the same words. Every existing SideChat test passes unmodified. Folds stacked PR #9587 (design-critique, design-tweak and mochi sends through sendTurn), which merged into this branch. Error.name markers spelled as machine tokens (send-refused / send-unconfirmed) to satisfy the i18n all-caps-const check main added. Review round 1: an override (follow-up chip) send no longer clears the send tails, so a late-accepted send's notice can still be retired by proof and its restored text taken back (GPT F1, test-pinned). Mochi bridge tests mock the chat POST with an `ok: true` receipt body, which sendTurn requires.
…ransport wire ChatEmbed (the app-sdk's embeddable chat) posted to the bare /api/chat, read its SSE stream as JSON, and called the resulting SyntaxError success; a refused POST rejected the mutation nothing read. Either way the composer had already been cleared, so a failed send looked sent and the text was gone. sendTurn gains an injectable wire (the fetch seam: one POST that resolves on every HTTP status and rejects only when the request never left or the deadline fired); dashboardSendWire is the default, so ChatPane and ChatPage are unchanged. appSendWire re-expresses the scoped AppApi outcomes in that shape (2xx JSON -> ok; 2xx non-JSON -> unknown; non-2xx -> refused with the server's reason via a typed AppApiError; permission denial -> refused with a human sentence; rejected fetch -> transport-error; deadline -> response-late). ChatEmbed sends through sendTurn over that wire. refused / transport-error render an error row and hand the draft back as typed; response-late renders a "Delivery not confirmed" notice and hands the draft back, retired only when the poll shows THIS send's own user row past the send-start point -- by identity only (meta.sendId, or membership in a merged row's meta.sendIds), never by text. The server keeps the id on every path a send can take: a dispatched send persists it on its row, and a send queued behind a busy slot carries it through the queue entry onto the drained row (#8853), so there is no text-matching fallback to false-retire on. unknown does nothing; dispatched / queued let the poll render. mintSendId now lives once in chat-core/transport; ChatPane's and ChatPage's inline minters use it. useChatSession's seed send moves off the bare SSE endpoint onto the same wire (fire-and-forget; the slot is the deliverable). The composer swap (#8631): ChatEmbed's bare <input> + send button becomes the native ChatInput inside a SlotProvider for the embedded slot, narrowed by omission (no upload, voice, agent/model/project chrome, typed command menus, prompt optimizer or slot-approval chrome) with a fail-closed `embedded` preset, a `sending` prop, and sendOnEnter via the new useChatConfig hook. Enter/IME handling, autosize, the running-state send affordance and the composer's a11y contract are now the shared ones; recovered drafts keep their paragraph breaks. Two now-unreferenced app-sdk keys (send, send_message) are removed from every catalog. SideChat (#8655, merged into this branch): the one surface on a different endpoint family (/side/open + /side/turn) with the opposite receipt semantics -- the client helpers resolve JSON on 2xx and reject with an ApiError on non-2xx. A per-surface side wire re-expresses that in the fetch seam's shape, so sendTurn classifies the side panel's send by the same rule as every other surface (deadline, refused vs unreadable vs transport failure) while the acceptance body (run_id, queue_id, steer_id, pending, demoted, still_queued) passes through untouched and its handling is unchanged. Receipt policy: refused / transport-error roll back the optimistic bubble and hand the text back merged, with the server's reason when there is one; response-late hands the text back under an unconfirmed notice only when no bubble holds a copy (steer / queue), and leaves an idle send's bubble alone; unknown does nothing. The delivery-unconfirmed and reason-less transport-failure copy moves from appSdk.chatEmbed to shared pages.chatPage keys so ChatEmbed, ChatPane and SideChat state the same outcome in the same words. Every existing SideChat test passes unmodified. Folds stacked PR #9587 (design-critique, design-tweak and mochi sends through sendTurn), which merged into this branch. Error.name markers spelled as machine tokens (send-refused / send-unconfirmed) to satisfy the i18n all-caps-const check main added. Review round 1: an override (follow-up chip) send no longer clears the send tails, so a late-accepted send's notice can still be retired by proof and its restored text taken back (GPT F1, test-pinned). Mochi bridge tests mock the chat POST with an `ok: true` receipt body, which sendTurn requires. Review round 2 (GPT): mochi ChatPanel's failed-send restore merges the submitted text with any draft typed while the send was in flight (mergeRecoveredDraft) instead of keeping one and dropping the other (test-pinned).
…ransport wire ChatEmbed (the app-sdk's embeddable chat) posted to the bare /api/chat, read its SSE stream as JSON, and called the resulting SyntaxError success; a refused POST rejected the mutation nothing read. Either way the composer had already been cleared, so a failed send looked sent and the text was gone. sendTurn gains an injectable wire (the fetch seam: one POST that resolves on every HTTP status and rejects only when the request never left or the deadline fired); dashboardSendWire is the default, so ChatPane and ChatPage are unchanged. appSendWire re-expresses the scoped AppApi outcomes in that shape (2xx JSON -> ok; 2xx non-JSON -> unknown; non-2xx -> refused with the server's reason via a typed AppApiError; permission denial -> refused with a human sentence; rejected fetch -> transport-error; deadline -> response-late). ChatEmbed sends through sendTurn over that wire. refused / transport-error render an error row and hand the draft back as typed; response-late renders a "Delivery not confirmed" notice and hands the draft back, retired only when the poll shows THIS send's own user row past the send-start point -- by identity only (meta.sendId, or membership in a merged row's meta.sendIds), never by text. The server keeps the id on every path a send can take: a dispatched send persists it on its row, and a send queued behind a busy slot carries it through the queue entry onto the drained row (#8853), so there is no text-matching fallback to false-retire on. unknown does nothing; dispatched / queued let the poll render. mintSendId now lives once in chat-core/transport; ChatPane's and ChatPage's inline minters use it. useChatSession's seed send moves off the bare SSE endpoint onto the same wire (fire-and-forget; the slot is the deliverable). The composer swap (#8631): ChatEmbed's bare <input> + send button becomes the native ChatInput inside a SlotProvider for the embedded slot, narrowed by omission (no upload, voice, agent/model/project chrome, typed command menus, prompt optimizer or slot-approval chrome) with a fail-closed `embedded` preset, a `sending` prop, and sendOnEnter via the new useChatConfig hook. Enter/IME handling, autosize, the running-state send affordance and the composer's a11y contract are now the shared ones; recovered drafts keep their paragraph breaks. Two now-unreferenced app-sdk keys (send, send_message) are removed from every catalog. SideChat (#8655, merged into this branch): the one surface on a different endpoint family (/side/open + /side/turn) with the opposite receipt semantics -- the client helpers resolve JSON on 2xx and reject with an ApiError on non-2xx. A per-surface side wire re-expresses that in the fetch seam's shape, so sendTurn classifies the side panel's send by the same rule as every other surface (deadline, refused vs unreadable vs transport failure) while the acceptance body (run_id, queue_id, steer_id, pending, demoted, still_queued) passes through untouched and its handling is unchanged. Receipt policy: refused / transport-error roll back the optimistic bubble and hand the text back merged, with the server's reason when there is one; response-late hands the text back under an unconfirmed notice only when no bubble holds a copy (steer / queue), and leaves an idle send's bubble alone; unknown does nothing. The delivery-unconfirmed and reason-less transport-failure copy moves from appSdk.chatEmbed to shared pages.chatPage keys so ChatEmbed, ChatPane and SideChat state the same outcome in the same words. Every existing SideChat test passes unmodified. Folds stacked PR #9587 (design-critique, design-tweak and mochi sends through sendTurn), which merged into this branch. Error.name markers spelled as machine tokens (send-refused / send-unconfirmed) to satisfy the i18n all-caps-const check main added. Review round 1: an override (follow-up chip) send no longer clears the send tails, so a late-accepted send's notice can still be retired by proof and its restored text taken back (GPT F1, test-pinned). Mochi bridge tests mock the chat POST with an `ok: true` receipt body, which sendTurn requires. Review round 2 (GPT): mochi ChatPanel's failed-send restore merges the submitted text with any draft typed while the send was in flight (mergeRecoveredDraft) instead of keeping one and dropping the other (test-pinned). Review round 3 (GPT): sideHandBackText creates the slot's side record when absent, so a draft typed into a FRESH side panel survives an unmount; the closed-side frame guards key on the tombstone itself (not the record's absence) so the parked record cannot let a closed run's late frames in (both test-pinned).
…ransport wire ChatEmbed (the app-sdk's embeddable chat) posted to the bare /api/chat, read its SSE stream as JSON, and called the resulting SyntaxError success; a refused POST rejected the mutation nothing read. Either way the composer had already been cleared, so a failed send looked sent and the text was gone. sendTurn gains an injectable wire (the fetch seam: one POST that resolves on every HTTP status and rejects only when the request never left or the deadline fired); dashboardSendWire is the default, so ChatPane and ChatPage are unchanged. appSendWire re-expresses the scoped AppApi outcomes in that shape (2xx JSON -> ok; 2xx non-JSON -> unknown; non-2xx -> refused with the server's reason via a typed AppApiError; permission denial -> refused with a human sentence; rejected fetch -> transport-error; deadline -> response-late). ChatEmbed sends through sendTurn over that wire. refused / transport-error render an error row and hand the draft back as typed; response-late renders a "Delivery not confirmed" notice and hands the draft back, retired only when the poll shows THIS send's own user row past the send-start point -- by identity only (meta.sendId, or membership in a merged row's meta.sendIds), never by text. The server keeps the id on every path a send can take: a dispatched send persists it on its row, and a send queued behind a busy slot carries it through the queue entry onto the drained row (#8853), so there is no text-matching fallback to false-retire on. unknown does nothing; dispatched / queued let the poll render. mintSendId now lives once in chat-core/transport; ChatPane's and ChatPage's inline minters use it. useChatSession's seed send moves off the bare SSE endpoint onto the same wire (fire-and-forget; the slot is the deliverable). The composer swap (#8631): ChatEmbed's bare <input> + send button becomes the native ChatInput inside a SlotProvider for the embedded slot, narrowed by omission (no upload, voice, agent/model/project chrome, typed command menus, prompt optimizer or slot-approval chrome) with a fail-closed `embedded` preset, a `sending` prop, and sendOnEnter via the new useChatConfig hook. Enter/IME handling, autosize, the running-state send affordance and the composer's a11y contract are now the shared ones; recovered drafts keep their paragraph breaks. Two now-unreferenced app-sdk keys (send, send_message) are removed from every catalog. SideChat (#8655, merged into this branch): the one surface on a different endpoint family (/side/open + /side/turn) with the opposite receipt semantics -- the client helpers resolve JSON on 2xx and reject with an ApiError on non-2xx. A per-surface side wire re-expresses that in the fetch seam's shape, so sendTurn classifies the side panel's send by the same rule as every other surface (deadline, refused vs unreadable vs transport failure) while the acceptance body (run_id, queue_id, steer_id, pending, demoted, still_queued) passes through untouched and its handling is unchanged. Receipt policy: refused / transport-error roll back the optimistic bubble and hand the text back merged, with the server's reason when there is one; response-late hands the text back under an unconfirmed notice only when no bubble holds a copy (steer / queue), and leaves an idle send's bubble alone; unknown does nothing. The delivery-unconfirmed and reason-less transport-failure copy moves from appSdk.chatEmbed to shared pages.chatPage keys so ChatEmbed, ChatPane and SideChat state the same outcome in the same words. Every existing SideChat test passes unmodified. Folds stacked PR #9587 (design-critique, design-tweak and mochi sends through sendTurn), which merged into this branch. Error.name markers spelled as machine tokens (send-refused / send-unconfirmed) to satisfy the i18n all-caps-const check main added. Review round 1: an override (follow-up chip) send no longer clears the send tails, so a late-accepted send's notice can still be retired by proof and its restored text taken back (GPT F1, test-pinned). Mochi bridge tests mock the chat POST with an `ok: true` receipt body, which sendTurn requires. Review round 2 (GPT): mochi ChatPanel's failed-send restore merges the submitted text with any draft typed while the send was in flight (mergeRecoveredDraft) instead of keeping one and dropping the other (test-pinned). Review round 3 (GPT): sideHandBackText creates the slot's side record when absent, so a draft typed into a FRESH side panel survives an unmount; the closed-side frame guards key on the tombstone itself (not the record's absence) so the parked record cannot let a closed run's late frames in (both test-pinned). Review round 4 (GPT): the recovery-drain subscriber re-checks at call time that this instance is mounted and still shows the slot it subscribed for, so a receipt landing between an A->B re-prop and the effect cleanup cannot drain slot A's only copy into slot B.
…ransport wire ChatEmbed (the app-sdk's embeddable chat) posted to the bare /api/chat, read its SSE stream as JSON, and called the resulting SyntaxError success; a refused POST rejected the mutation nothing read. Either way the composer had already been cleared, so a failed send looked sent and the text was gone. sendTurn gains an injectable wire (the fetch seam: one POST that resolves on every HTTP status and rejects only when the request never left or the deadline fired); dashboardSendWire is the default, so ChatPane and ChatPage are unchanged. appSendWire re-expresses the scoped AppApi outcomes in that shape (2xx JSON -> ok; 2xx non-JSON -> unknown; non-2xx -> refused with the server's reason via a typed AppApiError; permission denial -> refused with a human sentence; rejected fetch -> transport-error; deadline -> response-late). ChatEmbed sends through sendTurn over that wire. refused / transport-error render an error row and hand the draft back as typed; response-late renders a "Delivery not confirmed" notice and hands the draft back, retired only when the poll shows THIS send's own user row past the send-start point -- by identity only (meta.sendId, or membership in a merged row's meta.sendIds), never by text. The server keeps the id on every path a send can take: a dispatched send persists it on its row, and a send queued behind a busy slot carries it through the queue entry onto the drained row (#8853), so there is no text-matching fallback to false-retire on. unknown does nothing; dispatched / queued let the poll render. mintSendId now lives once in chat-core/transport; ChatPane's and ChatPage's inline minters use it. useChatSession's seed send moves off the bare SSE endpoint onto the same wire (fire-and-forget; the slot is the deliverable). The composer swap (#8631): ChatEmbed's bare <input> + send button becomes the native ChatInput inside a SlotProvider for the embedded slot, narrowed by omission (no upload, voice, agent/model/project chrome, typed command menus, prompt optimizer or slot-approval chrome) with a fail-closed `embedded` preset, a `sending` prop, and sendOnEnter via the new useChatConfig hook. Enter/IME handling, autosize, the running-state send affordance and the composer's a11y contract are now the shared ones; recovered drafts keep their paragraph breaks. Two now-unreferenced app-sdk keys (send, send_message) are removed from every catalog. SideChat (#8655, merged into this branch): the one surface on a different endpoint family (/side/open + /side/turn) with the opposite receipt semantics -- the client helpers resolve JSON on 2xx and reject with an ApiError on non-2xx. A per-surface side wire re-expresses that in the fetch seam's shape, so sendTurn classifies the side panel's send by the same rule as every other surface (deadline, refused vs unreadable vs transport failure) while the acceptance body (run_id, queue_id, steer_id, pending, demoted, still_queued) passes through untouched and its handling is unchanged. Receipt policy: refused / transport-error roll back the optimistic bubble and hand the text back merged, with the server's reason when there is one; response-late hands the text back under an unconfirmed notice only when no bubble holds a copy (steer / queue), and leaves an idle send's bubble alone; unknown does nothing. The delivery-unconfirmed and reason-less transport-failure copy moves from appSdk.chatEmbed to shared pages.chatPage keys so ChatEmbed, ChatPane and SideChat state the same outcome in the same words. Every existing SideChat test passes unmodified. Folds stacked PR #9587 (design-critique, design-tweak and mochi sends through sendTurn), which merged into this branch. Error.name markers spelled as machine tokens (send-refused / send-unconfirmed) to satisfy the i18n all-caps-const check main added. Review round 1: an override (follow-up chip) send no longer clears the send tails, so a late-accepted send's notice can still be retired by proof and its restored text taken back (GPT F1, test-pinned). Mochi bridge tests mock the chat POST with an `ok: true` receipt body, which sendTurn requires. Review round 2 (GPT): mochi ChatPanel's failed-send restore merges the submitted text with any draft typed while the send was in flight (mergeRecoveredDraft) instead of keeping one and dropping the other (test-pinned). Review round 3 (GPT): sideHandBackText creates the slot's side record when absent, so a draft typed into a FRESH side panel survives an unmount; the closed-side frame guards key on the tombstone itself (not the record's absence) so the parked record cannot let a closed run's late frames in (both test-pinned). Review round 4 (GPT): the recovery-drain subscriber re-checks at call time that this instance is mounted and still shows the slot it subscribed for, so a receipt landing between an A->B re-prop and the effect cleanup cannot drain slot A's only copy into slot B. Rebased over #9593 (batch C) and #8947 (Quote / Ask, persisted per-slot Side Chat drafts): SideChat's recovery now hands text back through main's `restoreDraftTo` (the per-slot draft store) for the originating slot, so the unmount park and the `sideHandBackText` reducer this PR added are gone; `acceptOrThrow` keeps main's `sendResponseAuthRecovery` beside it.
Problem / Motivation
ChatEmbed(the app-sdk's embeddable chat) still rendered its own bare<input type="text">and send button — the last chat surface not on the shared composer. It had its own Enter/IME handling (useComposerDraft.submitOnEnter+composition), no autosize, no running-state affordance, its own a11y labels, and a single-line field that forced #8599 to flatten the shared recovery join to a space.Why it matters
Chat-core extraction RFC P3 (composer): one composer, capabilities granted by props. Every divergence in the embed's composer is a place the four surfaces drift — the same class of defect P1 fixed for message rendering and P2 for the send receipt.
What changed (motivation → approach → change)
website/src/app-sdk/ChatEmbed.tsx. The composer row becomes the real nativeChatInputinside aSlotProviderfor the embedded slot — the same mount ChatPane and SideChat use — under a new fail-closedembeddedflag onChatInput: every capability that defaults on for a first-class composer (typedCommandMenus,slotApprovalChrome,promptOptimizer) is forced off, the defaults are resolved in one place (capabilityDefault = !embedded), an explicit prop still wins, and a source-shape test scans the whole props destructure and forbids any prop defaulting totrueexceptconnected(a liveness flag, not a capability) — so a capability added later cannot light up inside an app embed by convention (mutation-verified: addingnewCap = truefails it by name). The opt-in chrome (upload, voice, agent/model/project) is simply not passed. NoonStop/onSteer: an embed must not stop or steer the slot's turn, so while the agent runs the plain Send stays and a send simply queues server-side (the samequeuedreceipt as before the swap). While a POST is in flight the embed passes a newsendingprop rather thandisabled: the Send button shows a spinner at full opacity, with hover and pointer-cursor affordances suppressed so it does not look pressable while ignoring clicks (the empty-draftdisabledclause is bypassed while sending, since the host clears the draft the moment it fires), reads "Sending…" (components.chatInput.sending, 12 locales + en-XA), isaria-busy, and refuses a second fire — while the field stays live (ChatInput'sdisabledwould announce "Stopping…" and grey the field). The user's send-key setting (chatConfig.sendOnEnter, the same local setting the main composer honours) is forwarded through a new shareduseChatConfig()hook (website/src/hooks/useChatConfig.ts) — the focus /mc-config-changedreload block that ChatPage, ChatPane, ChatSidebar anduseSimplifiedToolNameseach carried now lives once, and all four switch to it in this PR (the sidebar'stagColumnsEnabledand the simplified-tool-names reader thereby also gain the focus-reload fix).grep "addEventListener('mc-config-changed'" srcoutside tests: 1 hit, the hook (theirChatSettingsmocks in tests are unaffected because the hook is its own module). Local settings, never fetched, so the no-dashboard-client invariant holds.inputAriaLabelkeeps the embed's existing "Chat message" label; the placeholder logic (running → "Agent is working…") is unchanged. TheaboveComposerslot stays where it was (above the follow-up bar).Ambient API traffic audit.
ChatInputtalks to the dashboard client, not the embed's permission-scoped app wire, so every call it can make was checked against this mount: approval resolution is behindslotApprovalChrome, the auto-compact popover behind the context chip (contextPct), and the skills prefetch-on-focus was the one ungated path — it now fires only whentypedCommandMenusis on (ChatInput.tsx), so an embed never emits/api/skillstraffic its host's manifest did not declare. The "which traffic does this prop open?" question is stated once, onChatInputProps.embedded(the mount comment points there), so the two copies cannot drift — and the invariant itself is pinned:ChatEmbed.noDashboardClient.test.tsxreplaces the dashboard client with a recording Proxy, mounts the narrowed embed, focuses/types/blurs/sends, and asserts the app wire carried the send while the dashboard client saw zero calls. A new ambient effect anywhere inChatInput— including one reached through a child component's ownapi/clientimport, which no prop could gate — goes red there by name (mutation-verified: un-gating the skills prefetch fails it with"skills"). This Proxy test is the load-bearing gate; theembeddedflag and its source-shape test are the ergonomic layer on top.Side effect on SideChat. SideChat already passes
typedCommandMenus={false}, so its focus prefetch stops too. Checked: nothing in SideChat's tree reads the per-slot['skills', slot, project, agent]entry the prefetch warmed —SkillPickerMenuis the only reader and is gated off there; the command palette'sskillsProviderkeys on plain['skills']. Strictly a saved request.Contract change on a public export.
ChatEmbed(exported fromapp-sdk/index.ts) previously advertised "No Redux dependency"; it now requires mounting under the dashboard store. Both in-tree hosts (spec-builder, ops-mission-control) already do; an out-of-tree host relying on the old sentence would throw at mount. The header and the SDK barrel's publish-plan note say so explicitly, and the chat-core RFC now carries it as a named pre-publish gate (§4.1: store-free composer seam vs host-onlyChatEmbed). Ratified by the series conductor per the RFC phase plan (2026-09-05):ChatEmbedis host-only for now. DecouplingChatInputfrom the store is the RFC's P3 model-layer work, not a P2 prerequisite; the store-free seam is tracked in #8651 and remains the pre-publish gate for a standalone@kirocrew/app-sdk(RFC §4.1).useComposerDraft. With every consumer now onChatInput(whose ownuseImeGuardowns Enter/IME), the hook'scomposition/isComposing/submitOnEnterplumbing has zero production consumers and is deleted, with its test block; the hook is not exported to third-party apps.The single-line space-flatten on recovered drafts that #8599 had to add for the
<input>is removed: the sharedmergeRecoveredDraftparagraph-break join now renders in the textarea like every other recovery site.Catalogs.
appSdk.chatEmbed.send/send_messageare now unreferenced and are removed from all 12 locales + en-XA (dead-key baseline ratchets down by 2).Harness.
capture/flex-input-min-w-0drops its ChatEmbed replica scene (the literal class strings it copied no longer exist); the send-receipt capture clicks the shared Send button.RFC placement. P3 (composer) for the ChatEmbed surface; P2 (transport) for this surface landed in #8599, on which this PR is stacked (base branch
feat/chat-core-p2-embed; will re-target tomainonce #8599 merges). After this, SideChat, ChatPane and ChatEmbed all renderChatInput; ChatPage always has. Send paths still to convert (P2 remainder, own PRs): SideChat, ChatPage, plus the app-local senders inventoried in #8599.Tests
ChatEmbed.test.tsx(35),ChatEmbed.sendReceipt.test.tsx(19),ChatEmbed.frameless.test.tsx(2),ChatEmbed.approvalRollback.test.tsx(4): wrapped in a ReduxProvider(ChatInput reads slot state), selectors moved to the shared composer (getByRole('button', { name: 'Send' }), chip by role). The "disables input while sending" case is replaced by "a second submit while a send is in flight does not fire a second POST" — the re-entry guard is the contract, not a disabled field. The mid-flight merge asserts the paragraph-break join again.flexInputMinWidth.test.tsx: ChatEmbed case removed with the input it tested (3 remain).ChatInput.skillsPrefetchDeadline.test.tsx(+1): withtypedCommandMenus={false}, focusing the composer issues noapi.skillscall.ChatInput.embedded.test.tsx(new, 5): the three capabilities resolve throughcapabilityDefault; NO prop in the destructure defaults totrueexceptconnected(general scan, mutation-verified);embeddedturns the menus off (no prefetch); explicit prop wins; first-class default unchanged.ChatEmbed.test.tsx(+1, and the re-entry test now asserts theSending…/aria-busystate): the send-key setting is honoured —ctrl-entermode ignores plain Enter and sends on Ctrl+Enter.ChatEmbed.noDashboardClient.test.tsx(new, 1): the invariant — zero dashboard-client calls from the embedded composer across mount/focus/type/blur/send; the send is asserted on the app wire.useComposerDraft.test.tsx: thesubmitOnEnter/ composition block goes with the code it tested.npm run typecheck, changed-file ESLint clean.Manual verification
Capture harness (
website/capture/chat-embed-send-receipt.*) run on the base branch and on this branch; the script asserts the failure row and restored composer text before capturing.Screenshots / video
Light theme
Related Issues
Chat-core extraction RFC, phase P3 (composer) for ChatEmbed. Stacked on #8599. no linked issue: RFC phase work tracked in the RFC, not an issue.
Checklist
Contribution License Agreement
N/A — repository placeholder only; no OSPO CLA text has been supplied.