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 () => {