From 7b1ff4edb29d72c0ea30267a8265cdd03d21699f Mon Sep 17 00:00:00 2001 From: kate bonner Date: Mon, 24 Aug 2026 18:13:07 -0400 Subject: [PATCH 1/5] =?UTF-8?q?fix(chat):=20seat=20the=20dark=20prompt=20b?= =?UTF-8?q?ubble=20at=20~85%=20luminance=20=E2=80=94=20no=20more=20glare?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inverse grammar stays (the user's words are the INVERSE of the ground), but its dark realisation was pure grey-50: the only 100%-luminance surface in the UI, at up to 85% column width — a lamp, not a bubble. Dark ink on a light ground doesn't emit; a white panel on a dark one does. New role tokens in design-polish.css (--prompt-bubble-bg/-ink): light is the site's ink bubble verbatim; dark mixes 15% of the page ground into the inverse (#dcdcdc on stock dark — ≈13:1 on the ground, ≈11:1 under its own ink). The bubble, its in-bubble file/agent chips, and the pinned last-prompt ghost all consume the tokens — one grammar on every surface; message-part.css falls back to raw inverse for consumers without the app skin (enterprise, storybook). Verified live on :3004 against the :4096 server: dark bubble computes to color(srgb .8629…) = #dcdcdc on #080808, light stays #000/#fafafa. oxlint (baseline: 1 pre-existing error), tsgo -b clean. --- packages/app/src/design-polish.css | 24 +++++++++++++++++++ .../session/timeline/message-timeline.tsx | 18 +++++++------- .../src/components/message-part.css | 15 +++++++----- 3 files changed, 43 insertions(+), 14 deletions(-) diff --git a/packages/app/src/design-polish.css b/packages/app/src/design-polish.css index 681e37026..22426a24c 100644 --- a/packages/app/src/design-polish.css +++ b/packages/app/src/design-polish.css @@ -150,6 +150,30 @@ } } +/* ── prompt bubble ground — per-scheme LUMINANCE, same grammar ── + The user's words are the INVERSE of the ground (the website's chat + grammar). On light that is the site's ink bubble, verbatim — dark ink on a + light ground doesn't emit. On dark, full bg-inverse is pure grey-50: the + only 100%-luminance surface in the UI, at up to 85% column width — a lamp, + not a bubble. Mixing 15% of the page ground back in seats it at ~85% + luminance: still unmistakably the light thing (≈13:1 on the ground, + ≈11:1 under its own ink), no longer a glare source. */ +:root, +[data-color-scheme="light"] { + --prompt-bubble-bg: var(--v2-background-bg-inverse); + --prompt-bubble-ink: var(--v2-text-text-inverse); +} +[data-color-scheme="dark"] { + --prompt-bubble-bg: color-mix(in srgb, var(--v2-background-bg-inverse) 85%, var(--v2-background-bg-base)); + --prompt-bubble-ink: var(--v2-text-text-inverse); +} +@media (prefers-color-scheme: dark) { + :root:not([data-color-scheme="light"]) { + --prompt-bubble-bg: color-mix(in srgb, var(--v2-background-bg-inverse) 85%, var(--v2-background-bg-base)); + --prompt-bubble-ink: var(--v2-text-text-inverse); + } +} + /* ── inline code: readable in both schemes ── */ :not(pre) > code { color: #3d4451; diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 2d5fc0378..9f26c657b 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -2287,10 +2287,11 @@ export function MessageTimeline(props: { class="ml-auto block w-fit max-w-[min(75%,56ch)] text-left cursor-pointer border-none rounded-lg px-3 py-1.5 text-[13px] leading-[18px] font-normal truncate backdrop-blur-[2px]" style={{ // the ghost of the prompt bubble keeps the bubble's own - // inverse ground, translucent — one grammar for the - // user's words on every surface - background: "color-mix(in srgb, var(--v2-background-bg-inverse) 90%, transparent)", - color: "var(--v2-text-text-inverse)", + // ground, translucent — one grammar for the user's + // words on every surface (--prompt-bubble-*: the + // inverse, seated per scheme in design-polish.css) + background: "color-mix(in srgb, var(--prompt-bubble-bg) 90%, transparent)", + color: "var(--prompt-bubble-ink)", "box-shadow": "0 1px 3px color-mix(in srgb, var(--v2-background-bg-base) 40%, transparent)", }} onClick={scrollToBubbleMessage} @@ -2319,10 +2320,11 @@ export function MessageTimeline(props: { class="ml-auto block w-fit max-w-[min(75%,56ch)] text-left cursor-pointer border-none rounded-lg px-3 py-1.5 text-[13px] leading-[18px] font-normal truncate backdrop-blur-[2px]" style={{ // the ghost of the prompt bubble keeps the bubble's own - // inverse ground, translucent — one grammar for the - // user's words on every surface - background: "color-mix(in srgb, var(--v2-background-bg-inverse) 90%, transparent)", - color: "var(--v2-text-text-inverse)", + // ground, translucent — one grammar for the user's + // words on every surface (--prompt-bubble-*: the + // inverse, seated per scheme in design-polish.css) + background: "color-mix(in srgb, var(--prompt-bubble-bg) 90%, transparent)", + color: "var(--prompt-bubble-ink)", "box-shadow": "0 1px 3px color-mix(in srgb, var(--v2-background-bg-base) 40%, transparent)", }} onClick={scrollToBubbleMessage} diff --git a/packages/session-ui/src/components/message-part.css b/packages/session-ui/src/components/message-part.css index c8de7d25b..182a4c192 100644 --- a/packages/session-ui/src/components/message-part.css +++ b/packages/session-ui/src/components/message-part.css @@ -148,16 +148,19 @@ /* The prompt bubble, from the website's /amicode chat (harmoniqs-ai app/components/demo/parts.jsx, `Turn`): the user's words are the INVERSE - of the ground — bg-deep/text-bg there. bg-inverse/text-inverse is that - same pair in both schemes: an ink bubble on light, a near-white bubble on - dark. Padding is the site's px-4 py-2.5. */ + of the ground — bg-deep/text-bg there. The app tokens + (--prompt-bubble-*, design-polish.css) realise that per scheme: the + site's ink bubble verbatim on light; on dark the inverse is seated at + ~85% luminance so a large light bubble doesn't glare on a dark field. + Fallback = raw inverse, for consumers without the app skin. + Padding is the site's px-4 py-2.5. */ [data-slot="user-message-text"] { display: inline-block; white-space: pre-wrap; word-break: break-word; overflow: hidden; - background: var(--v2-background-bg-inverse); - color: var(--v2-text-text-inverse); + background: var(--prompt-bubble-bg, var(--v2-background-bg-inverse)); + color: var(--prompt-bubble-ink, var(--v2-text-text-inverse)); border: none; padding: 10px 16px; border-radius: var(--radius-lg); @@ -172,7 +175,7 @@ font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); font-size: 0.85em; color: inherit; - background: color-mix(in srgb, var(--v2-text-text-inverse) 14%, transparent); + background: color-mix(in srgb, var(--prompt-bubble-ink, var(--v2-text-text-inverse)) 14%, transparent); border-radius: var(--radius-sm); padding: 0.08em 0.35em; } From 2bfaeeee54f1e63aec78494e81f965b858ca5a27 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Mon, 24 Aug 2026 18:19:35 -0400 Subject: [PATCH 2/5] =?UTF-8?q?fix(timeline):=20drop=20the=20UPDATE=20eyeb?= =?UTF-8?q?row=20on=20prose=20steps=20=E2=80=94=20it=20said=20nothing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rail label exists to name steps whose content doesn't open with its own title. For reasoning that's real information; for prose the words ARE the step, and captioning every one "UPDATE" was pure repetition — one walkthrough session wore seven of them. Reasoning keeps its label; prose now leads with itself, and the measured dot-on-first-line (69c98ee) re-seats the rail dot on the prose automatically. Verified live on :3004: sessions that rendered 2/7/3 UPDATE labels render zero, REASONING unaffected. oxlint (baseline), tsgo -b clean. --- packages/app/src/pages/session/timeline/rows.ts | 6 +++--- packages/app/src/pages/session/timeline/timeline-row.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/app/src/pages/session/timeline/rows.ts b/packages/app/src/pages/session/timeline/rows.ts index 0a59e3cec..20dc98563 100644 --- a/packages/app/src/pages/session/timeline/rows.ts +++ b/packages/app/src/pages/session/timeline/rows.ts @@ -182,14 +182,14 @@ export namespace Timeline { const turnIsRunning = isActive && status === "busy" && !error // The rail label names steps whose content doesn't already open with its // own title. Group rows announce themselves ("Explored", "Worked in - // shell", "Edited files") and tool cards wear their chips, so only bare - // prose and reasoning steps need a name here. + // shell", "Edited files") and tool cards wear their chips. Prose needs no + // caption either — the words ARE the step, and "Update" said nothing they + // don't (Kate 2026-08-24) — so only reasoning steps get a name here. const railLabel = (group: PartGroup): string | undefined => { if (group.type !== "part") return undefined const part = assistantPartRefs.find( (ref) => ref.messageID === group.ref.messageID && ref.part.id === group.ref.partID, )?.part - if (part?.type === "text") return "Update" if (part?.type === "reasoning") return "Reasoning" return undefined } diff --git a/packages/app/src/pages/session/timeline/timeline-row.ts b/packages/app/src/pages/session/timeline/timeline-row.ts index 553a08ba1..9cb9d15f7 100644 --- a/packages/app/src/pages/session/timeline/timeline-row.ts +++ b/packages/app/src/pages/session/timeline/timeline-row.ts @@ -28,9 +28,9 @@ export namespace TimelineRow { /** the turn is still working, so the tail step is in flight rather than done */ turnRunning: boolean /** eyebrow naming the action for steps whose content doesn't already open - * with its own title — assistant prose ("Update") and reasoning - * ("Reasoning"). Tool cards and the Explored / Worked-in-shell / Edited - * group headers announce themselves, so they carry no label here. */ + * with its own title — reasoning ("Reasoning") only. Prose carries no + * caption (the words are the step), and tool cards and the Explored / + * Worked-in-shell / Edited group headers announce themselves. */ railLabel?: string }> {} export class Thinking extends Data.TaggedClass("Thinking")<{ From befe048704524a6f639167db2d3b2f159bdfc3d0 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Mon, 24 Aug 2026 18:48:47 -0400 Subject: [PATCH 3/5] fix(app): centre the draft landing's mark+composer optically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream anchored the group's top at a fixed 25.375% of the panel — tuned for a wide desktop window. In the tall Amicode webview that beached the mark and composer in the upper third with a hold of empty space below. Replace the anchor with the same grammar the start screen already uses (session-new-view): flex centring plus a pb lift, so the group sits at the optical centre at any panel height. --- packages/app/src/pages/new-session/new-session-view.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/app/src/pages/new-session/new-session-view.tsx b/packages/app/src/pages/new-session/new-session-view.tsx index 27e167ca1..35334f61a 100644 --- a/packages/app/src/pages/new-session/new-session-view.tsx +++ b/packages/app/src/pages/new-session/new-session-view.tsx @@ -41,7 +41,13 @@ export function NewSessionView(props: { data-component="session-new-design" class="relative flex-1 min-h-0 overflow-hidden rounded-md bg-v2-background-bg-deep" > -
+ {/* amicode: centre the mark+composer group optically — flex centring + with the same pb-lift the start screen uses (session-new-view), + so the group tracks the panel's height. Upstream's fixed + top-[25.375%] anchor was tuned for a wide desktop window; in the + tall Amicode webview it beached the group in the upper third with + a hold of empty space below. */} +
{/* amicode: brand mark + wordmark (recovered composition — the H-robot over the AMICODE wordmark, Kate's Kimi-clean ordering; From 1ef7c412f59051889474b3583ecbe15720a0615d Mon Sep 17 00:00:00 2001 From: kate bonner Date: Mon, 24 Aug 2026 20:10:58 -0400 Subject: [PATCH 4/5] =?UTF-8?q?fix(chat):=20seat=20the=20dark=20prompt=20b?= =?UTF-8?q?ubble=20lower=20still=20=E2=80=94=20~70%=20luminance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 85% seat was still too bright in use (Kate). Same grammar, same tokens — only dark's mix moves: 30% of the page ground into the inverse instead of 15%, ≈#b9b9b9 on stock dark. Separation ≈9:1 on the ground, ≈8:1 under its own ink — comfortably above every floor. --- packages/app/src/design-polish.css | 11 ++++++----- packages/session-ui/src/components/message-part.css | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/app/src/design-polish.css b/packages/app/src/design-polish.css index 22426a24c..4fb88d7b4 100644 --- a/packages/app/src/design-polish.css +++ b/packages/app/src/design-polish.css @@ -155,21 +155,22 @@ grammar). On light that is the site's ink bubble, verbatim — dark ink on a light ground doesn't emit. On dark, full bg-inverse is pure grey-50: the only 100%-luminance surface in the UI, at up to 85% column width — a lamp, - not a bubble. Mixing 15% of the page ground back in seats it at ~85% - luminance: still unmistakably the light thing (≈13:1 on the ground, - ≈11:1 under its own ink), no longer a glare source. */ + not a bubble. Mixing 30% of the page ground back in seats it near ~70% + luminance (Kate 2026-08-24: the first seat at 85% still read too bright): + still unmistakably the light thing (≈9:1 on the ground, ≈8:1 under its + own ink), no longer a glare source. */ :root, [data-color-scheme="light"] { --prompt-bubble-bg: var(--v2-background-bg-inverse); --prompt-bubble-ink: var(--v2-text-text-inverse); } [data-color-scheme="dark"] { - --prompt-bubble-bg: color-mix(in srgb, var(--v2-background-bg-inverse) 85%, var(--v2-background-bg-base)); + --prompt-bubble-bg: color-mix(in srgb, var(--v2-background-bg-inverse) 70%, var(--v2-background-bg-base)); --prompt-bubble-ink: var(--v2-text-text-inverse); } @media (prefers-color-scheme: dark) { :root:not([data-color-scheme="light"]) { - --prompt-bubble-bg: color-mix(in srgb, var(--v2-background-bg-inverse) 85%, var(--v2-background-bg-base)); + --prompt-bubble-bg: color-mix(in srgb, var(--v2-background-bg-inverse) 70%, var(--v2-background-bg-base)); --prompt-bubble-ink: var(--v2-text-text-inverse); } } diff --git a/packages/session-ui/src/components/message-part.css b/packages/session-ui/src/components/message-part.css index 182a4c192..7fc5b2cc0 100644 --- a/packages/session-ui/src/components/message-part.css +++ b/packages/session-ui/src/components/message-part.css @@ -151,7 +151,7 @@ of the ground — bg-deep/text-bg there. The app tokens (--prompt-bubble-*, design-polish.css) realise that per scheme: the site's ink bubble verbatim on light; on dark the inverse is seated at - ~85% luminance so a large light bubble doesn't glare on a dark field. + ~70% luminance so a large light bubble doesn't glare on a dark field. Fallback = raw inverse, for consumers without the app skin. Padding is the site's px-4 py-2.5. */ [data-slot="user-message-text"] { From 0d4359ce89d122c06e9f5709c962231dd3ed354c Mon Sep 17 00:00:00 2001 From: kate bonner Date: Mon, 24 Aug 2026 21:01:44 -0400 Subject: [PATCH 5/5] =?UTF-8?q?feat(timeline):=20blocks=20land=20whole,=20?= =?UTF-8?q?with=20a=20crisp=20entrance=20=E2=80=94=20no=20typing=20reveal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two halves of one grammar (the site demo's): each step of a turn appears as a COMPLETE block, and its appearance is animated. Withholding: a text/reasoning part that is still streaming — the tail of the last assistant message with no time.end — is kept out of the timeline; the Thinking row is the working signal while it composes, and the finished block enters whole. A part with a successor is complete by definition, so only the tail is ever withheld; a done turn withholds nothing, so history renders exactly as before. In showReasoning mode Thinking now also stands in while the tail is withheld (previously it hid once any part existed). Entrance: a row that JOINS the projection after the session's first paint animates in once — 4px rise + fade, 0.18s ease-out (--motion-enter-* tokens in design-polish.css). Virtual rows remount on every scroll-back, so mount alone never triggers it: a per-session key set, seeded synchronously with the whole history by the first mount after a session switch, decides each key exactly once. Animation is opacity+transform on the measured inner div only — the virtualizer owns row position and height. The row clip-margin grows 4px→8px to cover ring + rise. Reduced motion keeps the fade, drops the rise. rows-current.test.ts updated to the new spec: a streaming tail yields Thinking, not a half-streamed part row (the error-removal test's intent — no stale Error row once the turn resumes — is unchanged). Verified live on :3004 against :4096, dark, with a real streamed turn: 36/48 poll samples show Thinking with the prose withheld; the block's first DOM appearance already carries animation timeline-enter; turn-gap, bubble, Thinking, and prose each animated exactly once; zero pre-existing rows animated on load or after a scroll roundtrip. tsgo -b clean, oxlint baseline, timeline tests 37/37. --- packages/app/src/design-polish.css | 30 ++++++++++++++++ .../session/timeline/message-timeline.tsx | 36 ++++++++++++++++--- .../session/timeline/rows-current.test.ts | 10 ++++-- .../app/src/pages/session/timeline/rows.ts | 28 ++++++++++++--- 4 files changed, 93 insertions(+), 11 deletions(-) diff --git a/packages/app/src/design-polish.css b/packages/app/src/design-polish.css index 681e37026..d4c1a77ab 100644 --- a/packages/app/src/design-polish.css +++ b/packages/app/src/design-polish.css @@ -150,6 +150,36 @@ } } +/* ── entrance motion — timeline blocks land whole ── + New timeline rows (Kate 2026-08-24) enter as complete blocks: a small rise + and fade, fast and crisp, decided per row-key exactly once (the timeline + guards against virtual-row remount replays). Opacity+transform only — the + virtualizer owns row position and measured height, so entrance motion must + never touch layout. Reduced motion keeps the fade, drops the rise. */ +:root { + --motion-enter-duration: 0.18s; + --motion-enter-rise: 4px; + --motion-enter-ease: cubic-bezier(0.215, 0.61, 0.355, 1); +} +[data-timeline-enter] { + animation: timeline-enter var(--motion-enter-duration) var(--motion-enter-ease) both; +} +@keyframes timeline-enter { + from { + opacity: 0; + transform: translateY(var(--motion-enter-rise)); + } + to { + opacity: 1; + transform: translateY(0); + } +} +@media (prefers-reduced-motion: reduce) { + :root { + --motion-enter-rise: 0px; + } +} + /* ── inline code: readable in both schemes ── */ :not(pre) > code { color: #3d4451; diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 2d5fc0378..92c84fd3f 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -458,6 +458,29 @@ export function MessageTimeline(props: { const timelineRowByKey = projection.rowByKey const timelineRows = projection.rows + // Entrance bookkeeping (Kate 2026-08-24: blocks animate in whole, crisply). + // A row animates only when it JOINS the projection after the session's first + // paint, and only once — virtual rows unmount and remount on every + // scroll-back, so mount alone must never trigger the entrance. The first + // mounted row after a session switch seeds the set with the whole history + // (synchronously, so no render of stale rows can slip in between); a key + // never seen before animates and is recorded. Rows that join off-window + // animate on their first scroll into view — still exactly once. + let enteredFor: string | undefined + const enteredKeys = new Set() + const shouldAnimateEnter = (rowKey: string) => { + const sid = sessionID() + if (enteredFor !== sid) { + enteredFor = sid + enteredKeys.clear() + for (const row of timelineRows()) enteredKeys.add(TimelineRow.key(row)) + return false + } + if (enteredKeys.has(rowKey)) return false + enteredKeys.add(rowKey) + return true + } + let prependAnchor: { key: string; offset: number } | undefined let prependAnchorFrame: number | undefined let prependLoading = false @@ -1583,6 +1606,8 @@ export function MessageTimeline(props: { let element: HTMLDivElement const initialItem = virtualItemByKey().get(props.rowKey)! const initialRow = timelineRowByKey().get(props.rowKey)! + // Decided once at creation — remounts of an already-entered row get false. + const animateEnter = shouldAnimateEnter(props.rowKey) const item = createMemo(() => virtualItemByKey().get(props.rowKey) ?? initialItem) const row = createMemo(() => timelineRowByKey().get(props.rowKey) ?? initialRow) const tool = () => { @@ -1622,11 +1647,11 @@ export function MessageTimeline(props: { height: `${item().size}px`, overflow: "clip", // Rounded virtual measurements can otherwise clip a framed row's outer paint. - // 4px, not 0.5px: the live rail dot breathes by a 0→4px ring - // (index.css thought-rail-breathe) and the old margin clipped the - // whole animation away — found in PR #246's testing. Keep in step - // with the ring size there. - "overflow-clip-margin": row()._tag === "TurnGap" ? undefined : "4px", + // 8px, not 0.5px: the live rail dot breathes by a 0→4px ring + // (index.css thought-rail-breathe; found clipped in PR #246's + // testing), and an entering row rides a --motion-enter-rise + // translate on top of it — the margin must cover ring + rise. + "overflow-clip-margin": row()._tag === "TurnGap" ? undefined : "8px", }} >
{ ) expect(result.activeMessageID).toBe("msg_3") + // msg_4's reasoning is the busy turn's streaming tail (no time.end) — it + // is withheld until it completes (blocks land whole), so Thinking stands + // in as the working signal. expect(result.rows.map(TimelineRow.key)).toEqual([ "user-message:msg_1", "assistant-part:msg_1:msg_2:text:0", "turn-gap:msg_3", "user-message:msg_3", - "assistant-part:msg_3:msg_4:reasoning:0", + "thinking:msg_3", ]) }) @@ -202,6 +205,9 @@ describe("current session timeline rows", () => { normalized.messages.filter((message) => message.role === "user"), ) - expect(result.rows.map((row) => row._tag)).toEqual(["UserMessage", "AssistantPart"]) + // The stale error row must not appear once the turn resumes. The resumed + // text is the streaming tail (no time.end) so it is withheld until it + // completes — Thinking, not the half-streamed part, is what renders. + expect(result.rows.map((row) => row._tag)).toEqual(["UserMessage", "Thinking"]) }) }) diff --git a/packages/app/src/pages/session/timeline/rows.ts b/packages/app/src/pages/session/timeline/rows.ts index 0a59e3cec..a39f983b4 100644 --- a/packages/app/src/pages/session/timeline/rows.ts +++ b/packages/app/src/pages/session/timeline/rows.ts @@ -127,24 +127,41 @@ export namespace Timeline { .filter((part) => renderable(part, showReasoning)) .map((part) => ({ messageID: message.id, messageIndex, part })), ) + // Steps land as FULL blocks (Kate 2026-08-24: no typing reveal — the site + // demo's grammar, each step appears complete). A text/reasoning part that + // is still streaming — the tail of the last assistant message with no + // time.end yet — is withheld from the timeline; the Thinking row is the + // working signal while it composes, and the finished block enters whole. + // A part with a successor is complete by definition, so only the tail can + // ever be withheld, and a done turn (status not busy) withholds nothing. + const tail = assistantPartRefs.at(-1) + const tailStreaming = + isActive && + status === "busy" && + !error && + tail !== undefined && + tail.messageIndex === assistantMessages.length - 1 && + (tail.part.type === "text" || tail.part.type === "reasoning") && + !tail.part.time?.end + const settledPartRefs = tailStreaming ? assistantPartRefs.slice(0, -1) : assistantPartRefs const assistantItems = interrupted && !compaction ? [ - ...groupParts(assistantPartRefs.filter((ref) => ref.messageIndex <= interruptedMessageIndex)).map( + ...groupParts(settledPartRefs.filter((ref) => ref.messageIndex <= interruptedMessageIndex)).map( (group) => ({ type: "part" as const, group, }), ), { type: "interrupted" as const }, - ...groupParts(assistantPartRefs.filter((ref) => ref.messageIndex > interruptedMessageIndex)).map( + ...groupParts(settledPartRefs.filter((ref) => ref.messageIndex > interruptedMessageIndex)).map( (group) => ({ type: "part" as const, group, }), ), ] - : groupParts(assistantPartRefs).map((group) => ({ type: "part" as const, group })) + : groupParts(settledPartRefs).map((group) => ({ type: "part" as const, group })) if (previousUserMessage) rows.push(new TimelineRow.TurnGap({ userMessageID: userMessage.id })) if (comments.length > 0 && !inlineComments) @@ -217,7 +234,10 @@ export namespace Timeline { assistantGroupIndex += 1 }) - if (isActive && status === "busy" && !error && (showReasoning ? assistantPartRefs.length === 0 : true)) { + // In showReasoning mode the reasoning rows themselves carry the working + // signal — except while the tail is withheld (streaming), when Thinking + // must stand in for it or the turn would show nothing at all. + if (isActive && status === "busy" && !error && (showReasoning ? settledPartRefs.length === 0 || tailStreaming : true)) { const heading = assistantMessages .flatMap((message) => getMessageParts(message.id)) .map((part) => (part.type === "reasoning" && part.text ? reasoningHeading(part.text) : undefined))