From c056cfc140e30a99d6046543238c47c64aabec5e Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:55:06 +0000 Subject: [PATCH] feat(native-chat): add a "New" line above unseen messages (#28333) --- docs/STYLEGUIDE.md | 2 +- ...ession-history-forward-read-budget.test.ts | 5 + .../agent-session-subscriber-catch-up.ts | 1 + src/renderer/src/assets/main.css | 3 + .../native-chat/NativeChatDockedTaskList.tsx | 23 ++ ...veChatMessageList.new-rows-marker.test.tsx | 151 +++++++++ .../native-chat/NativeChatMessageList.tsx | 28 +- .../NativeChatNewRowsMarker.test.tsx | 109 ++++++ .../native-chat/NativeChatNewRowsMarker.tsx | 87 +++++ ...tiveChatStructuredSession.test-harness.tsx | 3 + .../NativeChatStructuredSession.test.tsx | 21 ++ .../native-chat/NativeChatTranscriptItems.tsx | 13 +- .../native-chat/NativeChatTranscriptRow.tsx | 12 +- .../native-chat-appearance-palette.test.ts | 4 + .../native-chat-live-session-contract.ts | 3 + .../native-chat-new-rows-marker.test.tsx | 313 ++++++++++++++++++ .../native-chat-new-rows-marker.ts | 188 +++++++++++ .../native-chat-pinned-rows.test.ts | 6 + .../native-chat/native-chat-pinned-rows.ts | 12 +- .../structured-agent-session-read-owner.ts | 23 +- ...tured-agent-session-read-transport.test.ts | 4 + ...structured-agent-session-read-transport.ts | 21 +- .../use-native-chat-reveal-latest.ts | 23 +- .../use-native-chat-transcript-window.ts | 7 +- ...use-structured-agent-session-read.test.tsx | 69 +++- .../use-structured-agent-session-read.ts | 1 + .../use-structured-agent-session.ts | 3 + .../use-structured-chat-live-session.ts | 4 +- .../use-orca-window-foreground-focused.ts | 25 ++ src/renderer/src/i18n/locales/en.json | 2 + src/renderer/src/i18n/locales/es.json | 2 + src/renderer/src/i18n/locales/fr.json | 2 + src/renderer/src/i18n/locales/ja.json | 2 + src/renderer/src/i18n/locales/ko.json | 2 + src/renderer/src/i18n/locales/zh.json | 2 + src/shared/agent-session-wire.ts | 7 +- ...structured-agent-session-coalescer.test.ts | 21 ++ .../structured-agent-session-coalescer.ts | 4 +- 38 files changed, 1159 insertions(+), 49 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatDockedTaskList.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatMessageList.new-rows-marker.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatNewRowsMarker.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatNewRowsMarker.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-new-rows-marker.test.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-new-rows-marker.ts create mode 100644 src/renderer/src/hooks/use-orca-window-foreground-focused.ts diff --git a/docs/STYLEGUIDE.md b/docs/STYLEGUIDE.md index 733ef99402e..e236389c7d8 100644 --- a/docs/STYLEGUIDE.md +++ b/docs/STYLEGUIDE.md @@ -113,7 +113,7 @@ The chat root (`.native-chat-appearance`) redefines these steps as ratios of the - **Secondary text on the chat canvas** (transcript rows, notices, labels between messages) uses `text-chat-foreground-faint`. `text-muted-foreground` is for text on a lifted surface inside the chat: cards, menus, popovers and the docks above the composer. - **Row hover** in the transcript is `hover:bg-accent/20`. Buttons, menu items and card controls use `hover:bg-accent`. - **Focus rings:** full-width transcript rows use the inset ring (`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring`) so it isn't clipped by the scroll container; standalone controls use `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring`. Inside the chat `--ring` is `--chat-focus-ring`, which clears 3:1 on the canvas; don't lower its opacity. -- **State colours in the chat:** warnings use `text-chat-warning`; diff lines use `text-chat-diff-added` / `text-chat-diff-removed`. They are mixed toward the foreground so they pass text contrast in light and dark. Don't use raw palette colours (`text-yellow-500`) or the git decoration tokens for text on a tinted row. +- **State colours in the chat:** warnings use `text-chat-warning`; diff lines use `text-chat-diff-added` / `text-chat-diff-removed`; what the reader has not seen yet uses `text-chat-unread`. They are mixed toward the foreground so they pass text contrast in light and dark. Don't use raw palette colours (`text-yellow-500`) or the git decoration tokens for text on a tinted row. ## Radius diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts b/src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts index f94624c26c6..7cce60a3bdd 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts @@ -119,6 +119,11 @@ describe('forward history SQL read budget', () => { Array.from({ length: count }, (_, index) => `item-${index}`) ) expect(batches.at(-1)?.batch.cursor).toEqual(journal.cursor()) + // Only the last page says the reader is caught up. + expect(batches.map((event) => event.hasNewer === true)).toEqual([ + ...Array(9).fill(true), + false + ]) expect(returnedRows).toEqual([...Array(9).fill(201), 200]) expect(parse).toHaveBeenCalledTimes(2_009) }) diff --git a/src/main/native-chat/agent-session-wire/agent-session-subscriber-catch-up.ts b/src/main/native-chat/agent-session-wire/agent-session-subscriber-catch-up.ts index 3546a7a6b8f..f54be54da13 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-subscriber-catch-up.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-subscriber-catch-up.ts @@ -115,6 +115,7 @@ export function deliverToSubscriber( }, fence: subscriber.fence, ...(page.latestTurn !== undefined ? { latestTurn: page.latestTurn } : {}), + ...(page.hasNewer ? { hasNewer: true as const } : {}), ...shared }, // On a multi-page catch-up the draft list rides only the final page, or a diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 840733db0e4..9a539b555fe 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -58,6 +58,7 @@ --color-chat-foreground: var(--chat-foreground); --color-chat-foreground-strong: var(--chat-foreground-strong); --color-chat-foreground-faint: var(--chat-foreground-faint); + --color-chat-unread: var(--chat-unread); --color-chat-user-surface: var(--chat-user-surface); --color-chat-user-border: var(--chat-user-border); --color-chat-code-foreground: var(--chat-code-foreground); @@ -637,6 +638,8 @@ body:has(> [data-slot='dialog-overlay'], > [data-slot='sheet-overlay']) { /* Mixed toward the foreground so each clears text contrast on the canvas in either scheme. */ --chat-focus-ring: color-mix(in srgb, var(--chat-source-foreground) 50%, var(--chat-canvas)); --chat-warning: color-mix(in srgb, var(--status-warning) 70%, var(--chat-source-foreground)); + /* What the reader has not seen yet: the transcript's "New" marker, in the note hue. */ + --chat-unread: color-mix(in srgb, var(--callout-note) 70%, var(--chat-source-foreground)); --chat-diff-added: color-mix( in srgb, var(--git-decoration-added) 70%, diff --git a/src/renderer/src/components/native-chat/NativeChatDockedTaskList.tsx b/src/renderer/src/components/native-chat/NativeChatDockedTaskList.tsx new file mode 100644 index 00000000000..3ed6376e95a --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatDockedTaskList.tsx @@ -0,0 +1,23 @@ +import { useMemo } from 'react' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { NativeChatTaskList } from './NativeChatTaskList' +import { nativeChatTaskListState } from './native-chat-task-list-state' + +/** The conversation's current task list, docked under the transcript. */ +export function NativeChatDockedTaskList({ + messages +}: { + messages: readonly NativeChatMessage[] +}): React.JSX.Element | null { + const { list } = useMemo(() => nativeChatTaskListState(messages), [messages]) + if (!list || list.tasks.length === 0) { + return null + } + return ( +
+
+ +
+
+ ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.new-rows-marker.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.new-rows-marker.test.tsx new file mode 100644 index 00000000000..536d4a35e8f --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.new-rows-marker.test.tsx @@ -0,0 +1,151 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { act, cleanup, render, screen } from '@testing-library/react' +import { createRef } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { NativeChatMessageList } from './NativeChatMessageList' +import type { NativeChatMessageListHandle } from './use-native-chat-reveal-latest' +import { + deliverResizes, + layout, + marker, + ROW_PITCH_PX, + scrollTranscript, + session, + stubLayout, + stubResizeObserver, + TRANSCRIPT_LENGTH +} from './native-chat-windowing-test-harness' + +// Pacing is not this test's subject. +vi.mock('./use-native-chat-paced-text', async (importOriginal) => + (await import('./native-chat-unpaced-text-fixture')).unpacedTextModule(importOriginal) +) + +afterEach(cleanup) + +/** A transcript of rows the journal placed, as a structured chat's are. */ +function chat( + length: number, + isVisible: boolean, + { + ref, + readCurrent = true + }: { ref?: React.Ref; readCurrent?: boolean } = {} +): React.JSX.Element { + const messages: NativeChatMessage[] = Array.from({ length }, (_, index) => ({ + ...marker(index), + journalPosition: { sequence: index + 1, index: 0 } + })) + return ( + + ) +} + +describe('the "New" marker in the transcript', () => { + let paneShown = true + let restore: (() => void)[] = [] + beforeEach(() => { + paneShown = true + vi.spyOn(document, 'hasFocus').mockReturnValue(true) + restore = [ + stubLayout({ scrollGeometry: true, offsetChain: true, isVisible: () => paneShown }), + stubResizeObserver() + ] + }) + afterEach(() => { + for (const undo of restore.toReversed()) { + undo() + } + layout.measuredRowHeights = [] + vi.restoreAllMocks() + }) + + // The window mounts only rows near the view; the marker's row is far from the end it opens at. + it('is drawn above the first row that came while the chat was hidden, however far back', () => { + const { rerender } = render(chat(TRANSCRIPT_LENGTH, true)) + rerender(chat(TRANSCRIPT_LENGTH, false)) + rerender(chat(TRANSCRIPT_LENGTH + 60, true)) + + const row = screen.getByRole('separator', { name: 'New' }).closest('[data-index]') + expect(row?.dataset.index).toBe(String(TRANSCRIPT_LENGTH)) + }) + + it('tells a screen reader that new messages came, in a region that was already there', () => { + const { container, rerender } = render(chat(TRANSCRIPT_LENGTH, true)) + const region = container.querySelector('[data-native-chat-scroll] ~ [aria-live="polite"]') + expect(region).toBeEmptyDOMElement() + + rerender(chat(TRANSCRIPT_LENGTH, false)) + rerender(chat(TRANSCRIPT_LENGTH + 60, true)) + expect(region).toHaveTextContent('New messages arrived while you were away') + }) + + it('goes once the reader sends something', () => { + const ref = createRef() + const { rerender } = render(chat(TRANSCRIPT_LENGTH, true, { ref })) + rerender(chat(TRANSCRIPT_LENGTH, false, { ref })) + rerender(chat(TRANSCRIPT_LENGTH + 60, true, { ref })) + expect(screen.getByRole('separator', { name: 'New' })).toBeInTheDocument() + + act(() => ref.current?.revealLatest()) + + expect(screen.queryByRole('separator', { name: 'New' })).toBeNull() + }) + + describe('when the reader comes back to new rows', () => { + /** The pane is hidden and shown again; the read catches up after the reveal has put the + * reader's place back. */ + function returnToNewRows(rerender: (ui: React.JSX.Element) => void, count = 60): void { + paneShown = false + rerender(chat(TRANSCRIPT_LENGTH, false, { readCurrent: false })) + paneShown = true + rerender(chat(TRANSCRIPT_LENGTH, true, { readCurrent: false })) + rerender(chat(TRANSCRIPT_LENGTH + count, true)) + } + const scrollTop = (container: HTMLElement): number | undefined => + container.querySelector('[data-native-chat-scroll]')?.scrollTop + + it('brings a reader who was at the end to where the new rows begin', () => { + const { container, rerender } = render(chat(TRANSCRIPT_LENGTH, true)) + returnToNewRows(rerender) + + expect(scrollTop(container)).toBe(TRANSCRIPT_LENGTH * ROW_PITCH_PX) + }) + + // A reply is drawn short and revealed over the frames after it arrives. + it('keeps the reader where the new rows begin while a reply is still being drawn', async () => { + const { container, rerender } = render(chat(TRANSCRIPT_LENGTH, true)) + returnToNewRows(rerender, 1) + + const drawn: number[] = [] + drawn[TRANSCRIPT_LENGTH] = 2_000 + layout.measuredRowHeights = drawn + act(() => void deliverResizes()) + await act(async () => { + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + }) + + expect(scrollTop(container)).toBe(TRANSCRIPT_LENGTH * ROW_PITCH_PX) + }) + + it('leaves a reader who was parked further up where they were', () => { + const { container, rerender } = render(chat(TRANSCRIPT_LENGTH, true)) + const readingAt = 2_000 + scrollTranscript(container, readingAt) + returnToNewRows(rerender) + + expect(screen.getByRole('separator', { name: 'New' })).toBeInTheDocument() + expect(scrollTop(container)).toBe(readingAt) + }) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index b537a0195e3..af3a96b2c4d 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -3,6 +3,7 @@ import { NATIVE_CHAT_TRANSCRIPT_COLUMN_CLASS } from './native-chat-appearance-style' import { NativeChatJumpToLatest } from './NativeChatJumpToLatest' +import { NativeChatNewRowsAnnouncement } from './NativeChatNewRowsMarker' import { useNativeChatRowTypography } from './use-native-chat-row-typography' import { useCallback, useMemo, useRef, useState } from 'react' import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown' @@ -10,9 +11,8 @@ import { translate } from '@/i18n/i18n' import type { NativeChatLiveSession } from './use-native-chat-live-session' import { useNativeChatTranscriptProjection } from './use-native-chat-transcript-projection' import { structuredQuestionTranscript } from './structured-agent-question-projection' -import { nativeChatTaskListState } from './native-chat-task-list-state' import { nativeChatTaskListPredecessors } from './native-chat-task-list-history' -import { NativeChatTaskList } from './NativeChatTaskList' +import { NativeChatDockedTaskList } from './NativeChatDockedTaskList' import { useNativeChatTurnStatus } from './use-native-chat-turn-status' import { NativeChatAwaitingInputRow } from './NativeChatAwaitingInputRow' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client-types' @@ -38,6 +38,7 @@ import { useNativeChatSubagentSections } from './use-native-chat-subagent-sectio import { toggleNativeChatExpandedKey } from './native-chat-expanded-keys' import { useNativeChatTurnMembership } from './use-native-chat-turn-membership' import { useNativeChatTranscriptScroll } from './use-native-chat-transcript-scroll' +import { useNativeChatNewRowsMarker } from './native-chat-new-rows-marker' import { useNativeChatOlderHistoryAutoload } from './use-native-chat-older-history-autoload' import { NativeChatOlderHistoryRow } from './NativeChatOlderHistoryRow' import { useNativeChatMessageRail } from './use-native-chat-message-rail' @@ -163,7 +164,6 @@ export function NativeChatMessageList({ [messages, subagentSections] ) const taskListPredecessors = useMemo(() => nativeChatTaskListPredecessors(messages), [messages]) - const taskListState = useMemo(() => nativeChatTaskListState(messages), [messages]) // Each row's turn, which turn is live, and the order the rows draw in, resolved once. const turnRows = useNativeChatTurnMembership( messages, @@ -227,6 +227,11 @@ export function NativeChatMessageList({ ), [allSlots, journalItems, stopping, journalSubmissions] ) + const { marker: newRowsMarker, dismiss } = useNativeChatNewRowsMarker( + slots, + waitingSlots, + isVisible && session.readCurrent === true + ) const transcriptWindow = useNativeChatTranscriptWindow({ scrollRef, slots, @@ -234,6 +239,7 @@ export function NativeChatMessageList({ // One pin serves both: revealing a diff and jumping from the rail are // mutually exclusive things to be doing. revealIndex: nativeChatSlotIndexOf(slots, railJump?.messageId ?? revealedDiff?.messageId), + newRowsMarkerIndex: nativeChatSlotIndexOf(slots, newRowsMarker?.key), typography }) const { showJump, onScroll, scrollToBottom, scrollMessageToTop, readerLeavesEnd } = @@ -321,7 +327,7 @@ export function NativeChatMessageList({ onTakeScroll: transcriptWindow.cancelAlign, onLeaveEnd: readerLeavesEnd }) - useNativeChatMessageListHandle(ref, jumpToLatest, readerScrollInput.revealFindMatch) + useNativeChatMessageListHandle(ref, jumpToLatest, readerScrollInput.revealFindMatch, dismiss) useNativeChatRailJumpLanding({ railJump, slots, @@ -398,6 +404,7 @@ export function NativeChatMessageList({ loadedMessageIds={loadedMessageIds} context={rowContext} window={transcriptWindow} + newRowsMarker={newRowsMarker} /> {liveLine ? ( + - {taskListState.list && taskListState.list.tasks.length > 0 ? ( -
-
- -
-
- ) : null} + ) diff --git a/src/renderer/src/components/native-chat/NativeChatNewRowsMarker.test.tsx b/src/renderer/src/components/native-chat/NativeChatNewRowsMarker.test.tsx new file mode 100644 index 00000000000..772c5a973c4 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatNewRowsMarker.test.tsx @@ -0,0 +1,109 @@ +// @vitest-environment happy-dom + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { NativeChatNewRowsMarker } from './NativeChatNewRowsMarker' +import type { NativeChatNewRowsMarkerState as Marker } from './native-chat-new-rows-marker' +import { overrideLayoutProperty } from './native-chat-windowing-test-harness' + +afterEach(cleanup) + +function marker(overrides: Partial = {}): Marker { + return { + key: 'answer', + place: 'above-row', + arriving: true, + onArrived: vi.fn(), + onDismiss: vi.fn(), + ...overrides + } +} + +const VIEWPORT_PX = 600 +const ROW_TOP_PX = 1_000 + +let restoreLayout: (() => void)[] = [] + +/** A chat pane whose marker sits `intoRowPx` down its row. The view's bottom edge rests + * `aboveMarkerPx` short of the marker: 0 for a reader who was at the end of what there was. */ +function pane( + value: Marker, + { intoRowPx = 0, aboveMarkerPx = 0 }: { intoRowPx?: number; aboveMarkerPx?: number } = {} +) { + const isRow = (element: HTMLElement): boolean => element.hasAttribute('data-index') + const isMarker = (element: HTMLElement): boolean => element.getAttribute('role') === 'separator' + const layout = (name: string, get: (this: HTMLElement) => unknown) => + overrideLayoutProperty(name, { get }) + restoreLayout = [ + layout('clientHeight', () => VIEWPORT_PX), + layout('scrollTop', () => ROW_TOP_PX + intoRowPx - VIEWPORT_PX - aboveMarkerPx), + layout('offsetTop', function () { + return isRow(this) ? ROW_TOP_PX : isMarker(this) ? intoRowPx : 0 + }), + layout('offsetParent', function () { + return isRow(this) || isMarker(this) ? this.parentElement : null + }) + ] + const onReveal = vi.fn() + const view = render( +
+
+ +
+
+
+ +
+
+