Skip to content

feat(chat-core): mount the real ChatInput composer in ChatEmbed - #8631

Merged
iamwhatever merged 1 commit into
feat/chat-core-p2-embedfrom
feat/chat-core-p2-embed-composer
Sep 5, 2026
Merged

feat(chat-core): mount the real ChatInput composer in ChatEmbed#8631
iamwhatever merged 1 commit into
feat/chat-core-p2-embedfrom
feat/chat-core-p2-embed-composer

Conversation

@CrysisDeu

@CrysisDeu CrysisDeu commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

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 native ChatInput inside a SlotProvider for the embedded slot — the same mount ChatPane and SideChat use — under a new fail-closed embedded flag on ChatInput: 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 to true except connected (a liveness flag, not a capability) — so a capability added later cannot light up inside an app embed by convention (mutation-verified: adding newCap = true fails it by name). The opt-in chrome (upload, voice, agent/model/project) is simply not passed. No onStop/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 same queued receipt as before the swap). While a POST is in flight the embed passes a new sending prop rather than disabled: 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-draft disabled clause is bypassed while sending, since the host clears the draft the moment it fires), reads "Sending…" (components.chatInput.sending, 12 locales + en-XA), is aria-busy, and refuses a second fire — while the field stays live (ChatInput's disabled would 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 shared useChatConfig() hook (website/src/hooks/useChatConfig.ts) — the focus / mc-config-changed reload block that ChatPage, ChatPane, ChatSidebar and useSimplifiedToolNames each carried now lives once, and all four switch to it in this PR (the sidebar's tagColumnsEnabled and the simplified-tool-names reader thereby also gain the focus-reload fix). grep "addEventListener('mc-config-changed'" src outside tests: 1 hit, the hook (their ChatSettings mocks in tests are unaffected because the hook is its own module). Local settings, never fetched, so the no-dashboard-client invariant holds. inputAriaLabel keeps the embed's existing "Chat message" label; the placeholder logic (running → "Agent is working…") is unchanged. The aboveComposer slot stays where it was (above the follow-up bar).

Ambient API traffic audit. ChatInput talks 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 behind slotApprovalChrome, the auto-compact popover behind the context chip (contextPct), and the skills prefetch-on-focus was the one ungated path — it now fires only when typedCommandMenus is on (ChatInput.tsx), so an embed never emits /api/skills traffic its host's manifest did not declare. The "which traffic does this prop open?" question is stated once, on ChatInputProps.embedded (the mount comment points there), so the two copies cannot drift — and the invariant itself is pinned: ChatEmbed.noDashboardClient.test.tsx replaces 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 in ChatInput — including one reached through a child component's own api/client import, 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; the embedded flag 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 — SkillPickerMenu is the only reader and is gated off there; the command palette's skillsProvider keys on plain ['skills']. Strictly a saved request.

Contract change on a public export. ChatEmbed (exported from app-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-only ChatEmbed). Ratified by the series conductor per the RFC phase plan (2026-09-05): ChatEmbed is host-only for now. Decoupling ChatInput from 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 on ChatInput (whose own useImeGuard owns Enter/IME), the hook's composition / isComposing / submitOnEnter plumbing 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 shared mergeRecoveredDraft paragraph-break join now renders in the textarea like every other recovery site.

Catalogs. appSdk.chatEmbed.send / send_message are now unreferenced and are removed from all 12 locales + en-XA (dead-key baseline ratchets down by 2).

Harness. capture/flex-input-min-w-0 drops 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 to main once #8599 merges). After this, SideChat, ChatPane and ChatEmbed all render ChatInput; 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 Redux Provider (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): with typedCommandMenus={false}, focusing the composer issues no api.skills call.
  • ChatInput.embedded.test.tsx (new, 5): the three capabilities resolve through capabilityDefault; NO prop in the destructure defaults to true except connected (general scan, mutation-verified); embedded turns the menus off (no prefetch); explicit prop wins; first-class default unchanged.
  • ChatEmbed.test.tsx (+1, and the re-entry test now asserts the Sending… / aria-busy state): the send-key setting is honoured — ctrl-enter mode 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: the submitOnEnter / composition block goes with the code it tested.
  • i18n: dead-key, parity, key-reference, English-identity, changed-value QA and all 11 locale style gates pass; added-lines gate 0.
  • 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

BEFORE (bare input) AFTER (real ChatInput)
before idle after idle
before refused after refused
Light theme

before light
after light

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

  • At most two commits; this PR has one Conventional Commits commit
  • Existing relevant tests pass and new deterministic tests cover the behavior
  • Self-review completed; code follows project style guidelines
  • Documentation updated if applicable; the composer contract is documented at the mount
  • No secrets, credentials, or internal references in the diff

