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
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ function SpanGlyph({
function LivePhaseHeader({
parts,
animate,
expanded,
lineId,
comboId,
detailId,
Expand All @@ -271,6 +272,9 @@ function LivePhaseHeader({
}: {
parts: ReadonlyArray<TMessageContentParts | undefined>;
animate: boolean;
/** The rows are on screen: title the span by its newest label rather than
* repeat a line the reader can see below. */
expanded: boolean;
lineId: string;
comboId: string;
detailId: string;
Expand All @@ -282,8 +286,8 @@ function LivePhaseHeader({
const mcpServerNames = useMCPServerNames();
const attachmentsById = useMemo(() => mapAttachments(attachments ?? []), [attachments]);
const activity = useMemo(
() => getLiveActivity(parts, localize, mcpServerNames, attachmentsById),
[parts, localize, mcpServerNames, attachmentsById],
() => getLiveActivity(parts, localize, mcpServerNames, attachmentsById, expanded),
[parts, localize, mcpServerNames, attachmentsById, expanded],
);
/** A code card names its sandbox startup from events outside the content
* array. The row reads the same signal for its newest call, so the span
Expand Down Expand Up @@ -774,6 +778,7 @@ export default function ActivityPhaseGroup({
<LivePhaseHeader
parts={liveParts}
animate={smoothStreaming}
expanded={isExpanded}
lineId={lineId}
comboId={comboId}
detailId={detailId}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -547,3 +547,62 @@ describe('ActivityPhaseGroup open header', () => {
expect(screen.getByTestId('activity-phase-panel').firstElementChild).toHaveClass('pl-6');
});
});

describe('ActivityPhaseGroup open live header', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
/** A thought's line holds for a second; the retired line lingers in the
* ticker, so the current line is read from its own title. */
const settle = () => act(() => jest.advanceTimersByTime(1000));
const thought = (reasoning_label?: string): TMessageContentParts =>
({
type: ContentTypes.THINK,
think: 'The refs share a commit. Next I check the ordering.',
...(reasoning_label == null ? {} : { reasoning_label }),
}) as unknown as TMessageContentParts;

test('previews the newest finished sentence while collapsed', () => {
render(
<ActivityPhaseGroup
labelPart={makeLabelPart('')}
hasContent
liveParts={[thought('Comparing refs')]}
>
<div data-testid="phase-content" />
</ActivityPhaseGroup>,
);
expect(screen.getByRole('button')).toHaveTextContent('Next I check the ordering.');
});

test("titles an open card by the thought's label instead of repeating its text", () => {
render(
<ActivityPhaseGroup
labelPart={makeLabelPart('')}
hasContent
liveParts={[thought('Comparing refs')]}
>
<div data-testid="phase-content" />
</ActivityPhaseGroup>,
);
fireEvent.click(screen.getByRole('button'));
settle();
const header = screen.getByRole('button');
expect(within(header).getByTitle('Comparing refs')).toBeInTheDocument();
expect(within(header).queryByTitle('Next I check the ordering.')).toBeNull();
});

test('falls back to the generic thinking line when no label has landed', () => {
render(
<ActivityPhaseGroup labelPart={makeLabelPart('')} hasContent liveParts={[thought()]}>
<div data-testid="phase-content" />
</ActivityPhaseGroup>,
);
fireEvent.click(screen.getByRole('button'));
settle();
expect(within(screen.getByRole('button')).getByTitle('com_ui_thinking')).toBeInTheDocument();
});
});
17 changes: 13 additions & 4 deletions client/src/components/Chat/Messages/Content/live.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ function newestLine(
localize: Localize,
serverNames: readonly string[],
span: SpanSummary,
preferLabels: boolean,
): Pick<LiveActivity, 'text' | 'source' | 'pendingToolCallId' | 'comboCount'> {
for (let position = parts.length - 1; position >= 0; position -= 1) {
const part = parts[position];
Expand All @@ -278,9 +279,12 @@ function newestLine(
if (part.type === ContentTypes.THINK) {
/** Reached before any call or label, this thought IS the tail — the
* model is reasoning about its next step. Its multi-line peek stays
* inside the fold, so the header previews it one sentence at a time. */
* inside the fold, so the header previews it one sentence at a time.
* Unless the fold is open: the text is on screen then, and a header
* repeating a line of it under the reader's eyes is noise, so the
* header keeps to the thought's label. */
const reasoning = typeof part.think === 'string' ? part.think : (part.think?.value ?? '');
const sentence = lastReasoningSentence(reasoning);
const sentence = preferLabels ? undefined : lastReasoningSentence(reasoning);
if (sentence != null) {
return { text: sentence, source: `think:${position}`, comboCount: 1 };
}
Expand All @@ -300,7 +304,7 @@ function newestLine(
* leaving it unnamed would hold a stale call on screen while new prose
* piles up behind the disclosure. */
const value = typeof part.text === 'string' ? part.text : (part.text?.value ?? '');
const commentary = boundIntentLabel(value);
const commentary = preferLabels ? undefined : boundIntentLabel(value);
if (commentary != null) {
return { text: commentary, source: `text:${position}`, comboCount: 1 };
}
Expand Down Expand Up @@ -346,10 +350,15 @@ export function getLiveActivity(
localize: Localize,
serverNames: readonly string[],
attachmentsById?: Record<string, TAttachment[] | undefined>,
/** Name the span by its newest LABEL: a thought's generated label or the
* generic thinking line, a batch label, a call's line — never a line of
* reasoning or commentary. For a header whose rows are on screen, where
* quoting them back is repetition. */
preferLabels = false,
): LiveActivity {
const span = summarizeSpan(parts, attachmentsById);
return {
...newestLine(parts, localize, serverNames, span),
...newestLine(parts, localize, serverNames, span, preferLabels),
outcome: { failed: span.failed, cancelled: span.cancelled },
iconNames: getSpanIconNames(parts),
};
Expand Down
Loading