diff --git a/apps/web/src/pages/workspace/transcript.module.css b/apps/web/src/pages/workspace/transcript.module.css index c2c08a4e..ef8aa8d2 100644 --- a/apps/web/src/pages/workspace/transcript.module.css +++ b/apps/web/src/pages/workspace/transcript.module.css @@ -66,6 +66,45 @@ overflow-wrap: anywhere; } +/* The run line's box, so the hand-off to it doesn't move. */ +.thinking { + align-self: start; + 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. */ +[data-streaming] > .run > span:first-child { + background-image: linear-gradient( + 90deg, + var(--muted-foreground) 40%, + var(--foreground) 50%, + 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; +} + +@media (prefers-reduced-motion: reduce), (forced-colors: active) { + [data-streaming] > .run > 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..0ff152e1 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: boolean, ) => ( { - const label = state.streaming ? undefined : runLabel(steps); + const live = state.streaming && runLive; + const label = live ? undefined : runLabel(steps); return ( - + @@ -854,6 +858,23 @@ 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); + 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() === "") @@ -933,6 +954,7 @@ export function RoomThread({ first.turn, visible, streaming, + section === last && wordsLast, ) : null} {foldUpdates(liveLast(section.cards, live)).map((card, at) => @@ -967,13 +989,24 @@ 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 ? ( + {thinking && !runLast ? ( - + + {/* Under the coordinator's words, as its cards are, the name isn't repeated. */} + {headedLast ? null : ( + + )} + + + {wording.activity.thinkingNow} + + + ) : null} {children}