From f1392f35c553ab3006f28fa3551de7809c3479ee Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 26 Sep 2026 08:44:00 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20fix:=20Title=20an=20Ope?= =?UTF-8?q?n=20Live=20Card=20by=20Its=20Newest=20Label,=20Not=20a=20Line?= =?UTF-8?q?=20of=20Its=20Text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An open card whose label has not landed yet kept the live line, so its sticky header quoted the sentence the reader could already see streaming in the rows beneath it. Open, the header now names the span by its newest label: a thought's generated label or the generic thinking line, a batch label, a call's own line. Reasoning sentences and prose commentary are for the collapsed header, which stands in for rows the reader cannot see. --- .../Messages/Content/ActivityPhaseGroup.tsx | 9 ++- .../__tests__/ActivityPhaseGroup.test.tsx | 59 +++++++++++++++++++ .../components/Chat/Messages/Content/live.ts | 17 ++++-- 3 files changed, 79 insertions(+), 6 deletions(-) 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), };