Contribution License Agreement

N/A — repository placeholder only; no OSPO CLA text has been supplied.

@CrysisDeu
CrysisDeu requested a review from a team as a code owner September 5, 2026 02:40
@CrysisDeu
CrysisDeu requested review from bolichen97 and removed request for a team September 5, 2026 02:40
@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Sep 5, 2026
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — 🟡 CONCERNS

Design-level review of 3ab29822dc07b9a24f89c82da6feb0f18f58a631 — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

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

  • ChatEmbed now requires the dashboard Redux store ("the composer subtree (ChatInput) reads slot state from Redux"), inverting the app-sdk directory's own rule ("Deliberately Redux-free… like the rest of this directory"). The PR contains the damage correctly — both in-tree hosts comply, the barrel note and RFC §4.1 gate are written — but every release this ships hardens the coupling, and the fix (a store-free composer seam) grows with each capability ChatInput gains. Get the SDK-roadmap owner's explicit sign-off on host-only vs. store-free now, before a second app-sdk export copies the pattern.
  • The embed↔dashboard-client boundary is enforced by convention (embedded flag + capabilityDefault) backed by a source-shape regex and a Proxy test. That holds only while capabilities enter through the props destructure; a dashboard call reached via a child component's own import (not a ChatInput prop) is covered solely by the Proxy test's mocked api/client — keep that test as the load-bearing gate, not the regex.

[DESIGN-REVIEWED] 3ab2982

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ no blocking findings

GPT 5.6 completed its review of 3ab29822dc07b9a24f89c82da6feb0f18f58a631 and found no blocking issues.

This comment is updated in place on each push.

Review details

No findings.
[GPT-REVIEWED] 3ab2982

False positive or not applicable? A repository writer can comment:
/ai-review override gpt 3ab29822dc07b9a24f89c82da6feb0f18f58a631: <one-sentence reason>

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5) — 🟡 CONCERNS

Premise-level review of 3ab29822dc07b9a24f89c82da6feb0f18f58a631 — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

All counts verified. The description's claims hold: SideChat never used the deleted hook members, the mc-config-changed listener now exists in exactly one production module, all remaining api.* calls in ChatInput sit behind a capability gate, and ChatEmbed has exactly 2 in-tree hosts. Final review:

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 ships

