placeholder is content-gated: survives agent_start, releases at first visible content, sticky per turn

This commit is contained in:
amosblomqvist
2026-10-02 02:42:25 +03:00
parent 0f99a92fd3
commit e5b06f7fbd
4 changed files with 128 additions and 17 deletions
+14 -14
View File
@@ -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({
</Fragment>
))}
{/* 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) && (
<span className="flex items-center gap-2">
<BrailleSpinner label="Thinking" />
<Shimmer duration={1} className="text-sm">
+30
View File
@@ -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") {
+1
View File
@@ -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,