Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions apps/web/src/pages/workspace/transcript.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
TheGreatAxios marked this conversation as resolved.
Resolved
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;
Expand Down
40 changes: 40 additions & 0 deletions apps/web/src/pages/workspace/transcript.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<RoomThread
turns={[{ role: "person", text: "Start", at: 1 }, live]}
thinking
/>,
);
expect(liveLines(alone)).toBe(1);
expect(alone).not.toContain(thinkingLine);
const report = "Saved the requirements.";
const afterReport = renderToStaticMarkup(
<RoomThread
turns={[
{ role: "person", text: "Start", at: 1 },
{ role: "assistant", text: "Being written up now.", at: 2 },
live,
]}
updates={[{ sender: "writer", text: report, at: 3 }]}
thinking
/>,
);
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.";
Expand Down
45 changes: 39 additions & 6 deletions apps/web/src/pages/workspace/transcript.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,13 @@ import {
MessageParts,
MessageSendStatus,
Reasoning,
ThinkingIndicator,
ShimmerText,
Tool,
ToolGroup,
ToolHeader,
ToolInput,
ToolOutput,
groupActivity,
type ActivityStep,
type ConversationMapTurn,
type ConversationPart,
Expand Down Expand Up @@ -812,6 +813,7 @@ export function RoomThread({
turn: TranscriptTurn,
visible: readonly ConversationPart[],
streaming: boolean,
runLive: boolean,
) => (
<ConversationItem key={itemId} itemId={itemId}>
<Message
Expand All @@ -827,12 +829,14 @@ export function RoomThread({
renderText={renderTurnText}
renderStep={renderStep}
renderActivity={(steps, state) => {
const label = state.streaming ? undefined : runLabel(steps);
const live = state.streaming && runLive;
const label = live ? undefined : runLabel(steps);
return (
<Activity steps={steps} streaming={state.streaming}>
<Activity steps={steps} streaming={live}>
<ActivityTrigger
{...(label === undefined ? {} : { label })}
labels={wording.activity.trigger}
className={styles.run}
/>
<ActivityContent renderStep={renderStep} />
</Activity>
Expand All @@ -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() === "")
Expand Down Expand Up @@ -933,6 +954,7 @@ export function RoomThread({
first.turn,
visible,
streaming,
section === last && wordsLast,
)
: null}
{foldUpdates(liveLast(section.cards, live)).map((card, at) =>
Expand Down Expand Up @@ -967,13 +989,24 @@ export function RoomThread({
</ConversationItem>
);
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 ? (
<ConversationItem itemId="colleague-thinking">
<ThinkingIndicator label={wording.activity.thinkingNow} />
<Message from="agent" status="streaming">
{/* Under the coordinator's words, as its cards are, the name isn't repeated. */}
{headedLast ? null : (
<MessageHeader name={wording.speaker.coordinator} />
)}
<span className={styles.thinking}>
<ShimmerText role="status">
{wording.activity.thinkingNow}
</ShimmerText>
</span>
</Message>
</ConversationItem>
) : null}
{children}
Expand Down