diff --git a/components/chat.tsx b/components/chat.tsx index 09750b5..48a2fe3 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, stop, clearError, resumeStream, setReattachSettled } = + const { messages, nativeMessages, send, submitAnswer, setMessages, adoptRecord, status, error, awaitingFirstChunk, stop, clearError, resumeStream, setReattachSettled } = useChatSession({ chatId, initialMessages, initiallyInFlight }); // The reattach settled terminal → the sync-and-continue seam (below). @@ -1177,19 +1177,19 @@ export function Chat({ ))} - {/* 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) && ( + {/* THE pre-first-CONTENT placeholder, ONE grammar everywhere + (owner, optimistic round 2, 2026-10-02): spinner + + shimmered Thinking… from the instant of any action that + produces tokens (send, answer, session start, reattach, + wake) — status flips synchronously — and it stays past + agent_start (protocol events release nothing; the old + status gate vanished the line into an empty wait) until + the turn's first VISIBLE content: a reasoning/text delta + or a tool call being written, where the stream takes over + with the SAME look (the collapsible reasoning row / live + tool card). The release is sticky — no flash between + assistant steps. Never collapsible itself. */} + {(awaitingFirstChunk || turnInFlight) && ( diff --git a/components/session-core.ts b/components/session-core.ts index 060ce3c..65bbb7d 100644 --- a/components/session-core.ts +++ b/components/session-core.ts @@ -135,11 +135,30 @@ export function absorbed(m: LhMessage, record: readonly LhMessage[]): boolean { return false; } +/** Does this content array carry anything VISIBLE (owner, optimistic + * round 2, 2026-10-02)? A text/thinking block with text, or a tool call + * being written (the live tool card renders from its first streamed + * block). Empty leading parts (headers, empty message starts) do NOT + * count — the placeholder lives until real content, past agent_start. */ +function contentHasVisible(content: unknown): boolean { + if (!Array.isArray(content)) return false; + return content.some((b) => { + const block = b as { type?: string; text?: string }; + return block?.type === "toolCall" || (typeof block?.text === "string" && block.text.length > 0); + }); +} + export interface SessionState { messages: LhViewMessage[]; nativeMessages: LhMessage[]; status: SessionStatus; error: { message: string } | null; + /** A turn is running (this client submitted or reattached) and has + * produced NO visible content yet — the Thinking… placeholder's + * honest gate: protocol events (agent_start) do not release it, the + * turn's first content does, and the release is sticky for the rest + * of the turn (owner, optimistic round 2, 2026-10-02). */ + awaitingFirstChunk: boolean; } export interface SessionModelOptions extends SessionModelDeps { @@ -174,6 +193,9 @@ export class SessionModel { private streamingMessageId: string | null = null; private statusValue: SessionStatus = "ready"; private errorValue: { message: string } | null = null; + /** Sticky per-consume: this turn has shown visible content (see + * SessionState.awaitingFirstChunk). Reset at every consume start. */ + private sawContentValue = 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; @@ -209,6 +231,8 @@ export class SessionModel { nativeMessages: [...this.base, ...this.tail], status: this.statusValue, error: this.errorValue, + awaitingFirstChunk: + (this.statusValue === "submitted" || this.statusValue === "streaming") && !this.sawContentValue, }; } @@ -289,6 +313,7 @@ export class SessionModel { const mid = message.id; if (mid === undefined) return; this.mutate(() => { + this.sawContentValue = true; // a rendered marker is content const idx = this.tail.findIndex((m) => m.id === mid); this.tail = idx === -1 ? [...this.tail, message as LhMessage] : this.tail.with(idx, message as LhMessage); @@ -309,14 +334,17 @@ export class SessionModel { // the wire) still wins. const id = message.id ?? this.streamingMessageId ?? localId(); const withId = { ...message, id } as LhMessage; + const hasContent = contentHasVisible((message as { content?: unknown }).content); if (agent.type === "message_start") { this.streamingMessageId = id; this.streamingIds.add(id); this.mutate(() => { + this.sawContentValue ||= hasContent; this.tail = [...this.tail, withId]; }); } else { this.mutate(() => { + this.sawContentValue ||= hasContent; const idx = this.tail.findIndex((m) => m.id === id); this.tail = idx === -1 ? [...this.tail, withId] : this.tail.with(idx, withId); }); @@ -354,6 +382,7 @@ export class SessionModel { // The results batch: append as native messages (client-stamped // ids — the settle sync lands the server canon). this.mutate(() => { + this.sawContentValue = true; // rendered results are content this.tail = [ ...this.tail, ...(agent.toolResults as ToolResultMessage[]).map( @@ -374,6 +403,7 @@ export class SessionModel { if (!res.body) throw new Error("The turn stream body is empty."); const reader = res.body.getReader(); let sawEvent = false; + this.sawContentValue = false; // fresh consume: nothing visible yet try { for await (const event of sseEvents(reader)) { if (event.type === "turn-settled") { diff --git a/components/session.ts b/components/session.ts index 7ce0431..c7686be 100644 --- a/components/session.ts +++ b/components/session.ts @@ -86,6 +86,7 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U nativeMessages: state.nativeMessages, status: state.status, error: state.error, + awaitingFirstChunk: state.awaitingFirstChunk, send, submitAnswer, setMessages, diff --git a/tests/session-core.test.ts b/tests/session-core.test.ts index 98507c4..459afe2 100644 --- a/tests/session-core.test.ts +++ b/tests/session-core.test.ts @@ -492,7 +492,7 @@ describe("resumeStream (the reattach path)", () => { // (the earlier test in this block pins that order). }); - 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 () => { + it("OPTIMISTIC SUBMIT (owner, optimistic round 2026-10-02): send flips status synchronously — the placeholder is live before the server answers anything", async () => { let release!: (r: Response) => void; const gate = new Promise((res) => (release = res)); globalThis.fetch = (async (input: RequestInfo | URL) => { @@ -510,8 +510,88 @@ describe("resumeStream (the reattach path)", () => { release(Response.json({ messages: [] })); await p; // 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. + // submitted at entry, before its fetches). + }); + + it("CONTENT-GATED, not status-gated (owner, optimistic round 2): agent_start does NOT release the placeholder — the first visible content does", async () => { + // The old bug: the server emits agent_start instantly (pre-provider), + // the first wire event flipped status to streaming, and the line + // VANISHED into an empty wait until the provider's first token. + let release!: () => void; + const gate = new Promise((res) => (release = res)); + const body = new ReadableStream({ + async start(c) { + const enc = new TextEncoder(); + c.enqueue(enc.encode(sse({ type: "agent_start" }, { type: "turn_start" }))); + await gate; // the provider is slow — nothing but protocol so far + c.enqueue( + enc.encode( + sse({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "hi" }] } }), + ), + ); + c.enqueue(enc.encode(sse({ type: "agent_end", messages: [] }, "[DONE]"))); + c.close(); + }, + }); + globalThis.fetch = (async () => + new Response(body, { headers: { "Content-Type": "text/event-stream" } })) as typeof globalThis.fetch; + const m = model(); + const p = m.send({ type: "text", text: "q" }); + assert.equal(m.getState().awaitingFirstChunk, true, "submitted: placeholder up"); + await new Promise((r) => setTimeout(r, 25)); // let agent_start arrive + consume + assert.equal(m.getState().status, "streaming", "agent_start arrived — status flipped"); + assert.equal(m.getState().awaitingFirstChunk, true, "protocol events release NOTHING: no vanish into the empty wait"); + release(); + await p; + assert.equal(m.getState().status, "ready"); + assert.equal(m.getState().awaitingFirstChunk, false, "the turn ended with content — nothing awaited"); + }); + + it("EMPTY leading parts do not release; the release is STICKY for the rest of the turn", async () => { + let release!: () => void; + const gate = new Promise((res) => (release = res)); + const body = new ReadableStream({ + async start(c) { + const enc = new TextEncoder(); + // message_start with EMPTY content: an empty thinking header, + // nothing renderable — the placeholder must stay. + c.enqueue(enc.encode(sse({ type: "message_start", message: { role: "assistant", content: [] } }))); + await gate; + c.enqueue( + enc.encode( + sse({ type: "message_update", message: { role: "assistant", content: [{ type: "thinking", text: "pondering" }] } }), + ), + ); + c.enqueue( + enc.encode( + sse({ type: "message_end", message: { role: "assistant", content: [{ type: "thinking", text: "pondering" }], stopReason: "stop" } }), + ), + ); + // a SECOND assistant message opens with empty content — the + // release must NOT flash the placeholder back mid-turn. + c.enqueue(enc.encode(sse({ type: "message_start", message: { role: "assistant", content: [] } }))); + await new Promise((r) => setTimeout(r, 10)); + c.enqueue( + enc.encode( + sse({ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "done" }], stopReason: "stop" } }), + ), + ); + c.enqueue(enc.encode(sse({ type: "agent_end", messages: [] }, "[DONE]"))); + c.close(); + }, + }); + globalThis.fetch = (async () => + new Response(body, { headers: { "Content-Type": "text/event-stream" } })) as typeof globalThis.fetch; + const m = model(); + const p = m.send({ type: "text", text: "q" }); + await new Promise((r) => setTimeout(r, 25)); // message_start (empty) consumed + assert.equal(m.getState().status, "streaming"); + assert.equal(m.getState().awaitingFirstChunk, true, "empty leading parts render nothing — placeholder stays"); + release(); + await new Promise((r) => setTimeout(r, 25)); // first thinking delta + second empty message_start + assert.equal(m.getState().awaitingFirstChunk, false, "first content released — sticky through the empty second start"); + await p; + assert.equal(m.getState().awaitingFirstChunk, false); }); it("turn-settled fires the settle hook and ends cleanly", async () => {