diff --git a/apps/desktop/e2e/prompt-rail.spec.ts b/apps/desktop/e2e/prompt-rail.spec.ts index 5b686b11b8..5f5e698262 100644 --- a/apps/desktop/e2e/prompt-rail.spec.ts +++ b/apps/desktop/e2e/prompt-rail.spec.ts @@ -2,6 +2,8 @@ import { PROMPT_RAIL_PROMPT_COUNT } from '../src/main/e2e-fixture/seed-helpers'; import { expect, test } from './fixtures'; import type { Page } from '@playwright/test'; +const MAX_PROMPT_RAIL_TICKS = 64; + /** * The prompt anchor rail (#563) has failed three times in a row in the same * way: the code kept working and the pixels stopped. #2161 pinned it against @@ -72,6 +74,15 @@ async function scrollTranscriptTo(page: Page, position: 'top' | 'bottom'): Promi }, position); } +async function loadPromptRailBeyondVirtualWindow(page: Page): Promise { + const transcript = page.locator('.maka-chat-message-list'); + await scrollTranscriptTo(page, 'top'); + await transcript.hover(); + await page.mouse.wheel(0, -100); + await expect.poll(async () => Number(await transcript.getAttribute('data-turn-source-count'))) + .toBeGreaterThan(100); +} + test('every tick paints a bar with a real box', async ({ promptRailWindow: page }) => { // Measured over ALL ticks, not a sample: a helper that skips what it cannot // evaluate creates its blind spot exactly where a regression lives. @@ -82,7 +93,7 @@ test('every tick paints a bar with a real box', async ({ promptRailWindow: page }), ); - expect(bars).toHaveLength(PROMPT_RAIL_PROMPT_COUNT); + expect(bars).toHaveLength(Math.min(PROMPT_RAIL_PROMPT_COUNT, MAX_PROMPT_RAIL_TICKS)); // #2580 shipped bars at 0x0 — present in the DOM, painting nothing. expect(Math.min(...bars.map((bar) => bar.width))).toBeGreaterThan(0); expect(Math.min(...bars.map((bar) => bar.height))).toBeGreaterThan(0); @@ -208,6 +219,67 @@ test('the first click of a session lands on its prompt and holds', async ({ expect(settled?.tickIsCurrent).toBe(true); }); +test('long transcripts keep a bounded mounted turn window', async ({ + promptRailWindow: page, +}) => { + const count = async () => page.locator('[data-virtual-turn-id]').count(); + await page.locator('[data-chat-scroll-container="true"][data-turn-window="ready"]').waitFor(); + await loadPromptRailBeyondVirtualWindow(page); + expect(await page.evaluate(() => { + const transcript = document.querySelector('.maka-chat-message-list'); + const rows = transcript?.firstElementChild; + const turn = document.querySelector('[data-virtual-turn-id]'); + if (!rows || !turn) throw new Error('the virtual transcript is missing'); + return { + list: Number.parseFloat(getComputedStyle(rows).rowGap), + turn: Number.parseFloat(getComputedStyle(turn).rowGap), + }; + })).toEqual({ list: 16, turn: 16 }); + expect(await count()).toBeGreaterThan(0); + expect(await count()).toBeLessThanOrEqual(100); + await page.locator('.maka-prompt-rail-tick').first().click({ force: true }); + await expect(page.locator('[data-turn-id="turn-prompt-rail-1"]')).toHaveCount(1); + expect(await count()).toBeGreaterThan(0); + expect(await count()).toBeLessThanOrEqual(100); +}); + +test('evicting a turn-owned sibling interaction hands focus back to the transcript', async ({ + promptRailWindow: page, +}) => { + const scroller = page.locator('[data-chat-scroll-container="true"][data-turn-window="ready"]'); + await scroller.waitFor(); + await loadPromptRailBeyondVirtualWindow(page); + await scrollTranscriptTo(page, 'bottom'); + await expect(page.locator('[data-virtual-turn-id="turn-prompt-rail-120"]')).toHaveCount(1); + const retainedTurnId = await page.evaluate(() => { + const turns = document.querySelectorAll('[data-virtual-turn-id]'); + const turn = turns.item(turns.length - 1); + if (!turn?.dataset.virtualTurnId) throw new Error('the mounted turn is missing'); + const turnOwnedAction = document.createElement('button'); + turnOwnedAction.textContent = 'Turn-owned action'; + turn.append(turnOwnedAction); + turnOwnedAction.focus(); + const range = document.createRange(); + range.selectNodeContents(turnOwnedAction); + const selection = document.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + return turn.dataset.virtualTurnId; + }); + + await page.evaluate(() => { + const root = document.querySelector('[data-chat-scroll-container="true"]'); + if (!root) throw new Error('the chat scroll container is missing'); + root.scrollTop = 0; + root.dispatchEvent(new Event('scroll')); + }); + await expect(page.locator(`[data-virtual-turn-id="${retainedTurnId}"]`)).toHaveCount(0); + await expect.poll(() => page.evaluate(() => ({ + focus: document.activeElement?.classList.contains('maka-chat-message-list') ?? false, + selection: document.getSelection()?.isCollapsed ?? true, + }))).toEqual({ focus: true, selection: true }); +}); + test('a tick is what the pointer lands on, not the scrollbar', async ({ promptRailWindow: page, }) => { diff --git a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts index 9247a59ad0..8a7676ac38 100644 --- a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts +++ b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts @@ -11,7 +11,10 @@ import { type LiveTurnProjection, type InteractionQueues, } from '@maka/ui'; -import { createAppShellSessionEventHandlers } from '../../renderer/app-shell-session-events.js'; +import { + createAppShellSessionDisplayBatch, + createAppShellSessionEventHandlers, +} from '../../renderer/app-shell-session-events.js'; function renderWithLocale(child: ReactNode): string { return renderToStaticMarkup( @@ -66,23 +69,21 @@ describe('single live-turn handoff', () => { phase: 'streamed', steps: [{ stepId: 'assistant-1', - thinking: { text: '先检查', truncated: false, complete: true }, + thinking: { text: '先检查', truncated: false, complete: false }, text: { text: '最终答案', truncated: false, complete: true }, tools: [{ toolUseId: 'tool-1', toolName: 'Bash', stepId: 'assistant-1', - status: 'completed', + status: 'running', args: {}, result: { kind: 'text', text: 'ok' }, }], }], }); - // The render-layer fold keeps answer text as the grouping boundary, but - // adds no second Processing disclosure around the native reasoning and - // tool-call disclosures. Order is product-facing; vendor class names are not. - assert.equal((markup.match(/data-processing="block"/g) ?? []).length, 0); + // Thinking and tools own their disclosures; do not wrap them in another. + assert.equal((markup.match(/maka-processing-block/g) ?? []).length, 0); assert.ok(markup.indexOf('深度思考') >= 0); assert.ok(markup.indexOf('深度思考') < markup.indexOf('最终答案')); assert.ok(markup.indexOf('最终答案') < markup.indexOf('Bash')); @@ -198,6 +199,105 @@ describe('single live-turn handoff', () => { assert.ok(refreshes.some((call) => call.required === 'assistant-1')); }); + it('publishes visible deltas at most once per animation frame', () => { + const liveTurns = createStateSetter>({ + 'session-1': armLiveTurn('turn-1'), + }); + const liveTurnBySessionRef = { current: liveTurns.get() }; + const interactions = createStateSetter({}); + const frames: Array<() => void> = []; + let publications = 0; + const handlers = createAppShellSessionEventHandlers({ + uiLocale: 'zh', + activeIdRef: { current: 'session-1' }, + liveTurnBySessionRef, + refreshMessages: async () => true, + refreshSessions: async () => [], + setLiveTurnBySession: (updater) => { + publications += 1; + liveTurns.set(updater); + liveTurnBySessionRef.current = liveTurns.get(); + }, + setInteractionBySession: interactions.set, + showModelSetupToast: () => {}, + toastApi: { error: () => {} }, + scheduleFrame: (callback) => { frames.push(callback); }, + }); + + for (let index = 0; index < 100; index += 1) { + handlers.handleEvent('session-1', { + type: 'text_delta', + id: `event-${index}`, + turnId: 'turn-1', + messageId: 'assistant-1', + ts: index, + text: 'x', + }); + } + assert.equal(publications, 0); + assert.equal(frames.length, 1); + frames.shift()?.(); + assert.equal(publications, 1); + assert.equal(liveTurns.get()['session-1']?.steps[0]?.text?.text, 'x'.repeat(100)); + + handlers.handleEvent('session-1', { + type: 'text_delta', id: 'event-100', turnId: 'turn-1', messageId: 'assistant-1', ts: 100, text: 'y', + }); + handlers.handleEvent('session-1', { + type: 'text_complete', id: 'event-101', turnId: 'turn-1', messageId: 'assistant-1', ts: 101, text: 'done', + }); + assert.equal(publications, 2); + assert.equal(liveTurns.get()['session-1']?.steps[0]?.text?.text, 'done'); + frames.shift()?.(); + assert.equal(publications, 2); + }); + + it('shares pending display events across handler replacement', () => { + const liveTurns = createStateSetter>({ + 'session-1': armLiveTurn('turn-1'), + }); + const liveTurnBySessionRef = { current: liveTurns.get() }; + const interactions = createStateSetter({}); + const frames: Array<() => void> = []; + const displayBatch = createAppShellSessionDisplayBatch(); + let publications = 0; + const deps = { + uiLocale: 'zh' as const, + activeIdRef: { current: 'session-1' }, + liveTurnBySessionRef, + refreshMessages: async () => true, + refreshSessions: async () => [], + setLiveTurnBySession: (updater: (current: Record) => Record) => { + publications += 1; + liveTurns.set(updater); + liveTurnBySessionRef.current = liveTurns.get(); + }, + setInteractionBySession: interactions.set, + showModelSetupToast: () => {}, + toastApi: { error: () => {} }, + scheduleFrame: (callback: () => void) => { frames.push(callback); }, + displayBatch, + }; + const beforeRender = createAppShellSessionEventHandlers(deps); + beforeRender.handleEvent('session-1', { + type: 'text_delta', id: 'delta', turnId: 'turn-1', messageId: 'assistant-1', ts: 1, + text: 'partial', + }); + + const afterRender = createAppShellSessionEventHandlers(deps); + afterRender.handleEvent('session-1', { + type: 'text_complete', id: 'complete', turnId: 'turn-1', messageId: 'assistant-1', ts: 2, + text: 'done', + }); + assert.equal(liveTurns.get()['session-1']?.steps[0]?.text?.text, 'done'); + assert.equal(liveTurns.get()['session-1']?.steps[0]?.text?.complete, true); + + frames.shift()?.(); + assert.equal(publications, 1); + assert.equal(liveTurns.get()['session-1']?.steps[0]?.text?.text, 'done'); + assert.equal(liveTurns.get()['session-1']?.steps[0]?.text?.complete, true); + }); + it('queues a sandbox boundary request without ending the live turn', () => { const liveTurns = createStateSetter>({ 'session-1': armLiveTurn('turn-1'), diff --git a/apps/desktop/src/main/e2e-fixture/seed-helpers.ts b/apps/desktop/src/main/e2e-fixture/seed-helpers.ts index 5c7291f9fc..2a10dc836d 100644 --- a/apps/desktop/src/main/e2e-fixture/seed-helpers.ts +++ b/apps/desktop/src/main/e2e-fixture/seed-helpers.ts @@ -15,16 +15,8 @@ export const E2E_FIXTURE_NOW = Date.UTC(2026, 4, 22, 3, 0, 0); export const TURN_SESSION_ID = 'e2e-fixture-turn'; export const PROMPT_RAIL_SESSION_ID = 'e2e-fixture-prompt-rail'; -/** - * Prompts seeded for the prompt-rail fixture. Three constraints set the - * number: the rail renders nothing below three prompts, the transcript has to - * overflow the scrollport or its pinning has nothing to be pinned against, - * and — the binding one — it must exceed the progressive mount's initial - * window of ten, or the head of the transcript is already mounted when the - * fixture opens and the jump-into-unmounted-turns path never runs. At eight - * prompts the spec could not see that bug at all. - */ -export const PROMPT_RAIL_PROMPT_COUNT = 30; +/** Exceeds both the 64-tick rail and 100-turn mounted-window bounds. */ +export const PROMPT_RAIL_PROMPT_COUNT = 120; export const LONG_SIDEBAR_SESSION_PREFIX = 'e2e-fixture-sidebar-long-'; export const LONG_SIDEBAR_SESSION_COUNT = 60; export const LONG_SIDEBAR_PROJECT_ID = 'e2e-fixture-project'; diff --git a/apps/desktop/src/renderer/app-shell-session-events.ts b/apps/desktop/src/renderer/app-shell-session-events.ts index 0b58c5e129..066367e248 100644 --- a/apps/desktop/src/renderer/app-shell-session-events.ts +++ b/apps/desktop/src/renderer/app-shell-session-events.ts @@ -39,6 +39,15 @@ export interface AppShellSessionEventHandlers { settleAssistantStreaming(sessionId: string, messageId?: string): Promise; } +export interface AppShellSessionDisplayBatch { + readonly pendingEvents: Map; + framePending: boolean; +} + +export function createAppShellSessionDisplayBatch(): AppShellSessionDisplayBatch { + return { pendingEvents: new Map(), framePending: false }; +} + export function createAppShellSessionEventHandlers(options: { uiLocale: UiLocale; activeIdRef: RefBox; @@ -53,6 +62,8 @@ export function createAppShellSessionEventHandlers(options: { showModelSetupToast: (description: string, reason?: string) => void; toastApi: ToastApi; notifyRunEnded?: (payload: { kind: 'completed' | 'errored'; sessionId: string; body?: string }) => void; + scheduleFrame?: (callback: () => void) => void; + displayBatch?: AppShellSessionDisplayBatch; }): AppShellSessionEventHandlers { const { uiLocale, @@ -68,18 +79,71 @@ export function createAppShellSessionEventHandlers(options: { toastApi, notifyRunEnded, } = options; + const scheduleFrame = options.scheduleFrame ?? ( + typeof requestAnimationFrame === 'function' + ? (callback: () => void) => { + let pending = true; + const run = () => { + if (!pending) return; + pending = false; + callback(); + }; + requestAnimationFrame(run); + window.setTimeout(run, 100); + } + : undefined + ); + const displayBatch = options.displayBatch ?? createAppShellSessionDisplayBatch(); - function updateLiveTurn(sessionId: string, event: SessionEvent): void { - setLiveTurnBySession((current) => { - const nextProjection = applyLiveTurnEvent(current[sessionId], event, uiLocale); - if (nextProjection === current[sessionId]) return current; - const next = { ...current }; - if (nextProjection) next[sessionId] = nextProjection; + function applyProjectionEvents( + projection: LiveTurnProjection | undefined, + events: readonly SessionEvent[], + ): LiveTurnProjection | undefined { + let next = projection; + for (const event of events) next = applyLiveTurnEvent(next, event, uiLocale); + return next; + } + + function replaceLiveTurns( + current: Record, + batches: ReadonlyMap, + ): Record { + let next = current; + for (const [sessionId, events] of batches) { + const projection = applyProjectionEvents(current[sessionId], events); + if (projection === current[sessionId]) continue; + if (next === current) next = { ...current }; + if (projection) next[sessionId] = projection; else delete next[sessionId]; - return next; + } + return next; + } + + function takePendingDisplayEvents(sessionId: string): SessionEvent[] { + const events = displayBatch.pendingEvents.get(sessionId) ?? []; + displayBatch.pendingEvents.delete(sessionId); + return events; + } + + function scheduleDisplayEvent(sessionId: string, event: SessionEvent): void { + const events = displayBatch.pendingEvents.get(sessionId); + if (events) events.push(event); + else displayBatch.pendingEvents.set(sessionId, [event]); + if (displayBatch.framePending || !scheduleFrame) return; + displayBatch.framePending = true; + scheduleFrame(() => { + displayBatch.framePending = false; + if (displayBatch.pendingEvents.size === 0) return; + const batches = new Map(displayBatch.pendingEvents); + displayBatch.pendingEvents.clear(); + setLiveTurnBySession((current) => replaceLiveTurns(current, batches)); }); } + function updateLiveTurn(sessionId: string, events: readonly SessionEvent[]): void { + setLiveTurnBySession((current) => replaceLiveTurns(current, new Map([[sessionId, events]]))); + } + function settleLiveStep(sessionId: string, stepId: string): void { setLiveTurnBySession((current) => { const projection = current[sessionId]; @@ -104,11 +168,12 @@ export function createAppShellSessionEventHandlers(options: { } function reconcilePersistedMessages(sessionId: string, messages: readonly StoredMessage[]): void { + const pending = takePendingDisplayEvents(sessionId); setLiveTurnBySession((current) => { - const projection = current[sessionId]; + const projection = applyProjectionEvents(current[sessionId], pending); if (!projection) return current; const reconciled = reconcileTerminalLiveTurn(projection, messages); - if (reconciled === projection) return current; + if (reconciled === current[sessionId]) return current; const next = { ...current }; if (reconciled) next[sessionId] = reconciled; else delete next[sessionId]; @@ -122,8 +187,17 @@ export function createAppShellSessionEventHandlers(options: { } function handleEvent(sessionId: string, event: SessionEvent): void { - const before = liveTurnBySessionRef.current[sessionId]; - updateLiveTurn(sessionId, event); + if ( + scheduleFrame + && activeIdRef.current === sessionId + && (event.type === 'text_delta' || event.type === 'thinking_delta') + ) { + scheduleDisplayEvent(sessionId, event); + return; + } + const pending = takePendingDisplayEvents(sessionId); + const before = applyProjectionEvents(liveTurnBySessionRef.current[sessionId], pending); + updateLiveTurn(sessionId, [...pending, event]); switch (event.type) { case 'text_complete': diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 10d4637f8d..989f0090c7 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -141,7 +141,10 @@ import { createAppShellDailyReviewBridge } from './app-shell-daily-review-bridge import { useAppShellModuleData } from './use-module-data'; import { useKeepSystemAwake } from './use-keep-system-awake'; import { useAppShellProjectContext } from './use-project-context'; -import { createAppShellSessionEventHandlers } from './app-shell-session-events'; +import { + createAppShellSessionDisplayBatch, + createAppShellSessionEventHandlers, +} from './app-shell-session-events'; import { createAppShellE2eFixtureActions } from './app-shell-e2e-fixture'; import { createAppShellChatActions, @@ -1245,6 +1248,9 @@ function AppShellContent({ const planConversationItems = (planMode.state?.proposals ?? []).map((proposal) => ({ id: proposal.proposalId, afterTurnId: proposal.turnId, + renderWhenAnchorMissing: + proposal.status === 'pending_approval' + && proposal.proposalId === planMode.state?.latestProposalId, content: , })); const activeMessageLoading = Boolean(activeId && messageLoadPending); @@ -2172,6 +2178,7 @@ function AppShellContent({ toastApi, }); + const [sessionDisplayBatch] = useState(createAppShellSessionDisplayBatch); const { handleEvent, reconcilePersistedMessages, settleAssistantStreaming } = useStableActions(createAppShellSessionEventHandlers, { uiLocale, activeIdRef, @@ -2180,6 +2187,7 @@ function AppShellContent({ refreshSessions, setLiveTurnBySession, setInteractionBySession, + displayBatch: sessionDisplayBatch, onInteractionChanged: markInteractionChanged, onExecutionBoundaryChanged: reloadActiveExecutionBoundary, showModelSetupToast, diff --git a/apps/desktop/src/renderer/maka-tokens.css b/apps/desktop/src/renderer/maka-tokens.css index 519fddf15f..2346bf15ef 100644 --- a/apps/desktop/src/renderer/maka-tokens.css +++ b/apps/desktop/src/renderer/maka-tokens.css @@ -1482,8 +1482,7 @@ .maka-turn { display: flex; flex-direction: column; - /* The content wrapper owns between-turn flex layout; turns must retain - their measured height for content-visibility warm-up and scrolling. */ + /* The content wrapper owns between-turn flex layout. */ flex: 0 0 auto; /* PR5 CHAT-TURN-SPACING-0: uniform 12px within-turn gap, matching the between-turn gap (.maka-chatContent) — one density, no between/within @@ -1493,29 +1492,6 @@ margin: 0 auto; width: 100%; box-sizing: border-box; - /* Long-conversation first render: skip layout/paint for off-screen - turns (vercel react-best-practices rendering-content-visibility — - ~10x initial-render win on long sessions, keeps native Cmd+F and - text selection, no virtualization library). `auto 250px` is only a - placeholder until a turn is first rendered; the remembered actual - size then keeps scroll geometry exact. Never-rendered history would - otherwise inflate turn by turn while scrolling up ("endless scroll"), - so chat-view runs an idle-time bottom-up warm-up after session load — - see packages/ui/src/turn-size-warmup.ts. */ - content-visibility: auto; - contain-intrinsic-size: auto 250px; - } - - /* #642 single render path: the streaming answer now rides the tail turn's - own `.maka-turn` node (not a separate `.maka-turn-streaming` section), so - the content-visibility override keys off the `data-live-streaming` hook - the tail turn carries. It must never be skipped: it grows every frame and - anchors the auto-scroll-to-bottom behavior. The old `.maka-turn-streaming` - box + `margin-top` position-parity rule is gone: the tail turn is already - spaced by its parent `.maka-turn` gap, so there is no out-of-band section - to re-align. */ - .maka-turn[data-live-streaming="true"] { - content-visibility: visible; } /* ChatReasoning is ejected from Astryx lab 0.1.9 because lab is canary-only. diff --git a/apps/desktop/src/renderer/styles/chat-message.css b/apps/desktop/src/renderer/styles/chat-message.css index 3880243750..d9c12e890e 100644 --- a/apps/desktop/src/renderer/styles/chat-message.css +++ b/apps/desktop/src/renderer/styles/chat-message.css @@ -23,6 +23,14 @@ width: 100%; } +.maka-turn-virtual-item { + display: flex; + width: 100%; + flex-direction: column; + gap: var(--spacing-4); + contain: layout style; +} + .maka-chat-message-loading { display: grid; place-items: center; diff --git a/apps/desktop/src/renderer/styles/prompt-rail.css b/apps/desktop/src/renderer/styles/prompt-rail.css index 6c4e0f7696..c199381257 100644 --- a/apps/desktop/src/renderer/styles/prompt-rail.css +++ b/apps/desktop/src/renderer/styles/prompt-rail.css @@ -126,7 +126,6 @@ height: auto; width: 100%; box-shadow: none; - transition: color var(--duration-base) var(--ease-out-strong); animation: maka-prompt-rail-tick-in var(--duration-large) var(--ease-out-strong) backwards; /* Capped, so a 90-prompt conversation cascades in a third of a second rather than nearly two: past the cap the tail arrives together. */ @@ -138,9 +137,9 @@ color: var(--foreground); } -/* Dock-style falloff. `--maka-prompt-rail-proximity` counts ticks away from the - pointer (0 = hovered) and rests at the falloff distance, so the whole ramp is - one expression and the resting width is its own tail. */ +/* Dock-style falloff. The bar keeps a fixed layout width and changes only its + compositor transform, so active-prompt tracking never starts a width-driven + layout animation while the transcript is scrolling. */ .maka-prompt-rail-tick-bar { /* Load-bearing since #2580 put the tick on Astryx's Button: the bar used to be a direct child of the flex tick, which blockified it, and now it sits @@ -148,59 +147,17 @@ width or height, so without this the bar computes to 0x0 and the whole rail paints nothing. */ display: block; - width: calc(14px + ((3 - var(--maka-prompt-rail-proximity, 3)) * 3px)); + width: 26px; height: 3px; border-radius: var(--radius-pill); background: currentColor; - transition: width var(--duration-emphasized) var(--ease-out-strong); + transform: scaleX(var(--maka-prompt-rail-scale, 0.5385)); + transform-origin: right center; + transition: transform var(--duration-emphasized) var(--ease-out-strong); } -/* The travelling highlight for the prompt currently being read. Anchor - positioning is Astryx's own idiom for a sliding indicator (see its Outline): - the active tick's bar publishes an anchor name, the highlight reads its - position, and the transition on `top` is what makes it glide between prompts - as the transcript scrolls. Sized past the widest tick so it always reads as - the longer bar. */ .maka-prompt-rail-tick[data-active="true"] .maka-prompt-rail-tick-bar { - anchor-name: --maka-prompt-rail-active; - /* The highlight is drawn over this bar, so hide the bar rather than - double-print it. Fading rather than hiding also gives the prompt being - left something to do while the highlight travels away from it. */ - opacity: 0; - transition: - width var(--duration-emphasized) var(--ease-out-strong), - opacity var(--duration-base) var(--ease-out-strong); -} - -.maka-prompt-rail-indicator { - position: absolute; - position-anchor: --maka-prompt-rail-active; - right: var(--space-1); - top: anchor(center); - translate: 0 -50%; - width: 26px; - height: 3px; - border-radius: var(--radius-pill); - background: var(--foreground); - pointer-events: none; - transition: top var(--duration-large) var(--ease-out-strong); -} - -/* The glide is for reading: it carries the eye between neighbouring prompts as - the transcript scrolls. A click is the opposite — the reader picked the - destination, and the same 280ms spent crossing twenty prompts instead of one - reads as the bar flying off across the rail. `jumpTo` in - prompt-anchor-rail.tsx holds this attribute until the scroll it started has - settled, so the highlight is placed at each turn the jump passes through. */ -.maka-prompt-rail[data-jumping="true"] .maka-prompt-rail-indicator { - transition: none; -} - -/* No active prompt yet (a transcript that has not been scrolled) leaves the - anchor undefined, and an unresolved anchor would park the bar at the top of - the rail. Hide it until a tick claims the anchor. */ -.maka-prompt-rail:not(:has(.maka-prompt-rail-tick[data-active="true"])) .maka-prompt-rail-indicator { - display: none; + transform: scaleX(1); } /* HoverCard content. Astryx owns the card itself — surface, radius, shadow, diff --git a/packages/ui/src/__tests__/chat-conversation-items.test.ts b/packages/ui/src/__tests__/chat-conversation-items.test.ts new file mode 100644 index 0000000000..9583dadd21 --- /dev/null +++ b/packages/ui/src/__tests__/chat-conversation-items.test.ts @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { placeChatConversationItems } from '../chat-conversation-items.js'; + +test('places resident items with their turns and bounds an explicit orphan', () => { + const placed = placeChatConversationItems([ + { afterTurnId: 'old', value: 'historical' }, + { afterTurnId: 'resident', value: 'anchored' }, + { afterTurnId: 'missing-a', renderWhenAnchorMissing: true, value: 'stale-pending' }, + { afterTurnId: 'missing-b', renderWhenAnchorMissing: true, value: 'latest-pending' }, + ], new Set(['resident'])); + + assert.deepEqual(placed.byTurn.get('resident'), ['anchored']); + assert.equal(placed.byTurn.has('old'), false); + assert.equal(placed.orphan, 'latest-pending'); +}); diff --git a/packages/ui/src/__tests__/chat-scroll-anchor.test.ts b/packages/ui/src/__tests__/chat-scroll-anchor.test.ts index 4cb0daf53e..27fe18248f 100644 --- a/packages/ui/src/__tests__/chat-scroll-anchor.test.ts +++ b/packages/ui/src/__tests__/chat-scroll-anchor.test.ts @@ -3,7 +3,7 @@ import test from 'node:test'; import { parseHTML } from 'linkedom'; import { captureChatScrollAnchor, restoreChatScrollAnchor } from '../chat-scroll-anchor.js'; -test('reuses the visible article while progressive history grows above it', () => { +test('reuses the visible article while virtual history changes above it', () => { const { document } = parseHTML('
'); const root = document.querySelector('#root')!; Object.defineProperty(root, 'scrollTop', { value: 0, writable: true }); diff --git a/packages/ui/src/__tests__/prompt-anchor-rail.test.ts b/packages/ui/src/__tests__/prompt-anchor-rail.test.ts index a90d189c40..a54afac576 100644 --- a/packages/ui/src/__tests__/prompt-anchor-rail.test.ts +++ b/packages/ui/src/__tests__/prompt-anchor-rail.test.ts @@ -8,7 +8,7 @@ import { /** * The e2e suite cannot stage what these cover. Whether a jump survives depends - * on which frame the progressive fill lands on, and the e2e case went green + * on which frame the virtual window lands on, and the e2e case went green * against a renderer that did not survive it. Driving the frames here makes it * deterministic. */ @@ -36,7 +36,6 @@ function railHoldHarness() { /** The target's top edge, relative to the scrollport's. 0 = landed. */ targetTop: 600, targetPresent: true, - filled: false, queried: '', settled: 0, }; @@ -86,7 +85,6 @@ test('a jump re-aims at its destination every time the transcript grows', () => const release = holdJumpDestination({ root, readTargetId: () => 'turn-7', - isTranscriptFilled: () => state.filled, onSettled: () => { state.settled += 1; }, @@ -110,7 +108,6 @@ test('a jump re-aims at its destination every time the transcript grows', () => // A scroll that was cancelled part-way leaves the target off-target on an // otherwise still frame. That is the other way a jump used to be lost. - state.filled = true; state.targetTop = 240; harness.runFrame(); assert.equal(scrolled.length, 3, 'a stalled jump is corrected, not accepted'); @@ -119,14 +116,13 @@ test('a jump re-aims at its destination every time the transcript grows', () => harness.restore(); }); -test('a jump holds until the transcript reports itself filled and still', () => { +test('a jump holds until the mounted destination is still', () => { const harness = railHoldHarness(); const { root, scheduler, state } = harness; holdJumpDestination({ root, readTargetId: () => 'turn-7', - isTranscriptFilled: () => state.filled, onSettled: () => { state.settled += 1; }, @@ -136,18 +132,11 @@ test('a jump holds until the transcript reports itself filled and still', () => // Landed already, so nothing below is a correction — only the boundary. state.targetTop = 0; - // Still filling: quiet frames must not count, however many pass. This is the - // case a fixed timeout got wrong — it expired mid-fill and handed a - // still-growing transcript back to whatever else was moving it. - for (let index = 0; index < 20; index += 1) harness.runFrame(); - assert.equal(state.settled, 0, 'an unfilled transcript keeps its hold'); - - state.filled = true; harness.runFrame(); harness.runFrame(); assert.equal(state.settled, 0, 'settling waits for a few still frames'); harness.runFrame(); - assert.equal(state.settled, 1, 'filled and still ends the hold'); + assert.equal(state.settled, 1, 'a mounted and still destination ends the hold'); harness.restore(); }); @@ -155,13 +144,11 @@ test('a jump holds until the transcript reports itself filled and still', () => test('a jump waits for an unloaded destination before settling', () => { const harness = railHoldHarness(); const { root, scheduler, state } = harness; - state.filled = true; state.targetPresent = false; holdJumpDestination({ root, readTargetId: () => 'turn-7', - isTranscriptFilled: () => state.filled, onSettled: () => { state.settled += 1; }, @@ -189,7 +176,6 @@ test('a jump gives the transcript back the moment the reader touches it', () => holdJumpDestination({ root, readTargetId: () => 'turn-7', - isTranscriptFilled: () => state.filled, onSettled: () => { state.settled += 1; }, diff --git a/packages/ui/src/__tests__/turn-height-index.test.ts b/packages/ui/src/__tests__/turn-height-index.test.ts new file mode 100644 index 0000000000..2777f428cd --- /dev/null +++ b/packages/ui/src/__tests__/turn-height-index.test.ts @@ -0,0 +1,25 @@ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import { createTurnHeightIndex } from '../turn-height-index.js'; + +describe('turn height index', () => { + it('keeps measurements for the current layout and bounds old sessions and turns', () => { + const index = createTurnHeightIndex(2, 2); + assert.equal(index.record('s1', 'wide', 'a', 100), true); + assert.equal(index.record('s1', 'wide', 'a', 100.2), false); + index.record('s1', 'wide', 'b', 200); + index.record('s1', 'wide', 'c', 300); + assert.equal(index.lookup('s1', 'wide')?.has('a'), false); + index.record('s2', 'wide', 'a', 100); + index.record('s3', 'wide', 'a', 100); + assert.equal(index.lookup('s1', 'wide'), undefined); + }); + + it('does not reuse heights after the layout changes', () => { + const index = createTurnHeightIndex(); + index.record('s1', 'wide', 'a', 100); + index.record('s1', 'narrow', 'a', 200); + assert.equal(index.lookup('s1', 'wide'), undefined); + assert.equal(index.lookup('s1', 'narrow')?.get('a'), 200); + }); +}); diff --git a/packages/ui/src/__tests__/turn-size-index.test.ts b/packages/ui/src/__tests__/turn-size-index.test.ts deleted file mode 100644 index 5025f11d22..0000000000 --- a/packages/ui/src/__tests__/turn-size-index.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { strict as assert } from 'node:assert'; -import { describe, it } from 'node:test'; -import { - createTurnSizeIndex, - layoutKeyOf, - measureSettledGeometry, - measureTurnGeometry, - prefixHeightFor, -} from '../turn-size-index.js'; - -function geometry(heights: Record, gap = 4) { - return { heights: new Map(Object.entries(heights)), gap }; -} - -function fakeRoot(options: { - warmup?: string; - width?: number; - columnWidth?: number; - turns?: Array<{ id: string; top: number; height: number; streaming?: boolean }>; -}) { - return { - clientWidth: options.width ?? 900, - dataset: { turnWarmup: options.warmup, density: 'balanced' }, - querySelector: () => - options.columnWidth === undefined ? null : { clientWidth: options.columnWidth }, - querySelectorAll: () => - (options.turns ?? []).map((turn) => ({ - hasAttribute: (name: string) => name === 'data-live-streaming' && (turn.streaming ?? false), - getAttribute: (name: string) => (name === 'data-turn-id' ? turn.id : null), - getBoundingClientRect: () => ({ top: turn.top, height: turn.height }), - })), - }; -} - -describe('createTurnSizeIndex', () => { - it('drops the oldest session past capacity', () => { - const index = createTurnSizeIndex(2); - index.record('s1', 'k', geometry({ a: 1 })); - index.record('s2', 'k', geometry({ a: 1 })); - index.record('s3', 'k', geometry({ a: 1 })); - assert.equal(index.lookup('s1', 'k'), undefined); - assert.ok(index.lookup('s2', 'k')); - assert.ok(index.lookup('s3', 'k')); - }); -}); - -describe('prefixHeightFor', () => { - const ids = ['a', 'b', 'c', 'd']; - - it('is undefined when any prefix turn lacks a height', () => { - assert.equal(prefixHeightFor(ids, 2, geometry({ a: 100 })), undefined); - }); - - it('sums the prefix turns plus the gaps between them', () => { - // Two turns and the one gap between them; the gap joining the spacer to - // the first mounted turn belongs to the list. - assert.equal(prefixHeightFor(ids, 2, geometry({ a: 100, b: 250.5 }, 4)), 355); - }); -}); - -describe('measureSettledGeometry', () => { - const turns = [ - { id: 'a', top: 0, height: 100 }, - { id: 'b', top: 104, height: 200 }, - ]; - - it('aborts when the layout moved since the key was captured', () => { - const before = layoutKeyOf(fakeRoot({ width: 900 })); - const root = fakeRoot({ warmup: 'settled', width: 700, turns }); - assert.deepEqual(measureSettledGeometry(root, before), { status: 'aborted' }); - }); - - it('measures a settled transcript and skips live-streaming turns', () => { - const root = fakeRoot({ - warmup: 'settled', - turns: [...turns, { id: 'c', top: 308, height: 40, streaming: true }], - }); - const attempt = measureSettledGeometry(root, layoutKeyOf(root)); - assert.equal(attempt.status, 'measured'); - if (attempt.status === 'measured') { - assert.equal(attempt.geometry.heights.size, 2); - assert.equal(attempt.geometry.heights.get('c'), undefined); - assert.equal(attempt.geometry.gap, 4); - } - }); -}); - -describe('measureTurnGeometry', () => { - it('records heights and the median between-turn gap', () => { - const record = measureTurnGeometry([ - { turnId: 'a', top: 0, height: 100 }, - { turnId: 'b', top: 104, height: 200 }, - { turnId: 'c', top: 308, height: 50 }, - ]); - assert.ok(record); - assert.equal(record.gap, 4); - assert.equal(record.heights.get('b'), 200); - }); - - it('rejects a negative gap reading', () => { - assert.equal( - measureTurnGeometry([ - { turnId: 'a', top: 0, height: 100 }, - { turnId: 'b', top: 90, height: 200 }, - ]), - undefined, - ); - }); -}); diff --git a/packages/ui/src/__tests__/turn-virtualizer.test.ts b/packages/ui/src/__tests__/turn-virtualizer.test.ts new file mode 100644 index 0000000000..18f9f0fe54 --- /dev/null +++ b/packages/ui/src/__tests__/turn-virtualizer.test.ts @@ -0,0 +1,189 @@ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import { + buildTurnVirtualLayout, + estimatedTurnHeight, + initialTurnVirtualWindow, + reconcileTurnVirtualWindow, + stableTurnVirtualWindowForViewport, + turnVirtualWindowForRange, + turnVirtualWindowForViewport, + turnVirtualizationRequired, +} from '../turn-virtualizer.js'; + +const ids = (count: number) => Array.from({ length: count }, (_, index) => `t${index}`); + +describe('turn virtualizer', () => { + it('uses observed turns to estimate unmeasured history', () => { + assert.equal(estimatedTurnHeight(undefined), 280); + assert.equal(estimatedTurnHeight(new Map([['short', 100]])), 190); + assert.equal(estimatedTurnHeight(new Map([['a', 600], ['b', 1_000]])), 1_880 / 3); + }); + + it('starts with a bounded tail and exact spacer geometry', () => { + const layout = buildTurnVirtualLayout(ids(200), undefined, { estimatedHeight: 100, gap: 4 }); + const window = initialTurnVirtualWindow(layout, 40); + assert.deepEqual({ start: window.start, end: window.end }, { start: 160, end: 200 }); + assert.equal(window.beforeHeight, (160 * 100) + (159 * 4)); + assert.equal(window.afterHeight, 0); + }); + + it('recomputes spacer geometry without changing the mounted range', () => { + const turnIds = ids(100); + const estimated = buildTurnVirtualLayout(turnIds, undefined, { estimatedHeight: 100 }); + const initial = initialTurnVirtualWindow(estimated, 40); + const measured = buildTurnVirtualLayout( + turnIds, + new Map(turnIds.slice(0, 60).map((id) => [id, 200])), + { estimatedHeight: 100 }, + ); + const updated = turnVirtualWindowForRange(measured, initial.start, initial.end); + assert.deepEqual( + { start: updated.start, end: updated.end }, + { start: initial.start, end: initial.end }, + ); + assert.ok(updated.beforeHeight > initial.beforeHeight); + }); + + it('uses measurements and keeps a pixel overscan without exceeding the hard cap', () => { + const turnIds = ids(500); + const heights = new Map(turnIds.map((id, index) => [id, index % 2 === 0 ? 40 : 400])); + const layout = buildTurnVirtualLayout(turnIds, heights); + const window = turnVirtualWindowForViewport( + layout, + { scrollTop: 40_000, clientHeight: 900 }, + { overscanPx: 1_200, preferredTurns: 60, maxTurns: 100 }, + ); + assert.ok(window.end - window.start >= 60); + assert.ok(window.end - window.start <= 100); + assert.ok(layout.offsets[window.start]! <= 40_000); + assert.ok(layout.offsets[window.end]! >= 40_900); + }); + + it('keeps the mounted window stable until the viewport reaches its overscan edge', () => { + const layout = buildTurnVirtualLayout( + ids(120), + undefined, + { estimatedHeight: 200, gap: 4 }, + ); + const current = turnVirtualWindowForViewport( + layout, + { scrollTop: 0, clientHeight: 800 }, + ); + assert.deepEqual([current.start, current.end], [0, 60]); + + const inside = stableTurnVirtualWindowForViewport( + layout, + current, + { scrollTop: 8_000, clientHeight: 800 }, + ); + assert.deepEqual([inside.start, inside.end], [0, 60]); + + const crossed = stableTurnVirtualWindowForViewport( + layout, + current, + { scrollTop: 11_000, clientHeight: 800 }, + ); + assert.equal(crossed.end - crossed.start, 60); + assert.equal(crossed.start, current.start + 8); + }); + + it('retains focused and selected turns when a directional shift fits the hard cap', () => { + const layout = buildTurnVirtualLayout( + ids(120), + undefined, + { estimatedHeight: 200, gap: 4 }, + ); + const current = turnVirtualWindowForRange(layout, 0, 60); + const shifted = stableTurnVirtualWindowForViewport( + layout, + current, + { scrollTop: 11_000, clientHeight: 800 }, + { retainRange: { start: 0, end: 8 } }, + ); + + assert.deepEqual([shifted.start, shifted.end], [0, 68]); + }); + + it('keeps visible identities across prepends and batched appends', () => { + const beforeIds = ids(100); + const beforeLayout = buildTurnVirtualLayout(beforeIds, undefined); + const before = turnVirtualWindowForViewport(beforeLayout, { scrollTop: 8_000, clientHeight: 800 }); + const prepended = ['p0', 'p1', ...beforeIds]; + const afterPrepend = reconcileTurnVirtualWindow( + beforeIds, + buildTurnVirtualLayout(prepended, undefined), + before, + ); + assert.equal(prepended[afterPrepend.start], beforeIds[before.start]); + assert.equal(prepended[afterPrepend.end - 1], beforeIds[before.end - 1]); + + const tail = initialTurnVirtualWindow(beforeLayout, 40); + const appended = [...beforeIds, ...ids(80).map((id) => `a${id}`)]; + const afterAppend = reconcileTurnVirtualWindow( + beforeIds, + buildTurnVirtualLayout(appended, undefined), + tail, + ); + assert.equal(appended[afterAppend.start], beforeIds[tail.start]); + assert.equal(appended[afterAppend.end - 1], beforeIds[tail.end - 1]); + assert.equal(afterAppend.end - afterAppend.start, 40); + }); + + it('virtualizes by rendered distance before the turn-count cap', () => { + const compact = buildTurnVirtualLayout(ids(8), undefined, { estimatedHeight: 90 }); + const tall = buildTurnVirtualLayout(ids(8), undefined, { estimatedHeight: 500 }); + + assert.equal(turnVirtualizationRequired(compact, 800), false); + assert.equal(turnVirtualizationRequired(compact, 800, 4_000), true); + assert.equal(turnVirtualizationRequired(tall, 800), true); + assert.equal(turnVirtualizationRequired(buildTurnVirtualLayout(ids(101), undefined), undefined), true); + }); + + it('expands a window that cannot cover the viewport without oscillating', () => { + const layout = buildTurnVirtualLayout( + ids(10), + new Map([ + ['t4', 4_800], + ['t5', 1_000], + ]), + ); + const undersized = turnVirtualWindowForRange(layout, 4, 5); + const recovered = stableTurnVirtualWindowForViewport( + layout, + undersized, + { scrollTop: 5_000, clientHeight: 800 }, + ); + + assert.deepEqual([recovered.start, recovered.end], [0, 10]); + assert.deepEqual( + stableTurnVirtualWindowForViewport( + layout, + recovered, + { scrollTop: 5_000, clientHeight: 800 }, + ), + recovered, + ); + }); + + it('brings an explicit target into the bounded window', () => { + const turnIds = ids(1_000); + const layout = buildTurnVirtualLayout(turnIds, undefined); + const tail = initialTurnVirtualWindow(layout, 40); + const target = reconcileTurnVirtualWindow(turnIds, layout, tail, 10); + assert.ok(target.start <= 10 && target.end > 10); + assert.ok(target.end - target.start <= 100); + }); + + it('keeps the viewport bounded when a retained turn is too far away', () => { + const layout = buildTurnVirtualLayout(ids(1_000), undefined, { estimatedHeight: 100 }); + const window = turnVirtualWindowForViewport( + layout, + { scrollTop: 80_000, clientHeight: 800 }, + { maxTurns: 100, ensureIndex: 10 }, + ); + assert.equal(window.end - window.start, 100); + assert.ok(layout.offsets[window.start]! <= 80_000); + assert.ok(layout.offsets[window.end]! >= 80_800); + }); +}); diff --git a/packages/ui/src/__tests__/use-turn-virtualizer.test.tsx b/packages/ui/src/__tests__/use-turn-virtualizer.test.tsx new file mode 100644 index 0000000000..29fc3ca85b --- /dev/null +++ b/packages/ui/src/__tests__/use-turn-virtualizer.test.tsx @@ -0,0 +1,115 @@ +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import { act, useRef } from 'react'; +import { createRoot } from 'react-dom/client'; +import { parseHTML } from 'linkedom'; +import { useTurnVirtualizer } from '../use-turn-virtualizer.js'; + +const originalGlobals = { + document: globalThis.document, + Element: globalThis.Element, + HTMLElement: globalThis.HTMLElement, + MutationObserver: globalThis.MutationObserver, + Node: globalThis.Node, + requestAnimationFrame: globalThis.requestAnimationFrame, + cancelAnimationFrame: globalThis.cancelAnimationFrame, + ResizeObserver: globalThis.ResizeObserver, + window: globalThis.window, +}; +const originalActEnvironment = (globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}).IS_REACT_ACT_ENVIRONMENT; + +let mountedRoot: ReturnType | undefined; + +afterEach(async () => { + if (mountedRoot) await act(() => mountedRoot?.unmount()); + mountedRoot = undefined; + Object.assign(globalThis, { + ...originalGlobals, + IS_REACT_ACT_ENVIRONMENT: originalActEnvironment, + }); +}); + +test('remeasures a short transcript when tall turns require virtualization', async () => { + const { document, window } = parseHTML('
'); + const root = document.querySelector('#root'); + assert.ok(root); + let scrollHeight = 0; + Object.defineProperties(root, { + clientHeight: { value: 800 }, + clientWidth: { value: 800 }, + scrollHeight: { get: () => scrollHeight }, + scrollTop: { value: 0, writable: true }, + }); + Object.defineProperty(document, 'getSelection', { value: () => null }); + + const frames = new Map(); + let nextFrame = 1; + class TestResizeObserver { + static latest: TestResizeObserver | undefined; + constructor(private readonly callback: ResizeObserverCallback) { + TestResizeObserver.latest = this; + } + disconnect() {} + observe() {} + unobserve() {} + emit(entries: ResizeObserverEntry[]) { + this.callback(entries, this as unknown as ResizeObserver); + } + } + class TestMutationObserver { + disconnect() {} + observe() {} + takeRecords(): MutationRecord[] { return []; } + } + Object.assign(globalThis, { + document, + Element: window.Element, + HTMLElement: window.HTMLElement, + MutationObserver: TestMutationObserver, + Node: window.Node, + requestAnimationFrame: (callback: FrameRequestCallback) => { + const id = nextFrame; + nextFrame += 1; + frames.set(id, callback); + return id; + }, + cancelAnimationFrame: (id: number) => frames.delete(id), + ResizeObserver: TestResizeObserver, + window, + IS_REACT_ACT_ENVIRONMENT: true, + }); + + const turnIds = Array.from({ length: 8 }, (_, index) => `turn-${index}`); + function Harness() { + const scrollRef = useRef(root); + const range = useTurnVirtualizer({ sessionId: 'tall-session', turnIds, scrollRef }); + return ( +
+ {turnIds.slice(range.start, range.end).map((turnId) => ( +
+ ))} +
+ ); + } + + mountedRoot = createRoot(root); + await act(() => mountedRoot?.render()); + assert.equal(root.querySelector('[data-range]')?.getAttribute('data-range'), '0:8'); + + const observer = TestResizeObserver.latest; + assert.ok(observer); + scrollHeight = 4_800; + const entries = Array.from(root.querySelectorAll('[data-virtual-turn-id]')).map( + (target) => ({ target, borderBoxSize: [{ blockSize: 600 }] }) as unknown as ResizeObserverEntry, + ); + await act(() => observer.emit(entries)); + await act(() => { + const pending = [...frames.values()]; + frames.clear(); + for (const callback of pending) callback(0); + }); + + assert.notEqual(root.querySelector('[data-range]')?.getAttribute('data-range'), '0:8'); +}); diff --git a/packages/ui/src/arrival-bottom-pin.ts b/packages/ui/src/arrival-bottom-pin.ts index 7bd8fc5457..9d384c5769 100644 --- a/packages/ui/src/arrival-bottom-pin.ts +++ b/packages/ui/src/arrival-bottom-pin.ts @@ -5,12 +5,9 @@ * instantly and springs every later growth. That one-shot lives on the hook * instance, and `ChatSurfaceLayout` mounts once for the whole app shell, so it * is spent on the session that happened to be open at boot. Every switch after - * that is "later growth" — and a switched-to transcript does not arrive in one - * piece: the progressive mount commits a tail window, idle chunks fill the - * prefix, and the `content-visibility` warm-up then inflates every placeholder. - * Each step grows the document under a scroller the spring is chasing, so the - * new session opens mid-document and visibly flies to its latest turn (measured - * at ~10k px over ~1.2s on the 24-turn fixture). + * that is "later growth". A switched-to transcript still arrives across the + * virtual tail's first render and measured-height corrections, so a one-shot + * scroll would let the spring chase a moving bottom. * * A session change is navigation, not content growth: the transcript is meant * to be at its latest turn the first time it is painted, exactly as it is on a diff --git a/packages/ui/src/assistant-stream.ts b/packages/ui/src/assistant-stream.ts index 3e0c9847ca..857a5fb169 100644 --- a/packages/ui/src/assistant-stream.ts +++ b/packages/ui/src/assistant-stream.ts @@ -160,13 +160,6 @@ export function applyAssistantDelta( // L3: append. const appended = previousText + delta; - // L4: cross-delta redaction (@kenji review @msg 3c01e901 Blocker 1). - // Streaming splits tokens; a secret like `Authorization: Bearer - // sk-XXX...` can arrive as `"Authorization: Bearer sk-"` (delta N) - // + `"abcdef..."` (delta N+1). Per-delta redaction (L1) cannot see - // the whole token; only re-scanning the freshly-appended - // candidate catches it. `redactSecrets` is idempotent on - // already-masked text, so running it twice is correct. const safeAppended = redactSecrets(appended); const crossDeltaRedactionHappened = safeAppended !== appended; diff --git a/packages/ui/src/chat-conversation-items.ts b/packages/ui/src/chat-conversation-items.ts new file mode 100644 index 0000000000..e45ce69e87 --- /dev/null +++ b/packages/ui/src/chat-conversation-items.ts @@ -0,0 +1,23 @@ +export interface ChatConversationItem { + readonly afterTurnId: string; + readonly renderWhenAnchorMissing?: boolean; + readonly value: T; +} + +export function placeChatConversationItems( + items: readonly ChatConversationItem[], + residentTurnIds: ReadonlySet, +): { byTurn: ReadonlyMap; orphan: T | undefined } { + const byTurn = new Map(); + let orphan: T | undefined; + for (const item of items) { + if (residentTurnIds.has(item.afterTurnId)) { + const current = byTurn.get(item.afterTurnId) ?? []; + current.push(item.value); + byTurn.set(item.afterTurnId, current); + } else if (item.renderWhenAnchorMissing) { + orphan = item.value; + } + } + return { byTurn, orphan }; +} diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx index bc70e499fc..5d6c5c7a4b 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -292,12 +292,6 @@ function MessageCopyButton(props: { text: string }) { */ export const TurnView = memo(function TurnView(props: { turn: TurnViewModel; - /** - * #2224: this turn's height as measured on a previous visit under the - * current layout. Seeds contain-intrinsic-size so the placeholder equals - * the real size, and marks the turn for the warm-up to skip. - */ - seededHeight?: number; userLabel?: string; /** * PR109d-b: footer actions derived from `TurnStatus` + lineage map @@ -412,12 +406,6 @@ export const TurnView = memo(function TurnView(props: { data-turn-id={turn.turnId} data-live-streaming={props.liveStreaming ? 'true' : undefined} data-search-highlight={props.searchHighlighted ? 'true' : undefined} - data-size-seeded={props.seededHeight !== undefined ? 'true' : undefined} - style={ - props.seededHeight !== undefined - ? { containIntrinsicSize: `auto ${props.seededHeight}px` } - : undefined - } tabIndex={props.searchHighlighted ? -1 : undefined} > {forwardBadges.length > 0 && ( diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index 8f88dd535d..994f12c375 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -29,16 +29,12 @@ import { type TurnPresentationDeriver, } from './chat-turn.js'; import { useChatScroll } from './use-chat-scroll.js'; -import { useProgressiveTurnMount } from './use-progressive-turn-mount.js'; -import { createTurnSizeIndex, layoutKeyOf, measureSettledGeometry } from './turn-size-index.js'; +import { useTurnVirtualizer } from './use-turn-virtualizer.js'; +import { placeChatConversationItems } from './chat-conversation-items.js'; import { useUiLocale } from './locale-context.js'; import { getConversationCopy } from './conversation-copy.js'; import { SessionContextLayer } from './session-context-layer.js'; -// #2224: one geometry cache for the app's single ChatView. Session-keyed -// inside; module scope only saves threading it through the shell. -const turnSizeIndex = createTurnSizeIndex(); - export interface LiveContentActivationSnapshot { turnId: string; entries: ReadonlyMap; @@ -103,6 +99,7 @@ export function ChatView(props: { conversationItems?: ReadonlyArray<{ id: string; afterTurnId: string; + renderWhenAnchorMissing?: boolean; content: ReactNode; }>; /** @@ -391,16 +388,15 @@ export function ChatView(props: { (sessionId: string) => onOpenLinkedSessionRef.current?.(sessionId), [], ); - const conversationItemsByTurn = useMemo(() => { - const items = new Map>(); - for (const item of props.conversationItems ?? []) { - const current = items.get(item.afterTurnId) ?? []; - current.push({ id: item.id, content: item.content }); - items.set(item.afterTurnId, current); - } - return items; - }, [props.conversationItems]); const turnIds = useMemo(() => new Set(turns.map((turn) => turn.turnId)), [turns]); + const conversationItemPlacement = useMemo(() => placeChatConversationItems( + (props.conversationItems ?? []).map((item) => ({ + afterTurnId: item.afterTurnId, + renderWhenAnchorMissing: item.renderWhenAnchorMissing, + value: { id: item.id, content: item.content }, + })), + turnIds, + ), [props.conversationItems, turnIds]); const turnIdsRef = useRef(turnIds); turnIdsRef.current = turnIds; const loadTranscriptTurnRef = useRef(props.onLoadTranscriptTurn); @@ -411,42 +407,20 @@ export function ChatView(props: { } const scrollRef = chatLayout.scrollContainerRef; const [latestNavigationNonce, setLatestNavigationNonce] = useState(0); - // #2052: the first commit after a session switch mounts only a tail window - // of turns; the rest arrive in idle chunks with scroll compensation. The - // full `turns` array above still feeds deriveTurnPresentation and the - // prompt rail, so presentation caching (#2030) and rail geometry are not - // window-dependent; only the JSX mapping below is sliced. const orderedTurnIds = useMemo(() => turns.map((turn) => turn.turnId), [turns]); - // #2224: heights measured on a previous visit under the current layout. - // With them the unmounted prefix is held by one spacer and each turn's - // intrinsic size is seeded, so the scroller's total height stays put while - // the fill runs. Without them (first visit, resized window) everything - // below degrades to the plain #2052 fill and the warm-up relearns sizes. const sessionId = props.activeSession?.id; - // The lookup should land in the same commit as the session switch, so the - // scroller never paints a frame at its unseeded height. An in-place - // switch has the scroller ref during render and the memo reads it there. - // Two things invalidate that read: on a fresh mount the ref is still null - // (the scroller is an ancestor host whose ref attaches after descendant - // effects), and on platforms with classic scrollbars the column is wider - // until enough turns mount to overflow, so an early read misses the - // record. The nudge effect below answers both by retrying after every - // commit while the fill window is open (each fill chunk moves mountStart) - // and stopping on the first hit or when the window closes, so token - // streaming never re-reads layout. - const [lookupPass, setLookupPass] = useState(0); - const seededGeometry = useMemo(() => { - const root = scrollRef.current; - if (!sessionId || !root) return undefined; - return turnSizeIndex.lookup(sessionId, layoutKeyOf(root)); - }, [sessionId, scrollRef, lookupPass]); - const { start: mountStart, filled: turnsFilled, prefixHeight, revealTurn } = useProgressiveTurnMount({ + const { + start: mountStart, + end: mountEnd, + beforeHeight, + afterHeight, + revealTurn, + } = useTurnVirtualizer({ sessionId, turnIds: orderedTurnIds, scrollRef, - scrollBehavior: props.scrollBehavior, targetTurnId: props.scrollTargetTurn?.turnId, - seededGeometry, + targetKey: props.scrollTargetTurn?.nonce, }); const navigatePromptRailFallback = useCallback((turn: PromptAnchorRailTurn) => { if (turnIdsRef.current.has(turn.turnId)) revealTurn(turn.turnId); @@ -454,56 +428,7 @@ export function ChatView(props: { loadTranscriptTurnRef.current?.({ turnId: turn.turnId, sequence: turn.sequence }); } }, [revealTurn]); - useEffect(() => { - if (!turnsFilled && !seededGeometry && scrollRef.current) { - setLookupPass((count) => count + 1); - } - }, [sessionId, orderedTurnIds, mountStart, turnsFilled, seededGeometry, scrollRef]); - const mountedTurns = mountStart === 0 ? turns : turns.slice(mountStart); - // Record geometry once the transcript has settled: fill complete and the - // warm-up done, so every turn's box is its remembered final size and - // reading it forces no render. An exit-time capture would be too late, - // React runs effect cleanups after the next session's DOM is already in. - // Streaming turns are still moving and are left out. - useEffect(() => { - const root = scrollRef.current; - // A pair of turns is the smallest transcript measureSettledGeometry - // accepts, and the pair gate also keeps an empty session from polling - // forever: with no turns the warm-up never runs, so 'settled' is never - // written and the wait below would have no end. - if (!sessionId || !root || !turnsFilled || orderedTurnIds.length < 2) return; - let disposed = false; - let timer: number | undefined; - // Backstop for the same never-settles shape arriving some other way: a - // walk that has not settled after 150 polls is not going to, and a dead - // timer must not keep reading layout on a resting surface. - let polls = 0; - const startKey = layoutKeyOf(root); - const measure = () => { - if (disposed) return; - const attempt = measureSettledGeometry(root, startKey); - if (attempt.status === 'pending') { - polls += 1; - if (polls < 150) timer = window.setTimeout(measure, 200); - return; - } - if (attempt.status === 'measured') { - turnSizeIndex.record(sessionId, startKey, attempt.geometry); - // Published like data-turn-warmup, with the key as the value: a - // wait can then ask for the record covering the layout it is about - // to rely on, not merely some record from an earlier width. - root.dataset.turnGeometry = startKey; - } - }; - timer = window.setTimeout(measure, 200); - return () => { - disposed = true; - window.clearTimeout(timer); - // The key describes the transcript that was measured; whatever - // replaces it must not inherit the announcement. - delete root.dataset.turnGeometry; - }; - }, [sessionId, turnsFilled, orderedTurnIds, scrollRef]); + const mountedTurns = turns.slice(mountStart, mountEnd); const { highlightedTurnId } = useChatScroll({ scrollRef, sessionId: props.activeSession?.id, @@ -511,7 +436,6 @@ export function ChatView(props: { messages: props.messages, target: props.scrollTargetTurn, behavior: props.scrollBehavior, - warmupReady: turnsFilled, hasOlderHistory: props.hasOlderHistory, historyLoadPending: props.historyLoadPending, onLoadEarlierHistory: props.onLoadEarlierHistory, @@ -575,10 +499,11 @@ export function ChatView(props: { } const deepResearchActive = isDeepResearchSession(props.activeSession.labels); - const conversationItems = props.conversationItems ?? []; + const hasVisibleConversationItem = + conversationItemPlacement.byTurn.size > 0 || conversationItemPlacement.orphan !== undefined; const showEmptyState = - (chat.length === 0 && !streamingActive && conversationItems.length === 0) - || Boolean(props.messageLoading && chat.length === 0 && conversationItems.length === 0); + (chat.length === 0 && !streamingActive && !hasVisibleConversationItem) + || Boolean(props.messageLoading && chat.length === 0 && !hasVisibleConversationItem); const emptyContent = props.messageLoading ? (
@@ -656,10 +581,10 @@ export function ChatView(props: { scrollRef={scrollRef} onNavigateFallback={navigatePromptRailFallback} onNavigateStart={chatLayout.unlockAutoFollow} - transcriptFilled={turnsFilled} /> {chat.length === 0 && !streamingActive ? emptyContent : null} - {/* #2224: stands in for the unmounted prefix so the scroller's - total height (and the native scrollbar) holds still while - the fill replaces it chunk by chunk. */} - {mountStart > 0 && prefixHeight !== undefined && prefixHeight > 0 && ( + {beforeHeight > 0 && (