diff --git a/client/src/components/Chat/Messages/Content/ActivityPhaseGroup.tsx b/client/src/components/Chat/Messages/Content/ActivityPhaseGroup.tsx index d73ded46b6d..d9520e4e2f1 100644 --- a/client/src/components/Chat/Messages/Content/ActivityPhaseGroup.tsx +++ b/client/src/components/Chat/Messages/Content/ActivityPhaseGroup.tsx @@ -263,6 +263,7 @@ function SpanGlyph({ function LivePhaseHeader({ parts, animate, + expanded, lineId, comboId, detailId, @@ -271,6 +272,9 @@ function LivePhaseHeader({ }: { parts: ReadonlyArray; 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; @@ -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 @@ -774,6 +778,7 @@ export default function ActivityPhaseGroup({ { 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( + +
+ , + ); + 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( + +
+ , + ); + 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( + +
+ , + ); + fireEvent.click(screen.getByRole('button')); + settle(); + expect(within(screen.getByRole('button')).getByTitle('com_ui_thinking')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Chat/Messages/Content/live.ts b/client/src/components/Chat/Messages/Content/live.ts index 1f55ba14a7c..78298412db5 100644 --- a/client/src/components/Chat/Messages/Content/live.ts +++ b/client/src/components/Chat/Messages/Content/live.ts @@ -269,6 +269,7 @@ function newestLine( localize: Localize, serverNames: readonly string[], span: SpanSummary, + preferLabels: boolean, ): Pick { for (let position = parts.length - 1; position >= 0; position -= 1) { const part = parts[position]; @@ -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 }; } @@ -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 }; } @@ -346,10 +350,15 @@ export function getLiveActivity( localize: Localize, serverNames: readonly string[], attachmentsById?: Record, + /** 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), };