mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
Remove the New divider from Chat UI (#38044)
This commit is contained in:
@@ -25,4 +25,4 @@ 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:** 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.
|
||||
- **State colours:** 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.
|
||||
|
||||
@@ -61,7 +61,6 @@
|
||||
--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);
|
||||
@@ -697,8 +696,6 @@ body[data-modal-overlay-open]
|
||||
/* 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(--diff-added) 70%, var(--chat-source-foreground));
|
||||
--chat-diff-removed: color-mix(in srgb, var(--diff-removed) 70%, var(--chat-source-foreground));
|
||||
}
|
||||
|
||||
-149
@@ -1,149 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
import { act, cleanup, screen } from '@testing-library/react'
|
||||
import { render } from './native-chat-tooltip-test-harness'
|
||||
import { createRef } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat/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)
|
||||
|
||||
function chat(
|
||||
length: number,
|
||||
isVisible: boolean,
|
||||
{
|
||||
ref,
|
||||
readCurrent = true
|
||||
}: { ref?: React.Ref<NativeChatMessageListHandle>; readCurrent?: boolean } = {}
|
||||
): React.JSX.Element {
|
||||
const messages: NativeChatMessage[] = Array.from({ length }, (_, index) => ({
|
||||
...marker(index),
|
||||
journalPosition: { sequence: index + 1, index: 0 }
|
||||
}))
|
||||
return (
|
||||
<NativeChatMessageList
|
||||
ref={ref}
|
||||
session={{ ...session(messages), readCurrent }}
|
||||
isVisible={isVisible}
|
||||
isWorking={false}
|
||||
expandSignal={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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<HTMLElement>('[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<NativeChatMessageListHandle>()
|
||||
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', () => {
|
||||
/** Catch up after restoring the hidden pane’s scroll position. */
|
||||
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)
|
||||
})
|
||||
|
||||
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<void>((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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,6 @@ 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'
|
||||
@@ -40,7 +39,6 @@ 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'
|
||||
@@ -226,18 +224,12 @@ export function NativeChatMessageList({
|
||||
),
|
||||
[allSlots, journalItems, stopping, journalSubmissions, latestTurn, hostWork]
|
||||
)
|
||||
const { marker: newRowsMarker, dismiss } = useNativeChatNewRowsMarker(
|
||||
slots,
|
||||
waitingSlots,
|
||||
isVisible && session.readCurrent === true
|
||||
)
|
||||
const transcriptWindow = useNativeChatTranscriptWindow({
|
||||
scrollRef,
|
||||
slots,
|
||||
isVisible,
|
||||
// Rail jumps and diff reveals share one mutually exclusive pin.
|
||||
revealIndex: nativeChatSlotIndexOf(slots, railJump?.messageId ?? revealedDiff?.messageId),
|
||||
newRowsMarkerIndex: nativeChatSlotIndexOf(slots, newRowsMarker?.key),
|
||||
typography
|
||||
})
|
||||
const transcriptScroll = useNativeChatTranscriptScroll({
|
||||
@@ -320,7 +312,7 @@ export function NativeChatMessageList({
|
||||
onLeaveEnd: readerLeavesEnd,
|
||||
onScrollGesture: transcriptScroll.forwardedGesture
|
||||
})
|
||||
useNativeChatMessageListHandle(ref, jumpToLatest, readerInput.revealFindMatch, dismiss, allSlots)
|
||||
useNativeChatMessageListHandle(ref, jumpToLatest, readerInput.revealFindMatch, allSlots)
|
||||
useNativeChatRailJumpLanding({
|
||||
railJump,
|
||||
slots,
|
||||
@@ -381,7 +373,6 @@ export function NativeChatMessageList({
|
||||
loadedMessageIds={loadedMessageIds}
|
||||
context={rowContext}
|
||||
window={transcriptWindow}
|
||||
newRowsMarker={newRowsMarker}
|
||||
forwardGesture={readerInput.forwardedGesture}
|
||||
/>
|
||||
{liveLine ? (
|
||||
@@ -405,7 +396,6 @@ export function NativeChatMessageList({
|
||||
pendingId={railHistoryJump.pendingId}
|
||||
/>
|
||||
<NativeChatJumpToLatest visible={showJump} onJump={jumpToLatest} />
|
||||
<NativeChatNewRowsAnnouncement marked={newRowsMarker !== undefined} />
|
||||
</div>
|
||||
<NativeChatDockedTaskList key={session.sessionId} messages={messages} />
|
||||
</div>
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
// @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> = {}): 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)[] = []
|
||||
|
||||
/** Place the view bottom aboveMarkerPx short of a marker positioned intoRowPx within its row. */
|
||||
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(
|
||||
<div data-native-chat-root="true">
|
||||
<div data-native-chat-typing-redirect-ignore="true">
|
||||
<input aria-label="Find in chat" />
|
||||
</div>
|
||||
<div data-native-chat-scroll>
|
||||
<div data-index="3">
|
||||
<NativeChatNewRowsMarker marker={value} onReveal={onReveal} />
|
||||
</div>
|
||||
</div>
|
||||
<textarea aria-label="composer" />
|
||||
</div>
|
||||
)
|
||||
return { ...view, onReveal }
|
||||
}
|
||||
|
||||
describe('NativeChatNewRowsMarker', () => {
|
||||
afterEach(() => {
|
||||
for (const restore of restoreLayout) {
|
||||
restore()
|
||||
}
|
||||
})
|
||||
|
||||
it('brings its row to the top for a reader who was at the end', () => {
|
||||
const value = marker()
|
||||
const { container, onReveal } = pane(value)
|
||||
expect(onReveal).toHaveBeenCalledWith(container.querySelector('[data-index]'))
|
||||
expect(value.onArrived).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('brings itself to the top when it sits too far down its row to show with it', () => {
|
||||
const { onReveal } = pane(marker({ place: 'above-turn-bar' }), { intoRowPx: 500 })
|
||||
expect(onReveal).toHaveBeenCalledWith(screen.getByRole('separator'))
|
||||
})
|
||||
|
||||
it('leaves a reader who had parked the view above the new rows where they were', () => {
|
||||
const value = marker()
|
||||
const { onReveal } = pane(value, { aboveMarkerPx: 400 })
|
||||
expect(onReveal).not.toHaveBeenCalled()
|
||||
expect(value.onArrived).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not pull the reader back to it once it has arrived', () => {
|
||||
const { onReveal } = pane(marker({ arriving: false }))
|
||||
expect(onReveal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('goes once the reader writes to this chat, not to another pane or its find field', () => {
|
||||
const value = marker()
|
||||
pane(value)
|
||||
const typeIn = (target: Element) =>
|
||||
fireEvent(target, new Event('beforeinput', { bubbles: true }))
|
||||
typeIn(document.body)
|
||||
typeIn(screen.getByLabelText('Find in chat'))
|
||||
expect(value.onDismiss).not.toHaveBeenCalled()
|
||||
|
||||
typeIn(screen.getByLabelText('composer'))
|
||||
expect(value.onDismiss).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -1,82 +0,0 @@
|
||||
import { useEffect, useLayoutEffect, useRef } from 'react'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { NATIVE_CHAT_BOTTOM_THRESHOLD_PX } from './native-chat-autoscroll'
|
||||
import type { NativeChatNewRowsMarkerState } from './native-chat-new-rows-marker'
|
||||
import { nativeChatScrollOffsetWithin } from './use-native-chat-viewport-align'
|
||||
|
||||
/** Fields in the pane that take text for something other than the conversation. */
|
||||
const NOT_THE_CONVERSATION = '[data-native-chat-typing-redirect-ignore="true"]'
|
||||
|
||||
export function NativeChatNewRowsMarker({
|
||||
marker: { arriving, onArrived, onDismiss },
|
||||
onReveal
|
||||
}: {
|
||||
marker: NativeChatNewRowsMarkerState
|
||||
/** Scrolls a transcript element to the top of the view, and stops following the end. */
|
||||
onReveal: (element: HTMLElement) => void
|
||||
}): React.JSX.Element {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const label = translate('components.native-chat.newRowsMarker', 'New')
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!arriving) {
|
||||
return
|
||||
}
|
||||
onArrived()
|
||||
const marker = ref.current
|
||||
const row = marker?.closest<HTMLElement>('[data-index]')
|
||||
const scroller = row?.closest<HTMLElement>('[data-native-chat-scroll]')
|
||||
if (!marker || !row || !scroller) {
|
||||
return
|
||||
}
|
||||
const top = nativeChatScrollOffsetWithin(marker, scroller)
|
||||
// Preserve a reader parked above new rows; replies can still be short while paced rendering catches up.
|
||||
if (
|
||||
top === null ||
|
||||
top - (scroller.scrollTop + scroller.clientHeight) > NATIVE_CHAT_BOTTOM_THRESHOLD_PX
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Reveal the prompt’s row unless it would leave the marker too low in the viewport.
|
||||
const intoRow = top - (nativeChatScrollOffsetWithin(row, scroller) ?? top)
|
||||
onReveal(intoRow < scroller.clientHeight / 2 ? row : marker)
|
||||
}, [arriving, onArrived, onReveal])
|
||||
|
||||
useEffect(() => {
|
||||
const pane = ref.current?.closest('[data-native-chat-root]')
|
||||
// Only conversation input dismisses the marker; Find and other unrelated fields do not.
|
||||
const onInput = (event: Event): void => {
|
||||
if (!(event.target instanceof Element && event.target.closest(NOT_THE_CONVERSATION))) {
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
pane?.addEventListener('beforeinput', onInput)
|
||||
return () => pane?.removeEventListener('beforeinput', onInput)
|
||||
}, [onDismiss])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="separator"
|
||||
aria-label={label}
|
||||
className="flex items-center gap-3 text-2xs font-semibold uppercase tracking-[0.05em] text-chat-unread transition-opacity duration-300 ease-out starting:opacity-0 motion-reduce:transition-none"
|
||||
>
|
||||
<span>{label}</span>
|
||||
<span aria-hidden className="h-px flex-1 bg-chat-unread/50" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Keep the live region mounted before inserting text so screen readers announce it. */
|
||||
export function NativeChatNewRowsAnnouncement({ marked }: { marked: boolean }): React.JSX.Element {
|
||||
return (
|
||||
<div aria-live="polite" className="sr-only">
|
||||
{marked
|
||||
? translate(
|
||||
'components.native-chat.newRowsAnnouncement',
|
||||
'New messages arrived while you were away'
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import { nativeChatSlotKey, type NativeChatTranscriptSlot } from './native-chat-
|
||||
import type { NativeChatTranscriptWindow } from './use-native-chat-transcript-window'
|
||||
import type { ReaderGesture } from './native-chat-autoscroll'
|
||||
import { NativeChatForwardedGestureContext } from './native-chat-reader-scroll-input'
|
||||
import type { NativeChatNewRowsMarkerState } from './native-chat-new-rows-marker'
|
||||
import {
|
||||
NativeChatReplyRevealsContext,
|
||||
useNativeChatReplyReveals
|
||||
@@ -19,14 +18,12 @@ export function NativeChatTranscriptItems({
|
||||
loadedMessageIds,
|
||||
context,
|
||||
window,
|
||||
newRowsMarker,
|
||||
forwardGesture
|
||||
}: {
|
||||
slots: readonly NativeChatTranscriptSlot[]
|
||||
loadedMessageIds: ReadonlySet<string>
|
||||
context: NativeChatTranscriptRowContext
|
||||
window: NativeChatTranscriptWindow
|
||||
newRowsMarker?: NativeChatNewRowsMarkerState
|
||||
/** A reader gesture a row's visual frame took before the transcript could see it. */
|
||||
forwardGesture: (gesture: ReaderGesture) => void
|
||||
}): React.JSX.Element {
|
||||
@@ -46,13 +43,7 @@ export function NativeChatTranscriptItems({
|
||||
<Fragment key={item.key}>
|
||||
<div aria-hidden style={{ height: Math.max(0, item.start - previousEnd) }} />
|
||||
<div data-index={item.index} ref={window.measureRow} className="flow-root w-full">
|
||||
<NativeChatTranscriptRow
|
||||
slot={slot}
|
||||
context={context}
|
||||
newRowsMarker={
|
||||
rowKeys[item.index] === newRowsMarker?.key ? newRowsMarker : undefined
|
||||
}
|
||||
/>
|
||||
<NativeChatTranscriptRow slot={slot} context={context} />
|
||||
</div>
|
||||
</Fragment>
|
||||
)
|
||||
|
||||
@@ -15,8 +15,6 @@ import type { NativeChatDiffReveal, NativeChatDiffTarget } from './native-chat-t
|
||||
import { NativeChatRewindContext } from './native-chat-rewind-context'
|
||||
import { nativeChatRowOffersRewind } from './native-chat-rewind-eligibility'
|
||||
import { isActionableStructuredAgentSessionPrompt } from '../../../../shared/agent-session/view/structured-agent-session-live-turn'
|
||||
import { NativeChatNewRowsMarker } from './NativeChatNewRowsMarker'
|
||||
import type { NativeChatNewRowsMarkerState } from './native-chat-new-rows-marker'
|
||||
|
||||
/** Memoize shared context so unchanged rows keep their props during transcript updates. */
|
||||
export type NativeChatTranscriptRowContext = {
|
||||
@@ -42,13 +40,10 @@ export type NativeChatTranscriptRowContext = {
|
||||
/** Wrap message, status and rollup for one virtualized measurement; internal spacing is the row’s, inter-row gaps the window’s. */
|
||||
export const NativeChatTranscriptRow = memo(function NativeChatTranscriptRow({
|
||||
slot,
|
||||
context,
|
||||
newRowsMarker
|
||||
context
|
||||
}: {
|
||||
slot: NativeChatTranscriptSlot
|
||||
context: NativeChatTranscriptRowContext
|
||||
/** The "New" marker, when it draws in this row. */
|
||||
newRowsMarker?: NativeChatNewRowsMarkerState
|
||||
}): React.JSX.Element {
|
||||
// Offered only on the conversation's own turn-opening prompts (`nativeChatRowOffersRewind`).
|
||||
const rewind = useContext(NativeChatRewindContext)
|
||||
@@ -83,9 +78,6 @@ export const NativeChatTranscriptRow = memo(function NativeChatTranscriptRow({
|
||||
const deliveryNotice = context.deliveryNotices?.get(message.id)
|
||||
const predecessors = context.taskListPredecessors.get(message.id)
|
||||
const expanded = turnKey ? context.expandedTurnIds.has(turnKey) : undefined
|
||||
const marker = newRowsMarker ? (
|
||||
<NativeChatNewRowsMarker marker={newRowsMarker} onReveal={context.onScrollMessageToTop} />
|
||||
) : null
|
||||
const statusRow = status ? (
|
||||
<NativeChatWorkingStatus
|
||||
startedAt={status.startedAt}
|
||||
@@ -100,7 +92,6 @@ export const NativeChatTranscriptRow = memo(function NativeChatTranscriptRow({
|
||||
return (
|
||||
// The negative margin shortens the measured row, which is what moves the next one up.
|
||||
<div className={cn('flex flex-col gap-5', slot.continuesTurn && '-mb-3', sectionClassName)}>
|
||||
{newRowsMarker?.place === 'above-row' ? marker : null}
|
||||
{/* A turn with no user bubble carries its bar above its first row. */}
|
||||
{slot.statusAbove ? statusRow : null}
|
||||
{!slot.drawsMessage ? null : receipt ? (
|
||||
@@ -146,7 +137,6 @@ export const NativeChatTranscriptRow = memo(function NativeChatTranscriptRow({
|
||||
workRun={workRun}
|
||||
/>
|
||||
)}
|
||||
{newRowsMarker?.place === 'above-turn-bar' ? marker : null}
|
||||
{slot.statusAbove ? null : statusRow}
|
||||
{turnDiff ? (
|
||||
<NativeChatTurnDiffRollup diff={turnDiff} onReveal={context.onRevealDiff} />
|
||||
|
||||
@@ -50,70 +50,7 @@ function createChat(appScheme: 'light' | 'dark', terminalScheme: 'light' | 'dark
|
||||
return chat
|
||||
}
|
||||
|
||||
const statusRoles = [
|
||||
'--destructive',
|
||||
'--status-success',
|
||||
'--status-warning',
|
||||
'--git-decoration-added',
|
||||
'--git-decoration-deleted',
|
||||
'--diff-added-background',
|
||||
'--diff-added-gutter',
|
||||
'--diff-removed-background',
|
||||
'--diff-removed-gutter'
|
||||
]
|
||||
|
||||
describe('matching chat surfaces with opposite app schemes', () => {
|
||||
it.each([
|
||||
['dark', 'light'],
|
||||
['light', 'dark']
|
||||
] as const)('uses the %s app with a %s terminal palette', (appScheme, terminalScheme) => {
|
||||
const chat = createChat(appScheme, terminalScheme)
|
||||
// These roles cover transcript payloads, question badges, inline completion menus and controls.
|
||||
const matched = getComputedStyle(chat)
|
||||
const terminalForegroundAtFullStrength = `${terminalScheme === 'light' ? '#000000' : '#ffffff'} 100%`
|
||||
expect(chat.style.color).toBe('var(--foreground)')
|
||||
expect(matched.getPropertyValue('--chat-foreground')).toContain(
|
||||
terminalForegroundAtFullStrength
|
||||
)
|
||||
expect(matched.getPropertyValue('--chat-foreground-strong')).toContain(
|
||||
terminalForegroundAtFullStrength
|
||||
)
|
||||
expect(matched.getPropertyValue('--foreground')).toContain(terminalForegroundAtFullStrength)
|
||||
expect(matched.getPropertyValue('--muted')).toContain('7%')
|
||||
expect(matched.getPropertyValue('--muted-foreground')).toContain('62%')
|
||||
expect(matched.getPropertyValue('--popover')).toContain('4%')
|
||||
expect(matched.getPropertyValue('--popover-foreground')).toContain(
|
||||
terminalForegroundAtFullStrength
|
||||
)
|
||||
expect(matched.getPropertyValue('--accent')).toContain('9%')
|
||||
expect(matched.getPropertyValue('--input')).toContain('7%')
|
||||
expect(matched.getPropertyValue('--secondary')).toContain('7%')
|
||||
expect(matched.getPropertyValue('--ring')).toContain('44%')
|
||||
expect(matched.getPropertyValue('--chat-source-background')).toBe(
|
||||
terminalScheme === 'light' ? '#ffffff' : '#000000'
|
||||
)
|
||||
expect(matched.getPropertyValue('--chat-source-foreground')).toBe(
|
||||
terminalScheme === 'light' ? '#000000' : '#ffffff'
|
||||
)
|
||||
// The unread marker is mixed toward the chat's own foreground, not the app's.
|
||||
expect(matched.getPropertyValue('--chat-unread')).toContain(
|
||||
terminalScheme === 'light' ? '#0969da 70%, #000000' : '#4493f8 70%, #ffffff'
|
||||
)
|
||||
const codeForeground = matched.getPropertyValue('--chat-code-foreground')
|
||||
expect(codeForeground).toContain(`${terminalScheme === 'light' ? '#000000' : '#ffffff'} 85%`)
|
||||
expect(codeForeground).toContain(terminalScheme === 'light' ? '#ffffff' : '#000000')
|
||||
expect(codeForeground).not.toContain(appScheme === 'dark' ? '#fafafa' : '#0a0a0a')
|
||||
const outside = getComputedStyle(document.documentElement)
|
||||
const appStatus = statusRoles.map((role) => outside.getPropertyValue(role))
|
||||
const chatStatus = statusRoles.map((role) => matched.getPropertyValue(role))
|
||||
expect(chatStatus.every(Boolean)).toBe(true)
|
||||
expect(chatStatus).not.toEqual(appStatus)
|
||||
document.documentElement.classList.toggle('dark', terminalScheme === 'dark')
|
||||
expect(
|
||||
statusRoles.map((role) => getComputedStyle(document.documentElement).getPropertyValue(role))
|
||||
).toEqual(chatStatus)
|
||||
})
|
||||
|
||||
it('resolves an incomplete terminal palette without a local background variable cycle', () => {
|
||||
vi.spyOn(terminalThemeSelection, 'resolveConfiguredTerminalColors').mockReturnValue({})
|
||||
const chat = createChat('dark', 'light')
|
||||
|
||||
@@ -1,355 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat/native-chat-types'
|
||||
import { buildNativeChatTranscriptSlots } from './native-chat-transcript-slots'
|
||||
import {
|
||||
nativeChatNewRowsMarkerPlacement,
|
||||
useNativeChatNewRowsMarker
|
||||
} from './native-chat-new-rows-marker'
|
||||
|
||||
const at = (sequence: number) => ({ sequence, index: 0 })
|
||||
|
||||
/** A row the journal placed at `sequence`; null for one it has not (a terminal-backed chat's). */
|
||||
function row(
|
||||
id: string,
|
||||
sequence: number | null,
|
||||
role: NativeChatMessage['role'] = 'assistant',
|
||||
text = id
|
||||
): NativeChatMessage {
|
||||
return {
|
||||
id,
|
||||
role,
|
||||
blocks: [{ type: 'text', text }],
|
||||
timestamp: 1,
|
||||
source: 'transcript',
|
||||
...(sequence === null ? {} : { journalPosition: at(sequence) })
|
||||
}
|
||||
}
|
||||
|
||||
type Turn = { working?: boolean; worked?: number; expanded?: boolean }
|
||||
|
||||
function slotsOf(
|
||||
messages: NativeChatMessage[],
|
||||
{ working = false, worked, expanded = false }: Turn = {}
|
||||
) {
|
||||
const turnKey = working || worked !== undefined ? 'prompt' : undefined
|
||||
return buildNativeChatTranscriptSlots({
|
||||
messages,
|
||||
turnKeys: messages.map(() => turnKey),
|
||||
liveTurnKey: working ? turnKey : undefined,
|
||||
receipts: new Map(),
|
||||
turnStatuses: {
|
||||
active: null,
|
||||
completedByTurn:
|
||||
worked === undefined
|
||||
? {}
|
||||
: { prompt: { startedAt: null, thinking: false, workedSeconds: worked } }
|
||||
},
|
||||
turnDiffs: new Map(),
|
||||
expandedTurnKeys: new Set(expanded && turnKey ? [turnKey] : []),
|
||||
isWorking: working,
|
||||
lifecycleWorking: working
|
||||
})
|
||||
}
|
||||
|
||||
const SEEN = [row('prompt', 1, 'user'), row('reply', 2)]
|
||||
const LATER = [...SEEN, row('answer', 3), row('more', 4)]
|
||||
const ASKED = [row('prompt', 1, 'user')]
|
||||
const DONE = [...ASKED, row('step', 2), row('answer', 3)]
|
||||
|
||||
const missed = (key: string, after: number, through: number) => ({
|
||||
key,
|
||||
after: at(after),
|
||||
through: at(through)
|
||||
})
|
||||
|
||||
describe('where the "New" marker draws', () => {
|
||||
it('is above the first row that came while the reader was away', () => {
|
||||
expect(nativeChatNewRowsMarkerPlacement(slotsOf(LATER), missed('answer', 2, 4))).toEqual({
|
||||
key: 'answer',
|
||||
place: 'above-row'
|
||||
})
|
||||
})
|
||||
|
||||
it("is above the turn bar when only that bar parts the row from the reader's prompt", () => {
|
||||
const asked = [row('prompt', 1, 'user'), row('answer', 2)]
|
||||
expect(
|
||||
nativeChatNewRowsMarkerPlacement(slotsOf(asked, { worked: 17 }), missed('answer', 1, 2))
|
||||
).toEqual({ key: 'prompt', place: 'above-turn-bar' })
|
||||
})
|
||||
|
||||
it('stays under the rows of an opened turn the reader had already seen', () => {
|
||||
expect(
|
||||
nativeChatNewRowsMarkerPlacement(
|
||||
slotsOf(DONE, { worked: 17, expanded: true }),
|
||||
missed('answer', 2, 3)
|
||||
)
|
||||
).toEqual({ key: 'answer', place: 'above-row' })
|
||||
})
|
||||
|
||||
it('is above the bar of a turn the agent opened, whether the turn is open or shut', () => {
|
||||
const turn = DONE.slice(1)
|
||||
for (const expanded of [false, true]) {
|
||||
expect(
|
||||
nativeChatNewRowsMarkerPlacement(
|
||||
slotsOf(turn, { worked: 17, expanded }),
|
||||
missed('answer', 1, 3)
|
||||
)
|
||||
).toEqual({ key: 'step', place: 'above-row' })
|
||||
}
|
||||
})
|
||||
|
||||
it('is absent when nothing came, or the row that did is gone', () => {
|
||||
expect(nativeChatNewRowsMarkerPlacement(slotsOf(LATER), null)).toBeUndefined()
|
||||
expect(nativeChatNewRowsMarkerPlacement(slotsOf(SEEN), missed('answer', 2, 4))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useNativeChatNewRowsMarker', () => {
|
||||
let hasFocus: ReturnType<typeof vi.spyOn>
|
||||
beforeEach(() => {
|
||||
hasFocus = vi.spyOn(document, 'hasFocus').mockReturnValue(true)
|
||||
})
|
||||
afterEach(() => hasFocus.mockRestore())
|
||||
|
||||
type ChatProps = {
|
||||
messages: NativeChatMessage[]
|
||||
showing: boolean
|
||||
waiting?: NativeChatMessage[]
|
||||
turn?: Turn
|
||||
}
|
||||
|
||||
function chat(messages: NativeChatMessage[], turn?: Turn) {
|
||||
const initialProps: ChatProps = { messages, showing: true }
|
||||
return renderHook(
|
||||
(props: ChatProps) =>
|
||||
useNativeChatNewRowsMarker(
|
||||
slotsOf(props.messages, props.turn ?? turn),
|
||||
slotsOf(props.waiting ?? []),
|
||||
props.showing
|
||||
),
|
||||
{ initialProps }
|
||||
)
|
||||
}
|
||||
|
||||
function leaveAndReturn(left: NativeChatMessage[], onReturn: NativeChatMessage[], turn?: Turn) {
|
||||
const view = chat(left, turn)
|
||||
view.rerender({ messages: left, showing: false })
|
||||
view.rerender({ messages: onReturn, showing: true })
|
||||
return view
|
||||
}
|
||||
|
||||
it('marks nothing that arrives while the reader is looking', () => {
|
||||
const view = chat(SEEN)
|
||||
view.rerender({ messages: LATER, showing: true })
|
||||
expect(view.result.current.marker).toBeUndefined()
|
||||
})
|
||||
|
||||
it('marks what a hidden chat is handed when the reader comes back to it', () => {
|
||||
expect(leaveAndReturn(SEEN, LATER).result.current.marker?.key).toBe('answer')
|
||||
})
|
||||
|
||||
it("is never above the reader's own prompt", () => {
|
||||
const sent = [...SEEN, row('next-prompt', 3, 'user'), row('answer', 4)]
|
||||
expect(leaveAndReturn(SEEN, sent).result.current.marker?.key).toBe('answer')
|
||||
})
|
||||
|
||||
it('marks nothing the reader watches arrive after missing nothing', () => {
|
||||
const view = leaveAndReturn(SEEN, SEEN)
|
||||
view.rerender({ messages: LATER, showing: true })
|
||||
expect(view.result.current.marker).toBeUndefined()
|
||||
})
|
||||
|
||||
it('marks what came while the window was in the background', () => {
|
||||
const view = chat(SEEN)
|
||||
hasFocus.mockReturnValue(false)
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('blur'))
|
||||
})
|
||||
view.rerender({ messages: LATER, showing: true })
|
||||
hasFocus.mockReturnValue(true)
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
})
|
||||
expect(view.result.current.marker?.key).toBe('answer')
|
||||
})
|
||||
|
||||
it('goes once the reader answers', () => {
|
||||
const view = leaveAndReturn(SEEN, LATER)
|
||||
act(() => view.result.current.dismiss())
|
||||
expect(view.result.current.marker).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not appear when the reader answered before the chat had caught up', () => {
|
||||
const view = chat(SEEN)
|
||||
view.rerender({ messages: SEEN, showing: false })
|
||||
act(() => view.result.current.dismiss())
|
||||
view.rerender({ messages: LATER, showing: true })
|
||||
expect(view.result.current.marker).toBeUndefined()
|
||||
})
|
||||
|
||||
it('marks the first reply to a chat that held no row the journal had placed', () => {
|
||||
const sending = [row('prompt', null, 'user')]
|
||||
const view = leaveAndReturn(sending, [row('prompt', 1, 'user'), row('answer', 2)])
|
||||
expect(view.result.current.marker?.key).toBe('answer')
|
||||
})
|
||||
|
||||
it('is arriving on the return, until the row that draws it has brought it into view', () => {
|
||||
const view = leaveAndReturn(SEEN, LATER)
|
||||
expect(view.result.current.marker?.arriving).toBe(true)
|
||||
act(() => view.result.current.marker?.onArrived())
|
||||
expect(view.result.current.marker).toMatchObject({ key: 'answer', arriving: false })
|
||||
})
|
||||
|
||||
it('stays where it is through another look away, until the reader answers', () => {
|
||||
const view = leaveAndReturn(SEEN, LATER)
|
||||
act(() => view.result.current.marker?.onArrived())
|
||||
|
||||
view.rerender({ messages: LATER, showing: false })
|
||||
expect(view.result.current.marker?.key).toBe('answer')
|
||||
|
||||
view.rerender({ messages: [...LATER, row('newer', 5)], showing: true })
|
||||
expect(view.result.current.marker).toMatchObject({ key: 'answer', arriving: false })
|
||||
})
|
||||
|
||||
it('marks the next time away afresh once the reader has answered', () => {
|
||||
const view = leaveAndReturn(SEEN, LATER)
|
||||
act(() => view.result.current.dismiss())
|
||||
|
||||
view.rerender({ messages: LATER, showing: false })
|
||||
view.rerender({ messages: [...LATER, row('newer', 5)], showing: true })
|
||||
expect(view.result.current.marker).toMatchObject({ key: 'newer', arriving: true })
|
||||
})
|
||||
|
||||
it('marks the next time away afresh when the row it marked has gone', () => {
|
||||
const view = leaveAndReturn(SEEN, LATER)
|
||||
view.rerender({ messages: SEEN, showing: true })
|
||||
expect(view.result.current.marker).toBeUndefined()
|
||||
|
||||
view.rerender({ messages: SEEN, showing: false })
|
||||
view.rerender({ messages: [...SEEN, row('newer', 5)], showing: true })
|
||||
expect(view.result.current.marker?.key).toBe('newer')
|
||||
})
|
||||
|
||||
it('marks a message that was held behind the running turn once it joins the transcript', () => {
|
||||
const view = chat(SEEN)
|
||||
view.rerender({ messages: SEEN, showing: false })
|
||||
view.rerender({ messages: SEEN, showing: true, waiting: [row('held', 3)] })
|
||||
expect(view.result.current.marker).toBeUndefined()
|
||||
|
||||
// The turn writes on in front of the reader, then the handover places the message after that.
|
||||
view.rerender({ messages: [...SEEN, row('live', 4)], showing: true, waiting: [row('held', 3)] })
|
||||
expect(view.result.current.marker).toBeUndefined()
|
||||
view.rerender({ messages: [...SEEN, row('live', 4), row('held', 5)], showing: true })
|
||||
expect(view.result.current.marker?.key).toBe('held')
|
||||
})
|
||||
|
||||
it('stays above a queued message while the turn writes on ahead of it', () => {
|
||||
const queued: NativeChatMessage = { ...row('held', 3), queued: true }
|
||||
const view = leaveAndReturn(SEEN, [...SEEN, queued])
|
||||
expect(view.result.current.marker?.key).toBe('held')
|
||||
|
||||
view.rerender({ messages: [...SEEN, row('live', 4), queued], showing: true })
|
||||
expect(view.result.current.marker?.key).toBe('held')
|
||||
})
|
||||
|
||||
it('keeps its place when the reader opens the turn that came while they were away', () => {
|
||||
const view = leaveAndReturn(ASKED, DONE, { worked: 17 })
|
||||
expect(view.result.current.marker).toMatchObject({ key: 'prompt', place: 'above-turn-bar' })
|
||||
|
||||
view.rerender({ messages: DONE, showing: true, turn: { worked: 17, expanded: true } })
|
||||
expect(view.result.current.marker).toMatchObject({ key: 'prompt', place: 'above-turn-bar' })
|
||||
})
|
||||
|
||||
it('stays through another look away after the reader shuts the turn they opened', () => {
|
||||
const shut = { worked: 17 }
|
||||
const view = leaveAndReturn(ASKED, DONE, { worked: 17, expanded: true })
|
||||
view.rerender({ messages: DONE, showing: true, turn: shut })
|
||||
view.rerender({ messages: DONE, showing: false, turn: shut })
|
||||
view.rerender({ messages: DONE, showing: true, turn: shut })
|
||||
expect(view.result.current.marker).toMatchObject({ key: 'prompt', place: 'above-turn-bar' })
|
||||
})
|
||||
|
||||
describe('a reply still being written when the reader left', () => {
|
||||
const begun = [row('prompt', 1, 'user'), row('reply', 2, 'assistant', 'The plan')]
|
||||
const grown = [row('prompt', 1, 'user'), row('reply', 2, 'assistant', 'The plan has parts.')]
|
||||
const marked = (onReturn: NativeChatMessage[]) =>
|
||||
leaveAndReturn(begun, onReturn, { working: true }).result.current.marker?.key
|
||||
|
||||
it('is marked new when it grew while they were away', () => {
|
||||
expect(marked(grown)).toBe('reply')
|
||||
})
|
||||
|
||||
it('is not marked when it is as they left it', () => {
|
||||
expect(marked(begun)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('gives way to the next row when it is as they left it', () => {
|
||||
expect(marked([...begun, row('more', 3)])).toBe('more')
|
||||
})
|
||||
|
||||
it('is marked new when it grew with no row placed before it', () => {
|
||||
const alone = [row('reply', 2, 'assistant', 'The plan')]
|
||||
const grownAlone = [row('reply', 2, 'assistant', 'The plan has parts.')]
|
||||
const view = leaveAndReturn(alone, grownAlone, { working: true })
|
||||
expect(view.result.current.marker?.key).toBe('reply')
|
||||
})
|
||||
|
||||
describe('with a follow-up delivered into the turn after it', () => {
|
||||
const followUp = row('follow-up', 3, 'user')
|
||||
const marked = (onReturn: NativeChatMessage[]) =>
|
||||
leaveAndReturn([...begun, followUp], onReturn, { working: true }).result.current.marker?.key
|
||||
|
||||
it('is still marked new when it grew', () => {
|
||||
expect(marked([...grown, followUp])).toBe('reply')
|
||||
})
|
||||
|
||||
it('gives way to what answers the follow-up when it is as they left it', () => {
|
||||
expect(marked([...begun, followUp, row('answer', 4)])).toBe('answer')
|
||||
})
|
||||
|
||||
it('marks nothing when nothing changed', () => {
|
||||
expect(marked([...begun, followUp])).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a message held behind the turn, placed after it', () => {
|
||||
const markedWith = (held: NativeChatMessage, onReturn: NativeChatMessage[]) => {
|
||||
const view = chat(begun, { working: true })
|
||||
view.rerender({ messages: begun, showing: true, waiting: [held] })
|
||||
view.rerender({ messages: begun, showing: false, waiting: [held] })
|
||||
view.rerender({ messages: onReturn, showing: true, waiting: [held] })
|
||||
return view.result.current.marker?.key
|
||||
}
|
||||
|
||||
it('is still marked new when it grew', () => {
|
||||
expect(markedWith(row('held', 3, 'user'), grown)).toBe('reply')
|
||||
})
|
||||
|
||||
it('marks nothing when neither it nor the held message changed', () => {
|
||||
const fromAgent: NativeChatMessage = {
|
||||
...row('held', 3, 'user'),
|
||||
from: {
|
||||
kind: 'agent',
|
||||
senders: [
|
||||
{
|
||||
party: { address: 'term_peer', terminalHandle: 'term_peer', orcaSessionId: null },
|
||||
name: 'Claude'
|
||||
}
|
||||
],
|
||||
orchestration: null
|
||||
}
|
||||
}
|
||||
// Seen while it waited, it is not news once the turn ends and it joins the transcript.
|
||||
expect(markedWith(fromAgent, [...begun, fromAgent])).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('marks nothing in a chat read from a terminal, whose rows the journal never placed', () => {
|
||||
const view = leaveAndReturn([row('a', null)], [row('a', null), row('b', null)])
|
||||
expect(view.result.current.marker).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,192 +0,0 @@
|
||||
// Mark the first row that arrived or kept growing while the reader was away.
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useOrcaWindowForegroundFocused } from '@/hooks/use-orca-window-foreground-focused'
|
||||
import { compareAgentJournalPositions } from '../../../../shared/agent-session/journal/agent-session-journal-position'
|
||||
import type { AgentJournalPosition } from '../../../../shared/agent-session/journal/agent-session-journal-types'
|
||||
import { nativeChatSlotKey, type NativeChatTranscriptSlot } from './native-chat-transcript-slots'
|
||||
|
||||
/** Use journal positions for conversation rows, including folded-turn bars; terminal-backed rows have none and remain unmarked. */
|
||||
function rowPosition(slot: NativeChatTranscriptSlot): AgentJournalPosition | undefined {
|
||||
return slot.kind === 'message' && slot.depth === 0 && (slot.drawsMessage || slot.statusAbove)
|
||||
? slot.message.journalPosition
|
||||
: undefined
|
||||
}
|
||||
|
||||
/** Before every row the journal places: what the reader has read to when no row precedes. */
|
||||
const BEFORE_EVERY_ROW: AgentJournalPosition = { sequence: -1, index: 0 }
|
||||
|
||||
/** Where a reader who looks away now has read to, and the reply the agent is still writing. */
|
||||
type NativeChatLeftAt = {
|
||||
/** The newest row there was, as a position: that row may have folded away by the return. */
|
||||
position: AgentJournalPosition
|
||||
/** A growing reply counts as missed from the position immediately before it. */
|
||||
writing: { key: string; size: number; before: AgentJournalPosition } | null
|
||||
}
|
||||
|
||||
function rowSize(slot: NativeChatTranscriptSlot): number {
|
||||
return slot.kind === 'message'
|
||||
? JSON.stringify((slot.workRun ?? [slot.message]).map((message) => message.blocks)).length
|
||||
: 0
|
||||
}
|
||||
|
||||
function newestRowPosition(
|
||||
slots: readonly NativeChatTranscriptSlot[],
|
||||
limit?: AgentJournalPosition
|
||||
): AgentJournalPosition {
|
||||
let newest = BEFORE_EVERY_ROW
|
||||
for (const slot of slots) {
|
||||
const at = rowPosition(slot)
|
||||
if (
|
||||
at &&
|
||||
(!limit || compareAgentJournalPositions(at, limit) < 0) &&
|
||||
compareAgentJournalPositions(at, newest) > 0
|
||||
) {
|
||||
newest = at
|
||||
}
|
||||
}
|
||||
return newest
|
||||
}
|
||||
|
||||
function nativeChatLeftAt(
|
||||
slots: readonly NativeChatTranscriptSlot[],
|
||||
waitingSlots: readonly NativeChatTranscriptSlot[]
|
||||
): NativeChatLeftAt {
|
||||
const all = [...slots, ...waitingSlots]
|
||||
// Later user prompts or held sends do not stop the turn’s latest assistant row from growing.
|
||||
const writer = slots.findLast(
|
||||
(slot) =>
|
||||
slot.kind === 'message' &&
|
||||
slot.activeTurnIsWorking &&
|
||||
slot.message.role !== 'user' &&
|
||||
rowPosition(slot) !== undefined
|
||||
)
|
||||
const at = writer && rowPosition(writer)
|
||||
const writing = at
|
||||
? { key: nativeChatSlotKey(writer), size: rowSize(writer), before: newestRowPosition(all, at) }
|
||||
: null
|
||||
return { position: newestRowPosition(all), writing }
|
||||
}
|
||||
|
||||
/** The first row that came while the reader was away, and the journal stretch it came in. */
|
||||
export type NativeChatMissedRows = {
|
||||
/** The row, not a position: a queued message is placed again when it is handed over. */
|
||||
key: string
|
||||
after: AgentJournalPosition
|
||||
/** The newest row on the return: what is written after it was watched arriving. */
|
||||
through: AgentJournalPosition
|
||||
}
|
||||
|
||||
function isMissedRow(
|
||||
slot: NativeChatTranscriptSlot,
|
||||
after: AgentJournalPosition,
|
||||
through: AgentJournalPosition
|
||||
): boolean {
|
||||
const at = rowPosition(slot)
|
||||
return (
|
||||
at !== undefined &&
|
||||
compareAgentJournalPositions(at, after) > 0 &&
|
||||
compareAgentJournalPositions(at, through) <= 0 &&
|
||||
(slot.kind !== 'message' || slot.message.role !== 'user' || slot.message.from !== undefined)
|
||||
)
|
||||
}
|
||||
|
||||
function nativeChatMissedRows(
|
||||
slots: readonly NativeChatTranscriptSlot[],
|
||||
{ position, writing }: NativeChatLeftAt
|
||||
): NativeChatMissedRows | null {
|
||||
const row = writing && slots.find((slot) => nativeChatSlotKey(slot) === writing.key)
|
||||
const after = writing && !(row && rowSize(row) === writing.size) ? writing.before : position
|
||||
const through = newestRowPosition(slots)
|
||||
const first = slots.find((slot) => isMissedRow(slot, after, through))
|
||||
return first ? { key: nativeChatSlotKey(first), after, through } : null
|
||||
}
|
||||
|
||||
export type NativeChatNewRowsMarkerPlacement = {
|
||||
/** Key of the row the marker draws in. */
|
||||
key: string
|
||||
place: 'above-row' | 'above-turn-bar'
|
||||
}
|
||||
|
||||
export type NativeChatNewRowsMarkerState = NativeChatNewRowsMarkerPlacement & {
|
||||
/** The reader has just come back: bring the marker into view, once. */
|
||||
arriving: boolean
|
||||
onArrived: () => void
|
||||
onDismiss: () => void
|
||||
}
|
||||
|
||||
/** Place above the first missed row, or its turn bar when the two lines would otherwise touch. */
|
||||
export function nativeChatNewRowsMarkerPlacement(
|
||||
slots: readonly NativeChatTranscriptSlot[],
|
||||
missed: NativeChatMissedRows | null
|
||||
): NativeChatNewRowsMarkerPlacement | undefined {
|
||||
if (missed === null) {
|
||||
return undefined
|
||||
}
|
||||
const { key, after, through } = missed
|
||||
// Opening a folded turn draws missed rows above the one marked while it was shut.
|
||||
const index = slots.findIndex(
|
||||
(slot) => nativeChatSlotKey(slot) === key || isMissedRow(slot, after, through)
|
||||
)
|
||||
const first = slots[index]
|
||||
if (!first) {
|
||||
return undefined
|
||||
}
|
||||
const before = slots[index - 1]
|
||||
return before?.kind === 'message' && before.status && !before.statusAbove && !before.turnDiff
|
||||
? { key: nativeChatSlotKey(before), place: 'above-turn-bar' }
|
||||
: { key: nativeChatSlotKey(first), place: 'above-row' }
|
||||
}
|
||||
|
||||
export function useNativeChatNewRowsMarker(
|
||||
slots: readonly NativeChatTranscriptSlot[],
|
||||
/** Rows held behind the running turn: seen and missed like any other, drawn outside `slots`. */
|
||||
waitingSlots: readonly NativeChatTranscriptSlot[],
|
||||
/** The pane is the one showing, and shows what the host holds now. */
|
||||
isShowingLatest: boolean
|
||||
): {
|
||||
marker: NativeChatNewRowsMarkerState | undefined
|
||||
/** The reader answered the conversation, so nothing in it is news to them. */
|
||||
dismiss: () => void
|
||||
} {
|
||||
const watching = useOrcaWindowForegroundFocused(isShowingLatest)
|
||||
const [seen, setSeen] = useState<{
|
||||
watching: boolean
|
||||
/** While the reader is away: where they had read to. */
|
||||
left: NativeChatLeftAt | null
|
||||
/** Stands until the reader answers. */
|
||||
missed: NativeChatMissedRows | null
|
||||
arriving: boolean
|
||||
}>({ watching, left: null, missed: null, arriving: false })
|
||||
if (seen.watching !== watching) {
|
||||
const all = [...slots, ...waitingSlots]
|
||||
if (watching) {
|
||||
const missed = seen.left ? nativeChatMissedRows(all, seen.left) : seen.missed
|
||||
setSeen({ watching, left: null, missed, arriving: seen.left !== null && missed !== null })
|
||||
} else {
|
||||
// An unanswered marker whose row still stands is kept: the reader may be midway through it.
|
||||
const marked = nativeChatNewRowsMarkerPlacement(all, seen.missed) !== undefined
|
||||
setSeen({ ...seen, watching, left: marked ? null : nativeChatLeftAt(slots, waitingSlots) })
|
||||
}
|
||||
}
|
||||
const { missed } = seen
|
||||
const placement = useMemo(() => nativeChatNewRowsMarkerPlacement(slots, missed), [slots, missed])
|
||||
const dismiss = useCallback(() => {
|
||||
// Where they left as well: an answer sent before the chat has caught up settles that too.
|
||||
setSeen((held) =>
|
||||
held.missed === null && held.left === null ? held : { ...held, missed: null, left: null }
|
||||
)
|
||||
}, [])
|
||||
const onArrived = useCallback(() => {
|
||||
setSeen((held) => (held.arriving ? { ...held, arriving: false } : held))
|
||||
}, [])
|
||||
const { key, place } = placement ?? {}
|
||||
const marker = useMemo(
|
||||
() =>
|
||||
key && place
|
||||
? { key, place, arriving: seen.arriving, onArrived, onDismiss: dismiss }
|
||||
: undefined,
|
||||
[key, place, seen.arriving, onArrived, dismiss]
|
||||
)
|
||||
return { marker, dismiss }
|
||||
}
|
||||
@@ -9,25 +9,20 @@ export type NativeChatPinnedRowsInput = {
|
||||
count: number
|
||||
/** Mount reveal targets before their cards expand and report scroll positions. */
|
||||
revealIndex?: number | null
|
||||
/** Keep the New marker mounted so it can reveal itself and detect reader replies. */
|
||||
newRowsMarkerIndex?: number | null
|
||||
}
|
||||
|
||||
/** Pin the live tail for aria-live announcements and measured-height accuracy during streaming. */
|
||||
export function nativeChatPinnedRowIndexes({
|
||||
count,
|
||||
revealIndex,
|
||||
newRowsMarkerIndex
|
||||
revealIndex
|
||||
}: NativeChatPinnedRowsInput): ReadonlySet<number> {
|
||||
const pinned = new Set<number>()
|
||||
if (count <= 0) {
|
||||
return pinned
|
||||
}
|
||||
pinned.add(count - 1)
|
||||
for (const index of [revealIndex, newRowsMarkerIndex]) {
|
||||
if (index != null && index >= 0 && index < count) {
|
||||
pinned.add(index)
|
||||
}
|
||||
if (revealIndex != null && revealIndex >= 0 && revealIndex < count) {
|
||||
pinned.add(revealIndex)
|
||||
}
|
||||
return pinned
|
||||
}
|
||||
|
||||
@@ -78,24 +78,19 @@ export function useNativeChatRevealLatest(): {
|
||||
|
||||
function ignoreFindMatch(): void {}
|
||||
|
||||
/** Notify onSent for conversation answers; ordinary jumps to latest do not count as sends. */
|
||||
export function useNativeChatMessageListHandle(
|
||||
ref: React.Ref<NativeChatMessageListHandle> | undefined,
|
||||
jumpToLatest: () => void,
|
||||
revealFindMatch: NativeChatMessageListHandle['revealFindMatch'] = ignoreFindMatch,
|
||||
onSent?: () => void,
|
||||
slots: readonly NativeChatTranscriptSlot[] = []
|
||||
): void {
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
revealLatest: () => {
|
||||
onSent?.()
|
||||
jumpToLatest()
|
||||
},
|
||||
revealLatest: jumpToLatest,
|
||||
revealFindMatch,
|
||||
messageCopyText: (messageId) => nativeChatSlotCopyText(slots, messageId)
|
||||
}),
|
||||
[jumpToLatest, revealFindMatch, onSent, slots]
|
||||
[jumpToLatest, revealFindMatch, slots]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -51,7 +51,6 @@ export function useNativeChatTranscriptWindow({
|
||||
slots,
|
||||
isVisible,
|
||||
revealIndex,
|
||||
newRowsMarkerIndex,
|
||||
typography = DEFAULT_ROW_TYPOGRAPHY
|
||||
}: {
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>
|
||||
@@ -59,8 +58,6 @@ export function useNativeChatTranscriptWindow({
|
||||
isVisible: boolean
|
||||
/** Slot the transcript was asked to reveal, or -1. */
|
||||
revealIndex: number
|
||||
/** Slot drawing the "New" marker, or -1. */
|
||||
newRowsMarkerIndex?: number
|
||||
/** Chat text scale; gap scales with the text size. */
|
||||
typography?: NativeChatRowTypography
|
||||
}): NativeChatTranscriptWindow {
|
||||
@@ -71,8 +68,8 @@ export function useNativeChatTranscriptWindow({
|
||||
const previousMeasurementKeysRef = useRef<ReadonlySet<string> | null>(null)
|
||||
const retiredMeasurementCountRef = useRef(0)
|
||||
const pinned = useMemo(
|
||||
() => nativeChatPinnedRowIndexes({ count: slots.length, revealIndex, newRowsMarkerIndex }),
|
||||
[slots.length, revealIndex, newRowsMarkerIndex]
|
||||
() => nativeChatPinnedRowIndexes({ count: slots.length, revealIndex }),
|
||||
[slots.length, revealIndex]
|
||||
)
|
||||
// Keep measured offsets through content-only growth to preserve the end anchor; rebuild only on structural changes.
|
||||
const encodedItemKeys = JSON.stringify(slots.map(nativeChatSlotKey))
|
||||
|
||||
@@ -19642,8 +19642,6 @@
|
||||
"stateUnverifiableCount_other": "{{value0}} mit nicht verfügbarem Status"
|
||||
},
|
||||
"jumpToLatest": "Zur neuesten Nachricht springen",
|
||||
"newRowsAnnouncement": "Während der Abwesenheit sind neue Nachrichten eingegangen",
|
||||
"newRowsMarker": "Neu",
|
||||
"find": {
|
||||
"label": "Im Chat suchen",
|
||||
"noResults": "Keine Ergebnisse",
|
||||
|
||||
@@ -19965,8 +19965,6 @@
|
||||
"stateUnverifiableCount_other": "{{value0}} with status unavailable"
|
||||
},
|
||||
"jumpToLatest": "Jump to latest",
|
||||
"newRowsAnnouncement": "New messages arrived while you were away",
|
||||
"newRowsMarker": "New",
|
||||
"find": {
|
||||
"label": "Find in chat",
|
||||
"noResults": "No results",
|
||||
|
||||
@@ -19334,8 +19334,6 @@
|
||||
"toggleDetails": "Alternar detalles del turno"
|
||||
},
|
||||
"jumpToLatest": "Saltar a lo último",
|
||||
"newRowsAnnouncement": "Llegaron mensajes nuevos mientras no estabas",
|
||||
"newRowsMarker": "Nuevo",
|
||||
"find": {
|
||||
"label": "Buscar en el chat",
|
||||
"noResults": "Sin resultados",
|
||||
|
||||
@@ -19554,8 +19554,6 @@
|
||||
"toggleDetails": "Basculer les détails du tour"
|
||||
},
|
||||
"jumpToLatest": "Aller au dernier message",
|
||||
"newRowsAnnouncement": "De nouveaux messages sont arrivés en votre absence",
|
||||
"newRowsMarker": "Nouveau",
|
||||
"find": {
|
||||
"label": "Rechercher dans le chat",
|
||||
"noResults": "Aucun résultat",
|
||||
|
||||
@@ -18845,8 +18845,6 @@
|
||||
"toggleDetails": "ターンの詳細を切り替えます"
|
||||
},
|
||||
"jumpToLatest": "最新にジャンプ",
|
||||
"newRowsAnnouncement": "離れている間に新しいメッセージが届きました",
|
||||
"newRowsMarker": "新着",
|
||||
"find": {
|
||||
"label": "チャット内を検索",
|
||||
"noResults": "結果はありません",
|
||||
|
||||
@@ -18845,8 +18845,6 @@
|
||||
"toggleDetails": "턴 세부정보 전환"
|
||||
},
|
||||
"jumpToLatest": "최신으로 이동",
|
||||
"newRowsAnnouncement": "자리를 비운 동안 새 메시지가 도착했습니다",
|
||||
"newRowsMarker": "새 메시지",
|
||||
"find": {
|
||||
"label": "채팅에서 찾기",
|
||||
"noResults": "결과 없음",
|
||||
|
||||
@@ -20701,8 +20701,6 @@
|
||||
"pendingWork": "Aguarde a conclusão do trabalho e das mensagens pendentes antes de usar este comando.",
|
||||
"notDoneGoalChangeIncomplete": "Não foi possível concluir a alteração do objetivo. Verifique o chat."
|
||||
},
|
||||
"newRowsAnnouncement": "Novas mensagens chegaram enquanto você estava ausente",
|
||||
"newRowsMarker": "Novas",
|
||||
"find": {
|
||||
"label": "Localizar no chat",
|
||||
"noResults": "Nenhum resultado",
|
||||
|
||||
@@ -20299,8 +20299,6 @@
|
||||
"stateUnverifiableCount_many": "Статус недоступен: {{value0}}"
|
||||
},
|
||||
"jumpToLatest": "К последним сообщениям",
|
||||
"newRowsAnnouncement": "Пока вас не было, появились новые сообщения",
|
||||
"newRowsMarker": "Новое",
|
||||
"find": {
|
||||
"label": "Найти в чате",
|
||||
"noResults": "Ничего не найдено",
|
||||
|
||||
@@ -19632,8 +19632,6 @@
|
||||
"stateUnverifiableCount_other": "Durum bilgisi alınamıyor: {{value0}}"
|
||||
},
|
||||
"jumpToLatest": "En son öğeye git",
|
||||
"newRowsAnnouncement": "Siz yokken yeni mesajlar geldi",
|
||||
"newRowsMarker": "Yeni",
|
||||
"find": {
|
||||
"label": "Sohbette bul",
|
||||
"noResults": "Sonuç yok",
|
||||
|
||||
@@ -19349,8 +19349,6 @@
|
||||
"stateUnverifiableCount_other": "狀態不可用:{{value0}}"
|
||||
},
|
||||
"jumpToLatest": "跳到最新訊息",
|
||||
"newRowsAnnouncement": "你離開期間有新訊息",
|
||||
"newRowsMarker": "新訊息",
|
||||
"find": {
|
||||
"label": "在聊天中查詢",
|
||||
"noResults": "沒有結果",
|
||||
|
||||
@@ -18811,8 +18811,6 @@
|
||||
"toggleDetails": "切换回合详情"
|
||||
},
|
||||
"jumpToLatest": "跳到最新消息",
|
||||
"newRowsAnnouncement": "你离开期间有新消息",
|
||||
"newRowsMarker": "新消息",
|
||||
"find": {
|
||||
"label": "在聊天中查找",
|
||||
"noResults": "没有结果",
|
||||
|
||||
Reference in New Issue
Block a user