optimistic submit: one Thinking placeholder everywhere — spinner from the instant of submit
This commit is contained in:
+18
-20
@@ -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}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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 () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user