Remove the New divider from Chat UI (#38044)

This commit is contained in:
Jinjing
2026-11-03 21:49:09 -08:00
committed by GitHub
parent e98c6a439a
commit ee6a248ec9
25 changed files with 11 additions and 1025 deletions
+1 -1
View File
@@ -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.
-3
View File
@@ -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));
}
@@ -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))
-2
View File
@@ -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",
-2
View File
@@ -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",
-2
View File
@@ -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",
-2
View File
@@ -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",
-2
View File
@@ -18845,8 +18845,6 @@
"toggleDetails": "ターンの詳細を切り替えます"
},
"jumpToLatest": "最新にジャンプ",
"newRowsAnnouncement": "離れている間に新しいメッセージが届きました",
"newRowsMarker": "新着",
"find": {
"label": "チャット内を検索",
"noResults": "結果はありません",
-2
View File
@@ -18845,8 +18845,6 @@
"toggleDetails": "턴 세부정보 전환"
},
"jumpToLatest": "최신으로 이동",
"newRowsAnnouncement": "자리를 비운 동안 새 메시지가 도착했습니다",
"newRowsMarker": "새 메시지",
"find": {
"label": "채팅에서 찾기",
"noResults": "결과 없음",
-2
View File
@@ -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",
-2
View File
@@ -20299,8 +20299,6 @@
"stateUnverifiableCount_many": "Статус недоступен: {{value0}}"
},
"jumpToLatest": "К последним сообщениям",
"newRowsAnnouncement": "Пока вас не было, появились новые сообщения",
"newRowsMarker": "Новое",
"find": {
"label": "Найти в чате",
"noResults": "Ничего не найдено",
-2
View File
@@ -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": "沒有結果",
-2
View File
@@ -18811,8 +18811,6 @@
"toggleDetails": "切换回合详情"
},
"jumpToLatest": "跳到最新消息",
"newRowsAnnouncement": "你离开期间有新消息",
"newRowsMarker": "新消息",
"find": {
"label": "在聊天中查找",
"noResults": "没有结果",