optimistic submit: one Thinking placeholder everywhere — spinner from the instant of submit

This commit is contained in:
amosblomqvist
2026-10-02 02:29:24 +03:00
parent 142f1978f2
commit 0f99a92fd3
4 changed files with 29 additions and 48 deletions
+18 -20
View File
@@ -186,7 +186,7 @@ export function Chat({
// (core/view.ts projectView); sends are ONE explicit input event each // (core/view.ts projectView); sends are ONE explicit input event each
// ({chatId, input} — the stash indirection died with the SDK), and the // ({chatId, input} — the stash indirection died with the SDK), and the
// turn stream is the VERBATIM AgentEvent passthrough the server emits. // 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 }); useChatSession({ chatId, initialMessages, initiallyInFlight });
// The reattach settled terminal → the sync-and-continue seam (below). // The reattach settled terminal → the sync-and-continue seam (below).
@@ -1177,28 +1177,26 @@ export function Chat({
</Fragment> </Fragment>
))} ))}
{/* The pre-first-chunk line only (owner, streaming round {/* THE pre-first-chunk placeholder, ONE grammar everywhere
2026-10-01): once a tool call starts writing, its LIVE card (owner, optimistic round 2026-10-02): spinner + shimmered
carries the activity inline — the old bottom tool-activity Thinking… from the instant anything is submitted (send,
line retired rather than double-render. Same glimmer recipe answer, reattach — status flips synchronously, so it shows
as the thinking row: duration 1. The REATTACH variant before the first token, however slow the provider's
(reattach round 2026-10-02): while a fresh mount reattaches connection) until the first event lands, then it hands off
to a server-side turn, the placeholder says what is actually to the live stream — minimal change: the collapsible
happening — Reattaching… + the working-state spinner — reasoning row carries the SAME look, now with content.
instead of a bare Thinking shimmer. */} Never collapsible itself (there is nothing to show yet).
{(status === "submitted" || turnInFlight) && Once a tool call starts writing, its LIVE card carries the
(reattaching ? ( activity inline (the bottom tool-activity line retired,
<span className="flex items-center gap-2"> streaming round 2026-10-01). */}
<BrailleSpinner label="Reattaching to the running turn" /> {(status === "submitted" || turnInFlight) && (
<Shimmer duration={1} className="text-sm"> <span className="flex items-center gap-2">
Reattaching… <BrailleSpinner label="Thinking" />
</Shimmer>
</span>
) : (
<Shimmer duration={1} className="text-sm"> <Shimmer duration={1} className="text-sm">
Thinking… Thinking…
</Shimmer> </Shimmer>
))} </span>
)}
{error && ( {error && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm"> <div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm">
Something went wrong: {error.message} Something went wrong: {error.message}
-14
View File
@@ -140,10 +140,6 @@ export interface SessionState {
nativeMessages: LhMessage[]; nativeMessages: LhMessage[];
status: SessionStatus; status: SessionStatus;
error: { message: string } | null; 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 { export interface SessionModelOptions extends SessionModelDeps {
@@ -178,9 +174,6 @@ export class SessionModel {
private streamingMessageId: string | null = null; private streamingMessageId: string | null = null;
private statusValue: SessionStatus = "ready"; private statusValue: SessionStatus = "ready";
private errorValue: { message: string } | null = null; 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 // The single live consume (POST response or reattach stream): a newer
// consume cancels the older — one reader at a time. // consume cancels the older — one reader at a time.
private consumeAbort: AbortController | null = null; private consumeAbort: AbortController | null = null;
@@ -216,7 +209,6 @@ export class SessionModel {
nativeMessages: [...this.base, ...this.tail], nativeMessages: [...this.base, ...this.tail],
status: this.statusValue, status: this.statusValue,
error: this.errorValue, error: this.errorValue,
reattaching: this.reattachingValue,
}; };
} }
@@ -393,7 +385,6 @@ export class SessionModel {
} }
if (!sawEvent) { if (!sawEvent) {
sawEvent = true; sawEvent = true;
this.reattachingValue = false; // first event: content, not a handshake
if (this.statusValue !== "error") this.setStatus("streaming"); if (this.statusValue !== "error") this.setStatus("streaming");
} }
this.applyWireEvent(event); this.applyWireEvent(event);
@@ -401,7 +392,6 @@ export class SessionModel {
} finally { } finally {
this.streamingIds = new Set(); this.streamingIds = new Set();
this.executions = new Map(); this.executions = new Map();
this.reattachingValue = false; // settled/aborted: no handshake anymore
if (this.statusValue !== "error") this.setStatus("ready"); if (this.statusValue !== "error") this.setStatus("ready");
} }
} }
@@ -423,7 +413,6 @@ export class SessionModel {
this.consumeAbort?.abort(); this.consumeAbort?.abort();
const controller = new AbortController(); const controller = new AbortController();
this.consumeAbort = controller; this.consumeAbort = controller;
this.reattachingValue = true;
this.setStatus("submitted"); this.setStatus("submitted");
try { try {
if (refetchRecordFirst) { if (refetchRecordFirst) {
@@ -447,7 +436,6 @@ export class SessionModel {
await this.consumeStream(res); await this.consumeStream(res);
} catch (err) { } catch (err) {
if ((err as Error).name === "AbortError") return; // a newer consume won if ((err as Error).name === "AbortError") return; // a newer consume won
this.reattachingValue = false;
this.setError((err as Error).message); this.setError((err as Error).message);
this.setStatus("error"); this.setStatus("error");
} }
@@ -465,7 +453,6 @@ export class SessionModel {
const controller = new AbortController(); const controller = new AbortController();
this.consumeAbort = controller; this.consumeAbort = controller;
this.setError(null); this.setError(null);
this.reattachingValue = false; // an aborted handshake is not a placeholder
this.setStatus("submitted"); this.setStatus("submitted");
// The optimistic appends — exactly the messages the server's // The optimistic appends — exactly the messages the server's
// write-ahead will persist (inputAppends), with LOCAL ids so // write-ahead will persist (inputAppends), with LOCAL ids so
@@ -513,7 +500,6 @@ export class SessionModel {
stop(): void { stop(): void {
this.consumeAbort?.abort(); this.consumeAbort?.abort();
this.consumeAbort = null; this.consumeAbort = null;
this.reattachingValue = false;
this.streamingIds = new Set(); this.streamingIds = new Set();
this.streamingMessageId = null; this.streamingMessageId = null;
this.executions = new Map(); this.executions = new Map();
-1
View File
@@ -86,7 +86,6 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U
nativeMessages: state.nativeMessages, nativeMessages: state.nativeMessages,
status: state.status, status: state.status,
error: state.error, error: state.error,
reattaching: state.reattaching,
send, send,
submitAnswer, submitAnswer,
setMessages, setMessages,
+11 -13
View File
@@ -492,28 +492,26 @@ describe("resumeStream (the reattach path)", () => {
// (the earlier test in this block pins that order). // (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; let release!: (r: Response) => void;
const gate = new Promise<Response>((res) => (release = res)); const gate = new Promise<Response>((res) => (release = res));
globalThis.fetch = (async (input: RequestInfo | URL) => { globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = String(input); const url = String(input);
if (url === "/api/chats/chat_x") return gate; // the record fetch hangs if (url === "/api/chats/chat_x") return gate; // the POST 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]",
);
throw new Error(`unexpected fetch: ${url}`); throw new Error(`unexpected fetch: ${url}`);
}) as typeof globalThis.fetch; }) as typeof globalThis.fetch;
const m = model(); const m = model();
const p = m.resumeStream(); // runs sync to the first await (the gated fetch) // send() runs synchronously to its first await — status is already
assert.equal(m.getState().reattaching, true, "mid-handshake: the placeholder is Reattaching…"); // "submitted" (and the optimistic user bubble is appended) before
assert.equal(m.getState().status, "submitted"); // 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: [] })); release(Response.json({ messages: [] }));
await p; await p;
assert.equal(m.getState().reattaching, false, "first event landed: content, not a handshake"); // the reattach path is the same synchronous flip (resumeStream sets
assert.equal(m.getState().status, "ready"); // 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 () => { it("turn-settled fires the settle hook and ends cleanly", async () => {