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}