From 5ed9be4c6e758a079190cd8993cd46f4291b9683 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 6 Nov 2026 11:09:07 -0800 Subject: [PATCH 1/4] fix(web): show one live line per coordinator turn --- .../src/pages/workspace/transcript.module.css | 31 ++++++++++++++ .../src/pages/workspace/transcript.test.tsx | 40 +++++++++++++++++++ apps/web/src/pages/workspace/transcript.tsx | 37 ++++++++++++++--- 3 files changed, 103 insertions(+), 5 deletions(-) diff --git a/apps/web/src/pages/workspace/transcript.module.css b/apps/web/src/pages/workspace/transcript.module.css index c2c08a4e..eaf75ea5 100644 --- a/apps/web/src/pages/workspace/transcript.module.css +++ b/apps/web/src/pages/workspace/transcript.module.css @@ -66,6 +66,37 @@ overflow-wrap: anywhere; } +/* The box of the run line that replaces it, so the hand-off doesn't move. */ +.thinking { + align-self: start; + padding: var(--space-1); + font-size: 0.875rem; + line-height: 1.25rem; +} + +/* A live run's line shimmers as ShimmerText does, so it alone shows the turn + is at work. The trigger's first span is its line; the chevron stays solid. */ +.liveRun > span:first-child { + background-image: linear-gradient( + 90deg, + var(--muted-foreground) 40%, + var(--foreground) 50%, + var(--muted-foreground) 60% + ); + background-size: 200% 100%; + background-clip: text; + color: transparent; + animation: global(corbits-shimmer-sweep) 1.8s linear infinite; +} + +@media (prefers-reduced-motion: reduce), (forced-colors: active) { + .liveRun > span:first-child { + animation: none; + background: none; + color: inherit; + } +} + .endedDetails summary { display: inline; cursor: pointer; diff --git a/apps/web/src/pages/workspace/transcript.test.tsx b/apps/web/src/pages/workspace/transcript.test.tsx index 8e4b9ca2..0102490a 100644 --- a/apps/web/src/pages/workspace/transcript.test.tsx +++ b/apps/web/src/pages/workspace/transcript.test.tsx @@ -295,6 +295,46 @@ describe("conversation transcript", () => { expect(streaming([])).toBe(true); }); + test("a coordinator turn shows one live line, at the bottom", () => { + const live = { + role: "assistant", + text: "", + at: -1, + streaming: true, + parts: [{ kind: "reasoning", text: "Read the idea" }], + } as const; + const thinkingLine = 'data-item-id="colleague-thinking"'; + const liveLines = (html: string) => + html.split('data-slot="activity" data-streaming="true"').length - + 1 + + html.split(thinkingLine).length - + 1; + const alone = renderToStaticMarkup( + , + ); + expect(liveLines(alone)).toBe(1); + expect(alone).not.toContain(thinkingLine); + const report = "Saved the requirements."; + const afterReport = renderToStaticMarkup( + , + ); + expect(liveLines(afterReport)).toBe(1); + expect(afterReport.indexOf(thinkingLine)).toBeGreaterThan( + afterReport.indexOf(report), + ); + }); + test("a specialist's report joins its hand-off as one message and a long run of lone ones folds", () => { const summary = "Wrote the limits for your weather app, covering offline use and units."; diff --git a/apps/web/src/pages/workspace/transcript.tsx b/apps/web/src/pages/workspace/transcript.tsx index eee6ed14..bf5f73c2 100644 --- a/apps/web/src/pages/workspace/transcript.tsx +++ b/apps/web/src/pages/workspace/transcript.tsx @@ -35,12 +35,13 @@ import { MessageParts, MessageSendStatus, Reasoning, - ThinkingIndicator, + ShimmerText, Tool, ToolGroup, ToolHeader, ToolInput, ToolOutput, + groupActivity, type ActivityStep, type ConversationMapTurn, type ConversationPart, @@ -812,6 +813,7 @@ export function RoomThread({ turn: TranscriptTurn, visible: readonly ConversationPart[], streaming: boolean, + runLive = streaming, ) => ( { - const label = state.streaming ? undefined : runLabel(steps); + const live = state.streaming && runLive; + const label = live ? undefined : runLabel(steps); return ( - + @@ -854,6 +858,19 @@ export function RoomThread({ threadItems(turns, updates, approvals, runs, designChoice, results), results, ); + // The coordinator's turn shows one live line, at the bottom: its last run when its words end the + // conversation, else the thinking line. A run above a card is not the bottom. + const last = sections.at(-1); + const wordsLast = + last?.kind === "coordinator" && last.cards.length === 0 + ? last.turns.flatMap(({ turn }) => + readableParts(visibleTurnParts(turn), results), + ) + : []; + const runLast = + last?.kind === "coordinator" && + last.turns.some(({ turn }) => turn.streaming === true) && + groupActivity(wordsLast).at(-1)?.kind === "activity"; const mapTurns = sections.flatMap( (section, position): ConversationMapTurn[] => { if (section.kind !== "person" || section.turn.text.trim() === "") @@ -933,6 +950,7 @@ export function RoomThread({ first.turn, visible, streaming, + section === last && wordsLast.length > 0, ) : null} {foldUpdates(liveLast(section.cards, live)).map((card, at) => @@ -971,9 +989,18 @@ export function RoomThread({ ? null : renderMessage(itemId, turn, visible, turn.streaming === true); })} - {thinking ? ( + {thinking && !runLast ? ( - + + {wordsLast.length > 0 ? null : ( + + )} + + + {wording.activity.thinkingNow} + + + ) : null} {children} From 3d40cb960162ef8191492c47720f5f5d4b8aad93 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 6 Nov 2026 21:52:42 -0800 Subject: [PATCH 2/4] fix(web): align run lines with the speaker's name --- apps/web/src/pages/workspace/transcript.module.css | 14 ++++++++++---- apps/web/src/pages/workspace/transcript.tsx | 2 +- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/web/src/pages/workspace/transcript.module.css b/apps/web/src/pages/workspace/transcript.module.css index eaf75ea5..a1a664a1 100644 --- a/apps/web/src/pages/workspace/transcript.module.css +++ b/apps/web/src/pages/workspace/transcript.module.css @@ -66,17 +66,23 @@ overflow-wrap: anywhere; } -/* The box of the run line that replaces it, so the hand-off doesn't move. */ +/* The run line's box, so the hand-off to it doesn't move. */ .thinking { align-self: start; - padding: var(--space-1); + padding-block: var(--space-1); font-size: 0.875rem; line-height: 1.25rem; } +/* The trigger's inline padding (px-1) hangs outside, so its words start under + the speaker's name. */ +.run { + margin-inline-start: calc(-1 * var(--space-1)); +} + /* A live run's line shimmers as ShimmerText does, so it alone shows the turn is at work. The trigger's first span is its line; the chevron stays solid. */ -.liveRun > span:first-child { +[data-streaming] > .run > span:first-child { background-image: linear-gradient( 90deg, var(--muted-foreground) 40%, @@ -90,7 +96,7 @@ } @media (prefers-reduced-motion: reduce), (forced-colors: active) { - .liveRun > span:first-child { + [data-streaming] > .run > span:first-child { animation: none; background: none; color: inherit; diff --git a/apps/web/src/pages/workspace/transcript.tsx b/apps/web/src/pages/workspace/transcript.tsx index bf5f73c2..790dc2aa 100644 --- a/apps/web/src/pages/workspace/transcript.tsx +++ b/apps/web/src/pages/workspace/transcript.tsx @@ -836,7 +836,7 @@ export function RoomThread({ From 4668fc02b9a46087d66ae4fb3fc2a120693f19bc Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 6 Nov 2026 21:56:55 -0800 Subject: [PATCH 3/4] fix(web): prefix the live run's text clip for older Chromium --- apps/web/src/pages/workspace/transcript.module.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/pages/workspace/transcript.module.css b/apps/web/src/pages/workspace/transcript.module.css index a1a664a1..ef8aa8d2 100644 --- a/apps/web/src/pages/workspace/transcript.module.css +++ b/apps/web/src/pages/workspace/transcript.module.css @@ -90,6 +90,8 @@ var(--muted-foreground) 60% ); background-size: 200% 100%; + /* Nothing in the build prefixes; without the clip the text stays transparent. */ + -webkit-background-clip: text; background-clip: text; color: transparent; animation: global(corbits-shimmer-sweep) 1.8s linear infinite; From 1ed2030b883f7435623120226ace730d2e0d0409 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 6 Nov 2026 22:08:25 -0800 Subject: [PATCH 4/4] fix(web): don't repeat the coordinator's name on its thinking line --- apps/web/src/pages/workspace/transcript.tsx | 34 ++++++++++++--------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/apps/web/src/pages/workspace/transcript.tsx b/apps/web/src/pages/workspace/transcript.tsx index 790dc2aa..0ff152e1 100644 --- a/apps/web/src/pages/workspace/transcript.tsx +++ b/apps/web/src/pages/workspace/transcript.tsx @@ -813,7 +813,7 @@ export function RoomThread({ turn: TranscriptTurn, visible: readonly ConversationPart[], streaming: boolean, - runLive = streaming, + runLive: boolean, ) => ( - readableParts(visibleTurnParts(turn), results), - ) - : []; - const runLast = - last?.kind === "coordinator" && - last.turns.some(({ turn }) => turn.streaming === true) && - groupActivity(wordsLast).at(-1)?.kind === "activity"; + let headedLast = false; + let wordsLast = false; + let runLast = false; + if (last?.kind === "coordinator") { + const words = last.turns.flatMap(({ turn }) => + readableParts(visibleTurnParts(turn), results), + ); + headedLast = words.length > 0; + wordsLast = headedLast && last.cards.length === 0; + runLast = + wordsLast && + last.turns.some(({ turn }) => turn.streaming === true) && + groupActivity(words).at(-1)?.kind === "activity"; + } const mapTurns = sections.flatMap( (section, position): ConversationMapTurn[] => { if (section.kind !== "person" || section.turn.text.trim() === "") @@ -950,7 +954,7 @@ export function RoomThread({ first.turn, visible, streaming, - section === last && wordsLast.length > 0, + section === last && wordsLast, ) : null} {foldUpdates(liveLast(section.cards, live)).map((card, at) => @@ -985,14 +989,16 @@ export function RoomThread({ ); const visible = visibleTurnParts(turn); + const streaming = turn.streaming === true; return visible.length === 0 ? null - : renderMessage(itemId, turn, visible, turn.streaming === true); + : renderMessage(itemId, turn, visible, streaming, streaming); })} {thinking && !runLast ? ( - {wordsLast.length > 0 ? null : ( + {/* Under the coordinator's words, as its cards are, the name isn't repeated. */} + {headedLast ? null : ( )}