Intent: make the app-sdk embed render the one shared composer instead of its own bare input (chat-core RFC P3) — an ADDITION (convergence step).

  1. Embed composer becomes the real ChatInput (multiline, autosize, shared IME/a11y) — justified (RFC P3; the <input> divergence forced feat(chat-core): route ChatEmbed, SideChat and the remaining app send paths through the transport wire #8599's space-flatten hack)
  2. ChatEmbed now requires mounting under the dashboard Redux store — declared; contract change on a public export (see Watch)
  3. New fail-closed embedded flag on ChatInput — 1 consumer, but meaningfully different from explicit ={false} props (those fail open for future capabilities); boundary-derived (app-manifest allowedApiPaths)
  4. Skills focus-prefetch now gated behind typedCommandMenus — cause-level; verified the other api.* calls in ChatInput (approvals ×8, autocompact ×5) already sit behind gates
  5. SideChat silently stops prefetching skills — rides along; verified no reader of the warmed key
  6. New sending prop + "Sending…" in 13 locales — 1 consumer; justified as parity (the old embed showed a spinner; disabled would announce "Stopping…")
  7. New shared useChatConfig(); 4 call sites switch — net deletion of 3 duplicate listener blocks (grep confirms 1 production mc-config-changed listener remains)
  8. Sidebar tagColumnsEnabled and simplified-tool-names gain focus-reload — behavior change riding along, declared
  9. submitOnEnter/composition/isComposing deleted from useComposerDraft — verified 0 production consumers (SideChat uses neither)
  10. Recovered drafts keep the paragraph break; 2 dead catalog keys removed from 13 locales — deletions

Watch

  • The header previously promised "No Redux dependency"; the diff replaces it with "a host mounts this under the dashboard store". Counted: 2 in-tree hosts (spec-builder ChatColumn.tsx, ops-mission-control IncidentChat.tsx), 0 out-of-tree — so nothing breaks today, and the author flags the store-free-seam alternative as a pre-publish gate. Accepted-and-deferred is legitimate, but this is the PR where the one-way door on the public export actually swings; the human sign-off it asks for should happen on this PR, not at publish time.

[FIRST-PRINCIPLES-REVIEWED] 3ab2982

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

UX Review (Fable 5) — ✅ PASS

UX-level review of 3ab29822dc07b9a24f89c82da6feb0f18f58a631 — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

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

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed 3ab29822dc07b9a24f89c82da6feb0f18f58a631 — this comment is updated in place on each push.

Review details

No findings.

The sole candidate (ChatEmbed now requiring a Redux store) is a deliberate, documented contract change, not a defect: both shipping in-tree consumers (spec-builder/ChatColumn.tsx, ops-mission-control/IncidentChat.tsx) mount inside the dashboard SPA under the store, and no unwrapped production mount exists — so there is no concrete input (a) or observable wrong outcome (c) on any code path this diff ships. It dies under falsification.

[OPUS-REVIEWED] 3ab2982

Verdict parsed from the review's SHA-scoped output markers for commit 3ab29822dc07b9a24f89c82da6feb0f18f58a631.

False positive or not applicable? A repository writer can comment:
/ai-review override fable 3ab29822dc07b9a24f89c82da6feb0f18f58a631: <one-sentence reason>

@github-actions github-actions Bot added readiness: passed Eligible automated validation passed for the current revision and removed readiness: checking Automated validation is still running labels Sep 5, 2026
@CrysisDeu
CrysisDeu force-pushed the feat/chat-core-p2-embed-composer branch from 023e31e to 7e2c87c Compare September 5, 2026 02:56
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: passed Eligible automated validation passed for the current revision labels Sep 5, 2026
@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

Design — Watch: ChatInput's ambient api.* calls bypass the embed's app wire (023e31ec1 → fixed in 7e2c87cbb)

Accepted, fixed. Audited every dashboard-client call inside ChatInput against this mount: approval resolution is gated by slotApprovalChrome, the auto-compact popover by the context chip (contextPct, which the embed never passes), and the skills prefetch-on-focus was the one ungated path. It is now onFocus: typedCommandMenus ? prefetchSkills : undefined — a host with the typed menus off has no picker to warm, and an app-embedded composer emits no /api/skills traffic its manifest never declared. New test: with typedCommandMenus={false}, focus issues no api.skills call.

@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

Design — Suggestion: state which ChatInput internals bypass the app wire (023e31ec17e2c87cbb)

Implemented. The ChatEmbed.tsx header now says outright that ChatInput talks to the dashboard client, names the three seams and the prop each sits behind (slotApprovalChrome → approvals, contextPct → auto-compact, typedCommandMenus → skills prefetch), and tells the next author that flipping any of them on for an embed re-opens the question of what the host manifest declares.

@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

First Principles — Subtraction: delete composition / submitOnEnter from useComposerDraft (023e31ec17e2c87cbb)

Applied. composition, isComposing, submitOnEnter, the useImeGuard import and the FocusEvent/KeyboardEvent types are gone from the hook; the header now says Enter/IME lives in ChatInput's own guard. The submitOnEnter test block is deleted with the code. grep submitOnEnter src/app-sdk src/pages: 0 hits outside the SideChat source-contract test that asserts its absence.

@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

First Principles — Subtraction: delete the false "No Redux dependency" header sentence (023e31ec17e2c87cbb)

Applied. The header now reads: transcript and send state live in useAppApi() + React Query; the composer subtree (ChatInput) reads slot state from Redux, so a host mounts the embed under the dashboard store as every in-tree app already does.

@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

Design — Watch/Suggestion: pin the invariant, not the instance (7e2c87cbb770b5c957)

Implemented as prescribed. New ChatEmbed.noDashboardClient.test.tsx replaces api/client with a recording Proxy (so a call it has never heard of is still caught, by name), mounts the narrowed embed under the store, then focuses, types hello $skill /cmd @file, blurs and sends. It asserts the send reached the app wire (useAppApi().post('/api/chat?ws=1', …)) and that the dashboard client saw zero calls. Mutation-verified: removing the typedCommandMenus gate on the prefetch fails it with Received: ["skills"]. Any future ambient effect added to ChatInput now reds this test without anyone touching ChatEmbed.

@CrysisDeu
CrysisDeu force-pushed the feat/chat-core-p2-embed-composer branch from 7e2c87c to 770b5c9 Compare September 5, 2026 03:05
@github-actions github-actions Bot added readiness: passed Eligible automated validation passed for the current revision and removed readiness: checking Automated validation is still running labels Sep 5, 2026
@CrysisDeu
CrysisDeu force-pushed the feat/chat-core-p2-embed-composer branch from 770b5c9 to c587610 Compare September 5, 2026 03:17
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: passed Eligible automated validation passed for the current revision labels Sep 5, 2026
@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

Design — Watch/Suggestion: capability props default on; give the embed a fail-closed preset (770b5c957c58761054)

Implemented as suggested. ChatInput gains preset?: 'embedded'; the three defaulted capabilities now resolve through one line (capabilityDefault = preset !== 'embedded', then x = xProp ?? capabilityDefault), an explicit prop still wins, and ChatEmbed passes preset="embedded" instead of three remembered falses. ChatInput.embeddedPreset.test.tsx pins the shape: a source assertion forbids any capability reading its raw prop with = true, plus render tests for preset-off / explicit-prop-wins / first-class-default. The header now describes the real mechanism (fail-closed preset, opt-in chrome not passed) instead of "passes none renders none".

@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

Design — Watch: "must mount under the dashboard store" narrows where ChatEmbed can live once the SDK publishes standalone (770b5c957c58761054)

Implemented. The app-sdk/index.ts publish-plan comment now carries an explicit note: ChatEmbed renders the host's native ChatInput, whose subtree reads Redux slot state, so it requires the host document; a standalone @kirocrew/app-sdk either ships it host-only or gives the composer a store-free seam first. The PR body has a matching "contract change on a public export" paragraph.

@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

First Principles — Watch: the prefetch gate also changes SideChat (770b5c957c58761054)

Confirmed and now stated in the body. SideChat passes typedCommandMenus={false}, so its focus prefetch stops. Nothing else in SideChat's tree reads the per-slot ['skills', slot, project, agent] entry: SkillPickerMenu is the only reader of that key and is gated off there; the command palette's skillsProvider keys on plain ['skills']. SideChat.steerQueue.test.tsx and SideChat.imeEnter.test.tsx pass unchanged.

@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

First Principles — Watch: public-export contract flip not in the description (770b5c957c58761054)

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.

@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

UX — Suggestion: forward the user's send-key setting (770b5c957c58761054)

Implemented. ChatEmbed reads loadChatConfig().sendOnEnter (the same local setting the main composer honours) and forwards it to ChatInput, reloading on focus and on mc-config-changed exactly as ChatPane does. Local settings only — no fetch — so the zero-dashboard-client invariant test still passes. New test: in ctrl-enter mode plain Enter does not send, Ctrl+Enter does.

CrysisDeu added a commit that referenced this pull request Sep 5, 2026
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.
CrysisDeu added a commit that referenced this pull request Sep 5, 2026
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.
CrysisDeu added a commit that referenced this pull request Sep 5, 2026
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.
CrysisDeu added a commit that referenced this pull request Sep 5, 2026
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.
CrysisDeu added a commit that referenced this pull request Sep 5, 2026
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.
CrysisDeu added a commit that referenced this pull request Sep 5, 2026
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.
CrysisDeu added a commit that referenced this pull request Sep 6, 2026
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.
CrysisDeu added a commit that referenced this pull request Sep 6, 2026
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.
CrysisDeu added a commit that referenced this pull request Sep 6, 2026
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.
CrysisDeu added a commit that referenced this pull request Sep 6, 2026
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.
CrysisDeu added a commit that referenced this pull request Sep 6, 2026
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.
CrysisDeu added a commit that referenced this pull request Sep 6, 2026
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.
CrysisDeu added a commit that referenced this pull request Sep 6, 2026
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.
CrysisDeu added a commit that referenced this pull request Sep 6, 2026
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.
CrysisDeu added a commit that referenced this pull request Sep 6, 2026
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.
CrysisDeu added a commit that referenced this pull request Sep 6, 2026
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.
CrysisDeu added a commit that referenced this pull request Sep 7, 2026
…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.
CrysisDeu added a commit that referenced this pull request Sep 7, 2026
…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.
CrysisDeu added a commit that referenced this pull request Sep 7, 2026
…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.
CrysisDeu added a commit that referenced this pull request Sep 7, 2026
…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.
CrysisDeu added a commit that referenced this pull request Sep 7, 2026
…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.
CrysisDeu added a commit that referenced this pull request Sep 9, 2026
…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.
CrysisDeu added a commit that referenced this pull request Sep 9, 2026
…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.
CrysisDeu added a commit that referenced this pull request Sep 9, 2026
…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.
CrysisDeu added a commit that referenced this pull request Sep 9, 2026
…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).
CrysisDeu added a commit that referenced this pull request Sep 9, 2026
…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).
CrysisDeu added a commit that referenced this pull request Sep 9, 2026
…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.
CrysisDeu added a commit that referenced this pull request Sep 9, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants