From 0f99a92fd361be7371b9b0c2d0714f8722f0ce3a Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Fri, 2 Oct 2026 02:29:24 +0300 Subject: [PATCH] =?UTF-8?q?optimistic=20submit:=20one=20Thinking=20placeho?= =?UTF-8?q?lder=20everywhere=20=E2=80=94=20spinner=20from=20the=20instant?= =?UTF-8?q?=20of=20submit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/chat.tsx | 38 ++++++++++++++++++-------------------- components/session-core.ts | 14 -------------- components/session.ts | 1 - tests/session-core.test.ts | 24 +++++++++++------------- 4 files changed, 29 insertions(+), 48 deletions(-) diff --git a/components/chat.tsx b/components/chat.tsx index d893766..09750b5 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -186,7 +186,7 @@ export function Chat({ // (core/view.ts projectView); sends are ONE explicit input event each // ({chatId, input} — the stash indirection died with the SDK), and the // turn stream is the VERBATIM AgentEvent passthrough the server emits. - const { messages, nativeMessages, send, submitAnswer, setMessages, adoptRecord, status, error, reattaching, stop, clearError, resumeStream, setReattachSettled } = + const { messages, nativeMessages, send, submitAnswer, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } = useChatSession({ chatId, initialMessages, initiallyInFlight }); // The reattach settled terminal → the sync-and-continue seam (below). @@ -1177,28 +1177,26 @@ export function Chat({ ))} - {/* The pre-first-chunk line only (owner, streaming round - 2026-10-01): once a tool call starts writing, its LIVE card - carries the activity inline — the old bottom tool-activity - line retired rather than double-render. Same glimmer recipe - as the thinking row: duration 1. The REATTACH variant - (reattach round 2026-10-02): while a fresh mount reattaches - to a server-side turn, the placeholder says what is actually - happening — Reattaching… + the working-state spinner — - instead of a bare Thinking shimmer. */} - {(status === "submitted" || turnInFlight) && - (reattaching ? ( - - - - Reattaching… - - - ) : ( + {/* THE pre-first-chunk placeholder, ONE grammar everywhere + (owner, optimistic round 2026-10-02): spinner + shimmered + Thinking… from the instant anything is submitted (send, + answer, reattach — status flips synchronously, so it shows + before the first token, however slow the provider's + connection) until the first event lands, then it hands off + to the live stream — minimal change: the collapsible + reasoning row carries the SAME look, now with content. + Never collapsible itself (there is nothing to show yet). + Once a tool call starts writing, its LIVE card carries the + activity inline (the bottom tool-activity line retired, + streaming round 2026-10-01). */} + {(status === "submitted" || turnInFlight) && ( + + Thinking… - ))} + + )} {error && (
Something went wrong: {error.message} diff --git a/components/session-core.ts b/components/session-core.ts index b2c7e35..060ce3c 100644 --- a/components/session-core.ts +++ b/components/session-core.ts @@ -140,10 +140,6 @@ export interface SessionState { nativeMessages: LhMessage[]; status: SessionStatus; error: { message: string } | null; - /** A reattach handshake is in flight (resumeStream, no events yet): - * the view shows the honest “Reattaching…” placeholder instead of a - * bare Thinking shimmer (owner, reattach round 2026-10-02). */ - reattaching: boolean; } export interface SessionModelOptions extends SessionModelDeps { @@ -178,9 +174,6 @@ export class SessionModel { private streamingMessageId: string | null = null; private statusValue: SessionStatus = "ready"; private errorValue: { message: string } | null = null; - /** True from resumeStream() entry until its first event / settle / - * error — the handshake window (see SessionState.reattaching). */ - private reattachingValue = false; // The single live consume (POST response or reattach stream): a newer // consume cancels the older — one reader at a time. private consumeAbort: AbortController | null = null; @@ -216,7 +209,6 @@ export class SessionModel { nativeMessages: [...this.base, ...this.tail], status: this.statusValue, error: this.errorValue, - reattaching: this.reattachingValue, }; } @@ -393,7 +385,6 @@ export class SessionModel { } if (!sawEvent) { sawEvent = true; - this.reattachingValue = false; // first event: content, not a handshake if (this.statusValue !== "error") this.setStatus("streaming"); } this.applyWireEvent(event); @@ -401,7 +392,6 @@ export class SessionModel { } finally { this.streamingIds = new Set(); this.executions = new Map(); - this.reattachingValue = false; // settled/aborted: no handshake anymore if (this.statusValue !== "error") this.setStatus("ready"); } } @@ -423,7 +413,6 @@ export class SessionModel { this.consumeAbort?.abort(); const controller = new AbortController(); this.consumeAbort = controller; - this.reattachingValue = true; this.setStatus("submitted"); try { if (refetchRecordFirst) { @@ -447,7 +436,6 @@ export class SessionModel { await this.consumeStream(res); } catch (err) { if ((err as Error).name === "AbortError") return; // a newer consume won - this.reattachingValue = false; this.setError((err as Error).message); this.setStatus("error"); } @@ -465,7 +453,6 @@ export class SessionModel { const controller = new AbortController(); this.consumeAbort = controller; this.setError(null); - this.reattachingValue = false; // an aborted handshake is not a placeholder this.setStatus("submitted"); // The optimistic appends — exactly the messages the server's // write-ahead will persist (inputAppends), with LOCAL ids so @@ -513,7 +500,6 @@ export class SessionModel { stop(): void { this.consumeAbort?.abort(); this.consumeAbort = null; - this.reattachingValue = false; this.streamingIds = new Set(); this.streamingMessageId = null; this.executions = new Map(); diff --git a/components/session.ts b/components/session.ts index 0a90192..7ce0431 100644 --- a/components/session.ts +++ b/components/session.ts @@ -86,7 +86,6 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U nativeMessages: state.nativeMessages, status: state.status, error: state.error, - reattaching: state.reattaching, send, submitAnswer, setMessages, diff --git a/tests/session-core.test.ts b/tests/session-core.test.ts index f2f7101..98507c4 100644 --- a/tests/session-core.test.ts +++ b/tests/session-core.test.ts @@ -492,28 +492,26 @@ describe("resumeStream (the reattach path)", () => { // (the earlier test in this block pins that order). }); - it("reattaching is honest: true during the handshake, false once events flow", async () => { + it("OPTIMISTIC SUBMIT (owner, optimistic round 2026-10-02): send/reattach flip status synchronously — the Thinking… placeholder shows before the first token, however slow the provider", async () => { let release!: (r: Response) => void; const gate = new Promise((res) => (release = res)); globalThis.fetch = (async (input: RequestInfo | URL) => { const url = String(input); - if (url === "/api/chats/chat_x") return gate; // the record fetch hangs - if (url === "/api/chats/chat_x/stream") - return sseResponse( - { type: "message_start", message: { role: "assistant", content: [{ type: "text", text: "hi" }] } }, - { type: "agent_end", messages: [] }, - "[DONE]", - ); + if (url === "/api/chats/chat_x") return gate; // the POST hangs throw new Error(`unexpected fetch: ${url}`); }) as typeof globalThis.fetch; const m = model(); - const p = m.resumeStream(); // runs sync to the first await (the gated fetch) - assert.equal(m.getState().reattaching, true, "mid-handshake: the placeholder is Reattaching…"); - assert.equal(m.getState().status, "submitted"); + // send() runs synchronously to its first await — status is already + // "submitted" (and the optimistic user bubble is appended) before + // the server has answered ANYTHING. + const p = m.send({ type: "text", text: "q" }); + assert.equal(m.getState().status, "submitted", "the placeholder is live pre-first-chunk"); + assert.equal(m.getState().messages.at(-1)!.role, "user", "the optimistic bubble is up"); release(Response.json({ messages: [] })); await p; - assert.equal(m.getState().reattaching, false, "first event landed: content, not a handshake"); - assert.equal(m.getState().status, "ready"); + // the reattach path is the same synchronous flip (resumeStream sets + // submitted at entry, before its fetches) — pinned by the handshake + // test above reading status mid-fetch. }); it("turn-settled fires the settle hook and ends cleanly", async () => {