From dda63d443550a5bf0bd9b17d747bf70327c8be8b Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 28 Aug 2026 23:12:55 +0800 Subject: [PATCH 1/5] fix(desktop): restore the workbar surface's lazy boundary The feature barrel statically re-exported `WorkbarSurface`, and `app-shell` imports `WorkbarHost` from that barrel, so the surface and its five nested lazy tool panels landed in the eager chunk anyway. rolldown named it: INEFFECTIVE_DYNAMIC_IMPORT. Drop it from the barrel and give Storybook its own public entry. It cannot share `testing`: that module is loaded by `node --test` against tsc output, and the surface and its tool panels use extensionless relative specifiers only a bundler resolves, so re-exporting it there breaks every node suite behind `testing` at load time. Stories run through Vite and have no such limit. `workbar-boundary` recognizes the new entry alongside the other two. Renderer entry chunk 2,245.33 kB -> 2,166.20 kB (gzip 451.12 -> 426.86), with `workbar-surface` now emitted as its own 81.31 kB chunk, and the build warning is gone. --- .../main/__tests__/workbar-boundary.test.ts | 5 ++- .../src/renderer/features/workbar/index.ts | 6 +++- .../src/renderer/features/workbar/stories.ts | 33 +++++++++++++++++++ ...accessibility-runtime-surfaces.stories.tsx | 4 +-- .../stories/session-workbar.stories.tsx | 6 ++-- 5 files changed, 45 insertions(+), 9 deletions(-) create mode 100644 apps/desktop/src/renderer/features/workbar/stories.ts diff --git a/apps/desktop/src/main/__tests__/workbar-boundary.test.ts b/apps/desktop/src/main/__tests__/workbar-boundary.test.ts index 352a9e7dde..98dcfb8605 100644 --- a/apps/desktop/src/main/__tests__/workbar-boundary.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-boundary.test.ts @@ -67,7 +67,10 @@ describe('Workbar feature boundary', () => { }); it('is consumed outside the feature only through public entries', () => { - const allowed = /\/features\/workbar\/(?:index|testing)(?:\.js)?$/; + // `stories` joins `index` and `testing` as a public entry: the surface it + // exposes is only resolvable by a bundler, so Storybook can import it and + // the node suites behind `testing` cannot. + const allowed = /\/features\/workbar\/(?:index|testing|stories)(?:\.js)?$/; const violations: string[] = []; for (const root of [join(desktopRoot, 'src'), join(desktopRoot, 'stories')]) { for (const path of sourceFiles(root)) { diff --git a/apps/desktop/src/renderer/features/workbar/index.ts b/apps/desktop/src/renderer/features/workbar/index.ts index a104e6ade0..2b2decd585 100644 --- a/apps/desktop/src/renderer/features/workbar/index.ts +++ b/apps/desktop/src/renderer/features/workbar/index.ts @@ -17,8 +17,12 @@ * under the License. */ +// `WorkbarSurface` is deliberately absent: `workbar-host` reaches it through +// `lazy(() => import('./workbar-surface'))`, and re-exporting it here would +// pull the surface and its five nested tool panels back into the eager chunk +// for every importer of this barrel. Stories reach it through `stories`, +// which nothing shipped imports. export { WorkbarHost } from './ui/workbar-host'; -export { WorkbarSurface } from './ui/workbar-surface'; export { WorkbarTitlebarActions } from './ui/workbar-toggle'; export { WorkbarServicesProvider } from './services-context'; export { useWorkbarController } from './controller/use-workbar-controller'; diff --git a/apps/desktop/src/renderer/features/workbar/stories.ts b/apps/desktop/src/renderer/features/workbar/stories.ts new file mode 100644 index 0000000000..724bd0e9d0 --- /dev/null +++ b/apps/desktop/src/renderer/features/workbar/stories.ts @@ -0,0 +1,33 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Storybook-only entry, separate from `testing` for a reason the module graph + * enforces: `testing` is loaded by `node --test` against tsc output, and + * `workbar-surface` and its tool panels use extensionless relative specifiers + * that only a bundler resolves. Stories run through Vite, so they can reach + * the surface; the node suites cannot, and must not be made to. + * + * The production entry omits `WorkbarSurface` on top of that: `workbar-host` + * reaches it through `lazy()`, and a static re-export beside `WorkbarHost` + * would pull the surface and its five tool panels back into the eager chunk. + * Nothing shipped imports this module either. + */ + +export { WorkbarSurface } from './ui/workbar-surface.js'; diff --git a/apps/desktop/stories/accessibility-runtime-surfaces.stories.tsx b/apps/desktop/stories/accessibility-runtime-surfaces.stories.tsx index aa0783470d..84baefd6f6 100644 --- a/apps/desktop/stories/accessibility-runtime-surfaces.stories.tsx +++ b/apps/desktop/stories/accessibility-runtime-surfaces.stories.tsx @@ -22,9 +22,6 @@ import { useRef, useState } from 'react'; import { expect, fn, userEvent, waitFor, within } from 'storybook/test'; import type { ArtifactDescriptor } from '@maka/core/artifacts'; import { ToastProvider } from '@maka/ui'; -import { - WorkbarSurface, -} from '../src/renderer/features/workbar'; import { createFakeWorkbarServices, createSessionWorkbarPanelsState, @@ -34,6 +31,7 @@ import { WorkbarServicesProvider, type WorkbarServices, } from '../src/renderer/features/workbar/testing'; +import { WorkbarSurface } from '../src/renderer/features/workbar/stories'; import { RemoteProjectDirectoryDialog } from '../src/renderer/remote-project-directory-dialog'; import { RuntimeHostSshTerminalDialog } from '../src/renderer/settings/runtime-host-ssh-terminal-dialog'; import { withScopedMakaBridge } from './maka-bridge'; diff --git a/apps/desktop/stories/session-workbar.stories.tsx b/apps/desktop/stories/session-workbar.stories.tsx index 8d80ea4879..a46d591ace 100644 --- a/apps/desktop/stories/session-workbar.stories.tsx +++ b/apps/desktop/stories/session-workbar.stories.tsx @@ -28,10 +28,8 @@ import type { Task } from '@maka/core/task-ledger'; import type { SessionTrace } from '@maka/core/session-trace'; import type { ContextDiagnosticsResult } from '@maka/runtime-host/protocol'; import { ToastProvider } from '@maka/ui'; -import { - WorkbarServicesProvider, - WorkbarSurface, -} from '../src/renderer/features/workbar'; +import { WorkbarServicesProvider } from '../src/renderer/features/workbar'; +import { WorkbarSurface } from '../src/renderer/features/workbar/stories'; import { createFakeWorkbarServices, createSessionWorkbarPanelsState, From 34e03ef3a04e415ff767f2c6e1f28bdbfa08f7a4 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 28 Aug 2026 23:13:09 +0800 Subject: [PATCH 2/5] refactor(desktop): import ComposerProps from the @maka/ui entry point Four workbar files reached `ComposerProps` through a seven- or eight-level relative path into `packages/ui/dist/composer.d.ts`, a build artifact. `@maka/ui` already exports the type, and all four files already import from it. --- .../features/workbar/controller/use-workbar-controller.ts | 3 +-- .../features/workbar/tools/side-chat/quote-companion-panel.tsx | 2 +- apps/desktop/src/renderer/features/workbar/ui/workbar-host.tsx | 2 +- .../src/renderer/features/workbar/ui/workbar-surface.tsx | 2 +- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts index 4b829e148f..743aae8138 100644 --- a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts +++ b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts @@ -29,8 +29,7 @@ import { import type { QuoteRef } from '@maka/core/events'; import type { SessionSummary } from '@maka/core/session'; import { Composer, useUiLocale } from '@maka/ui'; -import type { ChatModelChoice } from '@maka/ui'; -import type { ComposerProps } from '../../../../../../../packages/ui/dist/composer.d.ts'; +import type { ChatModelChoice, ComposerProps } from '@maka/ui'; import { safeLocalStorageGet, safeLocalStorageSet } from '../../../browser-storage.js'; import { getDesktopConversationCopy } from '../../../locales/conversation-copy.js'; import { localizedShellErrorMessage } from '../../../locales/shell-copy.js'; diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx index b6a1ae9b9b..b3710f8bf6 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx @@ -31,7 +31,7 @@ import { type ChatModelChoice, type ComposerHandle, } from '@maka/ui'; -import type { ComposerProps } from '../../../../../../../../packages/ui/dist/composer.d.ts'; +import type { ComposerProps } from '@maka/ui'; import type { SessionSummary } from '@maka/core/session'; import { useQuoteCompanion } from './use-quote-companion'; import { useComposerAttachments } from '../../../../use-composer-attachments'; diff --git a/apps/desktop/src/renderer/features/workbar/ui/workbar-host.tsx b/apps/desktop/src/renderer/features/workbar/ui/workbar-host.tsx index a25106c1fc..3527458ae1 100644 --- a/apps/desktop/src/renderer/features/workbar/ui/workbar-host.tsx +++ b/apps/desktop/src/renderer/features/workbar/ui/workbar-host.tsx @@ -22,7 +22,7 @@ import { Card } from '@astryxdesign/core/Card'; import { ResizeHandle, type ResizableProps } from '@astryxdesign/core/Resizable'; import { Spinner } from '@astryxdesign/core/Spinner'; import { Composer, useUiLocale } from '@maka/ui'; -import type { ComposerProps } from '../../../../../../../packages/ui/dist/composer.d.ts'; +import type { ComposerProps } from '@maka/ui'; import type { ChatModelChoice } from '@maka/core/chat-model-choice'; import type { SessionSummary } from '@maka/core/session'; import { getShellCopy } from '../../../locales/shell-copy'; diff --git a/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx b/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx index 2788295ec4..ad5b2fa919 100644 --- a/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx +++ b/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx @@ -49,7 +49,7 @@ import { useUiLocale, type ChatModelChoice, } from '@maka/ui'; -import type { ComposerProps } from '../../../../../../../packages/ui/dist/composer.d.ts'; +import type { ComposerProps } from '@maka/ui'; import { ICON_SIZE, Activity, From f7a2141b34c138d18d421c5fcf3ab476143bd924 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 28 Aug 2026 23:13:09 +0800 Subject: [PATCH 3/5] refactor(ui): fold the assistant and thinking streams into one pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `assistant-stream` and `thinking-stream` were two copies of one authority: identical Options/Result shapes, the same five-step redact-append-cap pipeline, the same non-string guard, structurally identical complete paths. They differed only in caps, marker strings, and which end of an over-cap buffer survives — a direction `streaming-display-redaction` already parameterizes as `recovery`. `stream-delta` now owns the pipeline; the two modules keep their exported names, caps, and option types as thin wrappers that supply their own spec. Head-keep's short-circuit on a frozen buffer is now expressed as a property of `recovery: 'head'` rather than of the assistant stream, which is what it always was. Tested directly rather than only through the wrappers, since each wrapper exercises one direction and neither would fail first if the two stopped differing where they must. A mutation that routes head-keep through the tail-keep branch fails the new suite. `tool-output-stream` stays separate: it accumulates a chunk array with dedup-by-seq, a different problem. --- .../ui/src/__tests__/stream-delta.test.ts | 171 +++++++++++++ packages/ui/src/assistant-stream.ts | 221 +++------------- packages/ui/src/stream-delta.ts | 238 ++++++++++++++++++ packages/ui/src/thinking-stream.ts | 172 +++---------- 4 files changed, 470 insertions(+), 332 deletions(-) create mode 100644 packages/ui/src/__tests__/stream-delta.test.ts create mode 100644 packages/ui/src/stream-delta.ts diff --git a/packages/ui/src/__tests__/stream-delta.test.ts b/packages/ui/src/__tests__/stream-delta.test.ts new file mode 100644 index 0000000000..372a67ba79 --- /dev/null +++ b/packages/ui/src/__tests__/stream-delta.test.ts @@ -0,0 +1,171 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * The shared pipeline directly, not through `applyAssistantDelta` / + * `applyThinkingDelta`. Those wrappers only ever exercise one `recovery` + * direction each, so a change that broke the other direction — or that made + * the two agree where they must differ — would not fail there first. + * + * Markers here are short ASCII stand-ins so every expectation can name the + * exact resulting string rather than assert a shape. + */ + +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import { + applyStreamComplete, + applyStreamDelta, + type StreamDeltaSpec, +} from '../stream-delta.js'; + +const CHUNK = '[C]'; +const TOTAL = '[T]'; + +function spec(overrides: Partial = {}): StreamDeltaSpec { + return { + maxDeltaChars: 1024, + maxTotalChars: 8, + recovery: 'head', + chunkMarker: CHUNK, + totalMarker: TOTAL, + ...overrides, + }; +} + +describe('applyStreamDelta — the two recovery directions', () => { + it('head-keeps the prefix and tail-keeps the most recent, from one input', () => { + const head = applyStreamDelta('', 'abcdefghij', spec({ recovery: 'head' })); + const tail = applyStreamDelta('', 'abcdefghij', spec({ recovery: 'tail' })); + + // maxTotalChars 8 minus a 3-char marker leaves 5 characters of content. + assert.equal(head.text, `abcde${TOTAL}`); + assert.equal(tail.text, `${TOTAL}fghij`); + assert.equal(head.text.length, 8); + assert.equal(tail.text.length, 8); + assert.equal(head.truncated, true); + assert.equal(tail.truncated, true); + }); + + it('drops the carried state only where head-keep cut the suffix it describes', () => { + const headCut = applyStreamDelta('', 'abcdefghij', spec({ recovery: 'head' })); + const tailCut = applyStreamDelta('', 'abcdefghij', spec({ recovery: 'tail' })); + const headUncut = applyStreamDelta('', 'abc', spec({ recovery: 'head' })); + + assert.equal('redactionState' in headCut, false); + assert.notEqual(tailCut.redactionState, undefined); + assert.notEqual(headUncut.redactionState, undefined); + assert.equal(headUncut.truncated, false); + }); + + it('freezes a full head-kept buffer and keeps a tail-kept window sliding', () => { + const frozen = `abcde${TOTAL}`; + const dropped = applyStreamDelta(frozen, 'more', spec({ recovery: 'head' })); + assert.deepEqual(dropped, { text: frozen, redacted: false, truncated: true }); + + const sliding = `${TOTAL}fghij`; + const advanced = applyStreamDelta(sliding, 'KL', spec({ recovery: 'tail' })); + assert.equal(advanced.text, `${TOTAL}hijKL`); + assert.equal(advanced.truncated, true); + }); + + it('caps an oversize single delta the same way in both directions', () => { + // Well under the total cap, so only the per-delta gate can fire. + const perDelta = spec({ maxDeltaChars: 6, maxTotalChars: 1024 }); + const expected = `xy${CHUNK}fgh`; + + assert.equal( + applyStreamDelta('xy', 'abcdefgh', { ...perDelta, recovery: 'head' }).text, + expected, + ); + assert.equal( + applyStreamDelta('xy', 'abcdefgh', { ...perDelta, recovery: 'tail' }).text, + expected, + ); + }); +}); + +describe('applyStreamDelta — defensive guard', () => { + it('drops a non-string delta without claiming redaction, in both directions', () => { + for (const recovery of ['head', 'tail'] as const) { + assert.deepEqual( + applyStreamDelta('so far', undefined as unknown as string, spec({ recovery })), + { text: 'so far', redacted: false, truncated: false }, + ); + } + assert.equal( + applyStreamDelta(undefined as unknown as string, 42 as unknown as string, spec()).text, + '', + ); + }); + + it('passes the caller state straight back through the guard', () => { + const seeded = applyStreamDelta('', 'seed', spec({ maxTotalChars: 1024 })); + const carried = seeded.redactionState; + assert.notEqual(carried, undefined); + + const guarded = applyStreamDelta('seed', null as unknown as string, { + ...spec({ maxTotalChars: 1024 }), + redactionState: carried, + }); + assert.equal(guarded.redactionState, carried); + }); +}); + +describe('applyStreamComplete', () => { + it('applies the total cap in the direction it was given', () => { + assert.equal( + applyStreamComplete('abcdefghij', { + maxTotalChars: 8, + recovery: 'head', + totalMarker: TOTAL, + }).text, + `abcde${TOTAL}`, + ); + assert.equal( + applyStreamComplete('abcdefghij', { + maxTotalChars: 8, + recovery: 'tail', + totalMarker: TOTAL, + }).text, + `${TOTAL}fghij`, + ); + }); + + it('redacts before the cap and reports it', () => { + const result = applyStreamComplete( + 'Authorization: Bearer sk-secret123ABCDEFGHIJKLMNOP', + { maxTotalChars: 1024, recovery: 'head', totalMarker: TOTAL }, + ); + assert.equal(result.redacted, true); + assert.equal(result.truncated, false); + assert.equal(result.text.includes('sk-secret123ABCDEFGHIJKLMNOP'), false); + }); + + it('returns empty for a non-string payload', () => { + assert.deepEqual( + applyStreamComplete(undefined as unknown as string, { + maxTotalChars: 8, + recovery: 'tail', + totalMarker: TOTAL, + }), + { text: '', redacted: false, truncated: false }, + ); + }); +}); diff --git a/packages/ui/src/assistant-stream.ts b/packages/ui/src/assistant-stream.ts index 22e60848ed..f2aaca01c0 100644 --- a/packages/ui/src/assistant-stream.ts +++ b/packages/ui/src/assistant-stream.ts @@ -18,53 +18,24 @@ */ /** - * PR-UI-Cx (@kenji C1 residual note msg aa2d26a7) — pure - * trust-boundary helper for the assistant `text_delta` stream the - * renderer accumulates into the active `LiveTurnProjection`. + * PR-UI-Cx (@kenji C1 residual note msg aa2d26a7) — the assistant + * `text_delta` stream the renderer accumulates into the active + * `LiveTurnProjection`. * - * Mirrors A3 `tool-output-stream` / C0 `thinking-stream` exactly: - * - pure helper `applyAssistantDelta` - * - per-delta cap (defensive against a single misbehaving multi-MB - * chunk) - * - per-session total cap (bounds renderer state for a runaway - * stream) - * - secondary `redactSecrets` BEFORE state — the renderer cannot - * trust upstream to have masked every secret, and a raw - * `Authorization: Bearer …` prefix sitting in the live projection - * would expose the secret via React DevTools snapshot, the - * "copy message" affordance, and any future serialization that - * walks the streaming state. - * - * Why "head-keep, mark the tail" instead of "tail-keep, mark the - * head" for the total cap (different from thinking-stream): - * - * Assistant text is read by the user TOP-DOWN as it streams — - * they begin reading the first token immediately and follow the - * answer sequentially. Tail-keep would scroll the start of the - * answer OFF, which is exactly the wrong shape for "read the - * model's reply". Head-keep with a trailing "[…后续已截断]" - * marker preserves the visible content the user has been reading - * and tells them clearly that more was produced but cut. - * - * Thinking-stream tail-keeps because the user is watching the - * CURRENT chain of thought ("what is the model thinking right - * now"). Assistant output is the opposite affordance. - * - * Per-delta cap stays tail-keep with a head marker — same as - * thinking — because a single oversize delta is a runtime - * misbehavior and the user has not been "reading" within that one - * chunk yet; the chunk is about to be appended atomically. + * The pipeline itself lives in `stream-delta`, which this module and + * `thinking-stream` share; everything below is the assistant's own caps, + * markers, and recovery direction. Assistant text head-keeps because the user + * reads a reply top-down — see `stream-delta` for why thinking does not. */ -import { redactSecrets } from './redact.js'; -import { - appendStreamingDisplayRedaction, - createStreamingDisplayRedactionState, - truncateStreamingDisplayAppend, - type StreamingDisplayRedactionState, -} from './streaming-display-redaction.js'; import type { UiLocale } from '@maka/core/ui-locale'; import { getSharedUiCopy } from './shared-ui-copy.js'; +import { + applyStreamComplete, + applyStreamDelta, + type ApplyStreamOptions, + type ApplyStreamResult, +} from './stream-delta.js'; /** * Default caps. Tuned to: @@ -83,170 +54,40 @@ import { getSharedUiCopy } from './shared-ui-copy.js'; export const ASSISTANT_MAX_DELTA_CHARS = 4 * 1024; export const ASSISTANT_MAX_TOTAL_CHARS = 256 * 1024; -export interface ApplyAssistantOptions { - /** Override per-delta cap. */ - maxDeltaChars?: number; - /** Override per-session total cap. */ - maxTotalChars?: number; +export interface ApplyAssistantOptions extends ApplyStreamOptions { /** Resolved UI locale for user-visible truncation markers. */ locale?: UiLocale; - /** Differential-safe state returned by the preceding delta. */ - redactionState?: StreamingDisplayRedactionState; } -export interface ApplyAssistantResult { - /** Resulting accumulated assistant text (post-redaction, post-cap). */ - text: string; - /** True if redaction modified anything during this call. */ - redacted: boolean; - /** True if any per-delta or total truncation happened during this call. */ - truncated: boolean; - /** Bounded state needed to keep later prefixes oracle-equivalent. */ - redactionState?: StreamingDisplayRedactionState; -} +export type ApplyAssistantResult = ApplyStreamResult; -/** - * Apply a single `text_delta` to the prior accumulated assistant - * text. Pure: no React state, no DOM, no IPC. - * - * Pipeline (in order): - * 1. Append through the differential-safe redactor. It caches complete - * lines and re-runs the whole-text oracle over only the mutable suffix. - * 2. If the delta alone is oversized, cap the already-redacted mutable - * suffix so a cross-delta secret cannot leak through truncation. - * 3. If the safe-appended exceeds `maxTotalChars`, head-keep - * the prefix and append a trailing marker. (User reads the - * answer from top; we preserve what they've been reading - * and tell them the rest was cut.) - * - * Short-circuit: once the buffer is at the total cap (ends with - * the trailing-truncation marker), subsequent deltas are dropped - * entirely. - * - * The carried state is opaque: live projection stores only a WeakMap key and - * length counters, never the raw mutable suffix as enumerable React state. - */ +/** Apply a single `text_delta` to the prior accumulated assistant text. */ export function applyAssistantDelta( prev: string, rawDelta: string, options: ApplyAssistantOptions = {}, ): ApplyAssistantResult { - const maxDelta = options.maxDeltaChars ?? ASSISTANT_MAX_DELTA_CHARS; - const maxTotal = options.maxTotalChars ?? ASSISTANT_MAX_TOTAL_CHARS; const copy = getSharedUiCopy(options.locale ?? 'zh').stream; - const truncatedChunkMarker = copy.assistantChunkTruncated; - const truncatedTailMarker = copy.assistantTailTruncated; - - // Defensive guard: a non-string `rawDelta` is a runtime contract - // violation. Drop it silently rather than coerce to '' and claim - // redaction happened. - if (typeof rawDelta !== 'string') { - return { - text: prev ?? '', - redacted: false, - truncated: false, - ...(options.redactionState === undefined - ? {} - : { redactionState: options.redactionState }), - }; - } - - const previousText = prev ?? ''; - // Short-circuit: if the buffer is already capped (ends with the - // trailing marker AND is at maxTotal), drop further deltas - // entirely. This avoids reprocessing redaction / cap on a stream - // of subsequent deltas after the cap has been hit. - if ( - previousText.length >= maxTotal && - previousText.endsWith(truncatedTailMarker) - ) { - return { text: previousText, redacted: false, truncated: true }; - } - - const redactionState = options.redactionState ?? appendStreamingDisplayRedaction( - '', - previousText, - createStreamingDisplayRedactionState({ - maxRecoveryChars: maxTotal + 1, - recovery: 'head', - }), - ).state; - - // Oversize deltas keep the established redact-before-truncate behavior. Normal - // deltas stay raw until the line-aware append below so a later prefix can - // legitimately make an opaque token visible again. - const redactedDelta = redactSecrets(rawDelta); - const perDeltaRedactionHappened = redactedDelta !== rawDelta; - - // L2: per-delta cap. A single oversize delta gets tail-kept with - // a head marker. (Aligns with C0 thinking-stream; the user hasn't - // been reading inside the delta atomically.) - let deltaTruncated = false; - const rawAppended = appendStreamingDisplayRedaction( - previousText, - rawDelta, - redactionState, - ); - const appended = redactedDelta.length > maxDelta - ? truncateStreamingDisplayAppend( - previousText, - rawAppended, - maxDelta, - truncatedChunkMarker, - ) - : rawAppended; - deltaTruncated = appended !== rawAppended; - - // L5: total cap. Head-keep the prefix the user has been reading; - // mark the tail. - let result = appended.text; - let totalTruncated = false; - if (result.length > maxTotal) { - const keep = maxTotal - truncatedTailMarker.length; - result = appended.text.slice(0, keep) + truncatedTailMarker; - totalTruncated = true; - } - - return { - text: result, - redacted: perDeltaRedactionHappened || appended.redacted, - truncated: deltaTruncated || totalTruncated, - ...(totalTruncated + return applyStreamDelta(prev, rawDelta, { + maxDeltaChars: options.maxDeltaChars ?? ASSISTANT_MAX_DELTA_CHARS, + maxTotalChars: options.maxTotalChars ?? ASSISTANT_MAX_TOTAL_CHARS, + recovery: 'head', + chunkMarker: copy.assistantChunkTruncated, + totalMarker: copy.assistantTailTruncated, + ...(options.redactionState === undefined ? {} - : { redactionState: appended.state }), - }; + : { redactionState: options.redactionState }), + }); } -/** - * Apply a `text_complete` final payload. The complete event carries the FULL - * final assistant text, so this is a replace path: redact and apply only the - * per-session total cap, not the per-delta cap used for incremental chunks. - */ +/** Apply a `text_complete` final payload (replace, total cap only). */ export function applyAssistantComplete( rawText: string, options: Pick = {}, ): ApplyAssistantResult { - const maxTotal = options.maxTotalChars ?? ASSISTANT_MAX_TOTAL_CHARS; - const truncatedTailMarker = getSharedUiCopy(options.locale ?? 'zh').stream.assistantTailTruncated; - - if (typeof rawText !== 'string') { - return { text: '', redacted: false, truncated: false }; - } - - const redacted = redactSecrets(rawText); - const redactionHappened = redacted !== rawText; - - let result = redacted; - let totalTruncated = false; - if (result.length > maxTotal) { - const keep = maxTotal - truncatedTailMarker.length; - result = redacted.slice(0, keep) + truncatedTailMarker; - totalTruncated = true; - } - - return { - text: result, - redacted: redactionHappened, - truncated: totalTruncated, - }; + return applyStreamComplete(rawText, { + maxTotalChars: options.maxTotalChars ?? ASSISTANT_MAX_TOTAL_CHARS, + recovery: 'head', + totalMarker: getSharedUiCopy(options.locale ?? 'zh').stream.assistantTailTruncated, + }); } diff --git a/packages/ui/src/stream-delta.ts b/packages/ui/src/stream-delta.ts new file mode 100644 index 0000000000..9406f397b1 --- /dev/null +++ b/packages/ui/src/stream-delta.ts @@ -0,0 +1,238 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * The one trust-boundary pipeline the renderer runs over a streamed text + * buffer, shared by `assistant-stream` and `thinking-stream`. Both streams + * append provider text into a live `LiveTurnProjection` field, and both need + * the same three guarantees before that text becomes React state: + * + * - secondary `redactSecrets` BEFORE state — the renderer cannot trust + * upstream to have masked every secret, and a raw `Authorization: + * Bearer …` prefix sitting in the live projection would leak through a + * React DevTools snapshot, the "copy message" affordance, and any future + * serialization that walks the streaming state; + * - a per-delta cap, defensive against one misbehaving multi-MB chunk; + * - a per-session total cap that bounds renderer state for a runaway stream. + * + * The streams differ only in their caps, their user-visible markers, and which + * end of an over-cap buffer survives — the `recovery` direction + * `streaming-display-redaction` already parameterizes: + * + * `recovery: 'head'` (assistant text) keeps the prefix and marks the tail. + * Assistant output is read TOP-DOWN as it streams; tail-keep would scroll + * the start of the answer off, which is the wrong shape for "read the + * model's reply". Head-keep also freezes the buffer once it is full, so + * later deltas short-circuit rather than reprocess. + * + * `recovery: 'tail'` (thinking) keeps the most recent text and marks the + * head. The user watching extended thinking is following the CURRENT chain + * of thought, so the oldest reasoning is the least relevant. + * + * The per-delta cap is tail-keep with a head marker in BOTH streams: a single + * oversize delta is runtime misbehavior, the user has not been reading inside + * that chunk yet, and it is about to be appended atomically. + * + * `tool-output-stream` is deliberately not folded in here — it accumulates an + * array of chunks with dedup-by-seq, which is a different problem. + */ + +import { redactSecrets } from './redact.js'; +import { + appendStreamingDisplayRedaction, + createStreamingDisplayRedactionState, + truncateStreamingDisplayAppend, + truncateStreamingDisplayTail, + type StreamingDisplayRedactionState, +} from './streaming-display-redaction.js'; + +/** Caller-facing knobs, identical for both streams. */ +export interface ApplyStreamOptions { + /** Override per-delta cap. */ + maxDeltaChars?: number; + /** Override per-session total cap. */ + maxTotalChars?: number; + /** Differential-safe state returned by the preceding delta. */ + redactionState?: StreamingDisplayRedactionState; +} + +export interface ApplyStreamResult { + /** Resulting accumulated text (post-redaction, post-cap). */ + text: string; + /** True if redaction modified anything during this call. */ + redacted: boolean; + /** True if any per-delta or total truncation happened during this call. */ + truncated: boolean; + /** Bounded state needed to keep later prefixes oracle-equivalent. */ + redactionState?: StreamingDisplayRedactionState; +} + +/** Everything a concrete stream resolves before the shared pipeline runs. */ +export interface StreamDeltaSpec { + maxDeltaChars: number; + maxTotalChars: number; + /** Which end of an over-cap buffer survives. */ + recovery: 'head' | 'tail'; + /** Marker for a single oversize delta (always prepended). */ + chunkMarker: string; + /** Marker for the total cap: appended for 'head', prepended for 'tail'. */ + totalMarker: string; + redactionState?: StreamingDisplayRedactionState; +} + +/** The `complete` path replaces rather than appends, so it needs less. */ +export type StreamCompleteSpec = Pick< + StreamDeltaSpec, + 'maxTotalChars' | 'recovery' | 'totalMarker' +>; + +/** + * Apply a single delta to the prior accumulated text. Pure: no React state, + * no DOM, no IPC. + * + * Pipeline (in order): + * 1. Append through the differential-safe redactor. It caches complete + * lines and re-runs the whole-text oracle over only the mutable suffix. + * 2. If the delta alone is oversized, cap the already-redacted mutable + * suffix so a cross-delta secret cannot leak through truncation. + * 3. If the result exceeds `maxTotalChars`, cut the end `recovery` names. + * + * The carried state is opaque: the live projection stores only a WeakMap key + * and length counters, never the raw mutable suffix as enumerable React state. + */ +export function applyStreamDelta( + prev: string, + rawDelta: string, + spec: StreamDeltaSpec, +): ApplyStreamResult { + const { maxDeltaChars, maxTotalChars, recovery, chunkMarker, totalMarker } = spec; + const previousText = prev ?? ''; + + // Defensive guard: a non-string delta is a runtime contract violation. Drop + // it silently rather than coerce to '' and claim redaction happened. + if (typeof rawDelta !== 'string') { + return { + text: previousText, + redacted: false, + truncated: false, + ...(spec.redactionState === undefined + ? {} + : { redactionState: spec.redactionState }), + }; + } + + // Short-circuit: head-keep freezes the buffer once it is full, so a stream + // of subsequent deltas would redact and re-cap toward the same result. + // Tail-keep has no such fixed point — the window keeps sliding. + if ( + recovery === 'head' && + previousText.length >= maxTotalChars && + previousText.endsWith(totalMarker) + ) { + return { text: previousText, redacted: false, truncated: true }; + } + + const redactionState = spec.redactionState ?? appendStreamingDisplayRedaction( + '', + previousText, + createStreamingDisplayRedactionState({ + maxRecoveryChars: maxTotalChars + 1, + recovery, + }), + ).state; + + // Oversize deltas keep the established redact-before-truncate behavior. + // Normal deltas stay raw until the line-aware append below so a later prefix + // can legitimately make an opaque token visible again. + const redactedDelta = redactSecrets(rawDelta); + const perDeltaRedactionHappened = redactedDelta !== rawDelta; + + const rawAppended = appendStreamingDisplayRedaction( + previousText, + rawDelta, + redactionState, + ); + const appended = redactedDelta.length > maxDeltaChars + ? truncateStreamingDisplayAppend( + previousText, + rawAppended, + maxDeltaChars, + chunkMarker, + ) + : rawAppended; + const deltaTruncated = appended !== rawAppended; + + let result = appended.text; + let capped = appended; + let totalTruncated = false; + if (result.length > maxTotalChars) { + totalTruncated = true; + if (recovery === 'head') { + result = result.slice(0, maxTotalChars - totalMarker.length) + totalMarker; + } else { + capped = truncateStreamingDisplayTail(appended, maxTotalChars, totalMarker); + result = capped.text; + } + } + + return { + text: result, + redacted: perDeltaRedactionHappened || appended.redacted, + truncated: deltaTruncated || totalTruncated, + // A head-keep cut drops the mutable suffix the state describes, so there + // is nothing left to carry; every other path hands the state forward. + ...(recovery === 'head' && totalTruncated + ? {} + : { redactionState: capped.state }), + }; +} + +/** + * Apply a `complete` final payload. The complete event carries the FULL final + * text, so this is a replace path: redact and apply only the per-session total + * cap, not the per-delta cap used for incremental chunks. + */ +export function applyStreamComplete( + rawText: string, + spec: StreamCompleteSpec, +): ApplyStreamResult { + const { maxTotalChars, recovery, totalMarker } = spec; + + if (typeof rawText !== 'string') { + return { text: '', redacted: false, truncated: false }; + } + + const redacted = redactSecrets(rawText); + + let result = redacted; + let totalTruncated = false; + if (result.length > maxTotalChars) { + const keep = maxTotalChars - totalMarker.length; + result = recovery === 'head' + ? result.slice(0, keep) + totalMarker + : totalMarker + result.slice(result.length - keep); + totalTruncated = true; + } + + return { + text: result, + redacted: redacted !== rawText, + truncated: totalTruncated, + }; +} diff --git a/packages/ui/src/thinking-stream.ts b/packages/ui/src/thinking-stream.ts index 17ffe94d53..05cfcfbe78 100644 --- a/packages/ui/src/thinking-stream.ts +++ b/packages/ui/src/thinking-stream.ts @@ -18,9 +18,9 @@ */ /** - * PR-UI-C0 review fixup (@kenji msg 7885a347) — pure trust-boundary - * helper for the Anthropic extended-thinking stream the renderer - * accumulates from `ThinkingDeltaEvent` / `ThinkingCompleteEvent`. + * PR-UI-C0 review fixup (@kenji msg 7885a347) — the Anthropic + * extended-thinking stream the renderer accumulates from + * `ThinkingDeltaEvent` / `ThinkingCompleteEvent`. * * The original C0 implementation appended `event.text` directly * into the live-turn projection and rendered with @@ -33,31 +33,23 @@ * VISUAL height, not the DOM text length / React state / DevTools * snapshot. * - * This module mirrors the A3 `tool-output-stream` shape exactly: - * - pure helpers `applyThinkingDelta` / `applyThinkingComplete` - * - per-chunk cap (tail-keep with marker) - * - per-session total cap (tail-keep — thinking is sequential; - * oldest is least relevant for the user observing live - * reasoning) - * - secondary `redactSecrets` BEFORE state, with `redacted` - * monotonic (upstream claim survives; renderer can only - * escalate) + * The pipeline that answers both lives in `stream-delta`, shared with + * `assistant-stream`; everything below is thinking's own caps, markers, and + * recovery direction. * * The renderer stores both the accumulated text AND a per-session * monotonic `truncated` flag so the UI can show a "已截断" pill * in the `ReasoningPanel` header. */ -import { redactSecrets } from './redact.js'; -import { - appendStreamingDisplayRedaction, - createStreamingDisplayRedactionState, - truncateStreamingDisplayAppend, - truncateStreamingDisplayTail, - type StreamingDisplayRedactionState, -} from './streaming-display-redaction.js'; import type { UiLocale } from '@maka/core/ui-locale'; import { getSharedUiCopy } from './shared-ui-copy.js'; +import { + applyStreamComplete, + applyStreamDelta, + type ApplyStreamOptions, + type ApplyStreamResult, +} from './stream-delta.js'; /** * Default caps. Tuned to: @@ -71,148 +63,44 @@ import { getSharedUiCopy } from './shared-ui-copy.js'; export const THINKING_MAX_DELTA_CHARS = 4 * 1024; export const THINKING_MAX_TOTAL_CHARS = 32 * 1024; -export interface ApplyThinkingOptions { - /** Override per-delta cap. */ - maxDeltaChars?: number; - /** Override per-session total cap. */ - maxTotalChars?: number; +export interface ApplyThinkingOptions extends ApplyStreamOptions { /** Resolved UI locale for user-visible truncation markers. */ locale?: UiLocale; - /** Differential-safe state returned by the preceding delta. */ - redactionState?: StreamingDisplayRedactionState; } -export interface ApplyThinkingResult { - /** Resulting accumulated thinking text (post-redaction, post-cap). */ - text: string; - /** True if any redaction happened during this call. */ - redacted: boolean; - /** True if any drop / truncation happened during this call. */ - truncated: boolean; - /** Bounded state needed to keep later prefixes oracle-equivalent. */ - redactionState?: StreamingDisplayRedactionState; -} +export type ApplyThinkingResult = ApplyStreamResult; -/** - * Apply a single `thinking_delta` to the prior accumulated text. - * Pure: no React state, no DOM, no IPC. - * - * 1. Append through the differential-safe line/suffix redactor. - * 2. If the delta alone is oversized, cap the already-safe mutable suffix. - * 3. If the result exceeds `maxTotalChars`, tail-keep the most - * recent `maxTotalChars` characters with a head marker. - * Thinking is sequential reasoning; the user is looking at - * the CURRENT chain of thought, not the start. - */ +/** Apply a single `thinking_delta` to the prior accumulated text. */ export function applyThinkingDelta( prev: string, rawDelta: string, options: ApplyThinkingOptions = {}, ): ApplyThinkingResult { - const maxDelta = options.maxDeltaChars ?? THINKING_MAX_DELTA_CHARS; - const maxTotal = options.maxTotalChars ?? THINKING_MAX_TOTAL_CHARS; const copy = getSharedUiCopy(options.locale ?? 'zh').stream; - const truncatedHeadMarker = copy.thinkingHeadTruncated; - const truncatedChunkMarker = copy.thinkingChunkTruncated; - const previousText = prev ?? ''; - - // Defensive guard: a non-string `rawDelta` is a runtime contract - // violation. Drop it silently rather than coerce to '' and claim - // redaction happened. - if (typeof rawDelta !== 'string') { - return { - text: prev ?? '', - redacted: false, - truncated: false, - ...(options.redactionState === undefined - ? {} - : { redactionState: options.redactionState }), - }; - } - - const redactionState = options.redactionState ?? appendStreamingDisplayRedaction( - '', - previousText, - createStreamingDisplayRedactionState({ - maxRecoveryChars: maxTotal + 1, - recovery: 'tail', - }), - ).state; - - // Oversize deltas retain redact-before-truncate. Normal deltas remain raw - // until the line-aware append so every streamed prefix can match the oracle. - const redactedDelta = redactSecrets(rawDelta); - const redactionHappened = redactedDelta !== rawDelta; - - // L2: per-delta cap. Tail-keep with marker prepended. - let deltaTruncated = false; - const rawAppended = appendStreamingDisplayRedaction( - previousText, - rawDelta, - redactionState, - ); - const appended = redactedDelta.length > maxDelta - ? truncateStreamingDisplayAppend( - previousText, - rawAppended, - maxDelta, - truncatedChunkMarker, - ) - : rawAppended; - deltaTruncated = appended !== rawAppended; - - // L4: per-session total cap. Tail-keep most recent. - let result = appended.text; - let totalTruncated = false; - let capped = appended; - if (result.length > maxTotal) { - capped = truncateStreamingDisplayTail(appended, maxTotal, truncatedHeadMarker); - result = capped.text; - totalTruncated = true; - } - - return { - text: result, - redacted: redactionHappened || appended.redacted, - truncated: deltaTruncated || totalTruncated, - redactionState: capped.state, - }; + return applyStreamDelta(prev, rawDelta, { + maxDeltaChars: options.maxDeltaChars ?? THINKING_MAX_DELTA_CHARS, + maxTotalChars: options.maxTotalChars ?? THINKING_MAX_TOTAL_CHARS, + recovery: 'tail', + chunkMarker: copy.thinkingChunkTruncated, + totalMarker: copy.thinkingHeadTruncated, + ...(options.redactionState === undefined + ? {} + : { redactionState: options.redactionState }), + }); } /** * Apply a `thinking_complete` final payload. The provider's * `ThinkingCompleteEvent.text` is the FULL final thinking text * (not an incremental delta), so we replace rather than append. - * The same redaction + size cap rules apply. */ export function applyThinkingComplete( rawText: string, options: ApplyThinkingOptions = {}, ): ApplyThinkingResult { - const maxTotal = options.maxTotalChars ?? THINKING_MAX_TOTAL_CHARS; - const truncatedHeadMarker = getSharedUiCopy(options.locale ?? 'zh').stream.thinkingHeadTruncated; - - // Same defensive guard as `applyThinkingDelta`. - if (typeof rawText !== 'string') { - return { text: '', redacted: false, truncated: false }; - } - - // L1: secondary redaction. - const redacted = redactSecrets(rawText); - const redactionHappened = redacted !== rawText; - - // L2: total cap. Tail-keep most recent reasoning. - let result = redacted; - let totalTruncated = false; - if (result.length > maxTotal) { - const keep = maxTotal - truncatedHeadMarker.length; - result = truncatedHeadMarker + result.slice(result.length - keep); - totalTruncated = true; - } - - return { - text: result, - redacted: redactionHappened, - truncated: totalTruncated, - }; + return applyStreamComplete(rawText, { + maxTotalChars: options.maxTotalChars ?? THINKING_MAX_TOTAL_CHARS, + recovery: 'tail', + totalMarker: getSharedUiCopy(options.locale ?? 'zh').stream.thinkingHeadTruncated, + }); } From 7d4c928e839454b84d18d652ee9573a5dd8dc6f1 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 28 Aug 2026 23:13:18 +0800 Subject: [PATCH 4/5] refactor(desktop): collapse the live-content activation branches The first two render-phase branches reseeded `activation` with a byte-identical object literal, so they are one condition. The third branch clears `initialLiveContent` and stays as it is. The snapshot passed to `ChatView` also dropped its session guard: every branch reseeds `sessionId` to `activeSessionId`, and a render-phase setState re-runs the component body before anything commits, so the mismatched arm could never reach the DOM. --- .../src/renderer/chat-message-surface.tsx | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/renderer/chat-message-surface.tsx b/apps/desktop/src/renderer/chat-message-surface.tsx index 6ce3ac5611..5fee131113 100644 --- a/apps/desktop/src/renderer/chat-message-surface.tsx +++ b/apps/desktop/src/renderer/chat-message-surface.tsx @@ -166,13 +166,10 @@ export function ChatMessageSurface({ seedRevision: liveContentSeedRevision, initialLiveContent: liveContentSeedRevision > 0 ? captureLiveContent(liveTurn) : undefined, })); - if (activation.sessionId !== activeSessionId) { - setActivation({ - sessionId: activeSessionId, - seedRevision: liveContentSeedRevision, - initialLiveContent: liveContentSeedRevision > 0 ? captureLiveContent(liveTurn) : undefined, - }); - } else if (activation.seedRevision !== liveContentSeedRevision) { + if ( + activation.sessionId !== activeSessionId + || activation.seedRevision !== liveContentSeedRevision + ) { setActivation({ sessionId: activeSessionId, seedRevision: liveContentSeedRevision, @@ -240,9 +237,10 @@ export function ChatMessageSurface({ 0 ? captureLiveContent(liveTurn) : undefined} + // Every branch above reseeds `sessionId` to `activeSessionId`, and a + // render-phase setState re-runs this body before anything commits, so + // the activation reaching the DOM is always this session's. + initialLiveContentSnapshot={activation.initialLiveContent} shellRunUpdates={shellRunUpdates} deepResearchRun={deepResearchRun} emptyOverride={emptyOverride} From b13019d34e5776a9b3097c52a612694aaa60abd3 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 28 Aug 2026 23:13:18 +0800 Subject: [PATCH 5/5] perf(ui): write roving-row tabindex only when it changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The layout effect deliberately runs on every render, and it wrote `tabIndex` on every row each time. An identical assignment still invalidates style and forces synchronous layout — DevTools charged the loop 224ms of reflow across the module pages that use it. Compare before assigning. A freshly mounted row still gets its tabindex, because it arrives at the default 0. --- packages/ui/src/use-roving-row-focus.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/use-roving-row-focus.ts b/packages/ui/src/use-roving-row-focus.ts index 867f8d9b12..3d3e4c6464 100644 --- a/packages/ui/src/use-roving-row-focus.ts +++ b/packages/ui/src/use-roving-row-focus.ts @@ -74,7 +74,15 @@ export function useRovingRowFocus(containerRef: RefObject): // Clamp rather than reset: when the active row is deleted, the row that // took its place is the one that should still hold the list's tab stop. const active = Math.min(activeIndex, rows.length - 1); - for (const [index, row] of rows.entries()) row.tabIndex = index === active ? 0 : -1; + // Assign only on a change. Writing `tabIndex` invalidates style even when + // the value is identical, and this effect runs on every render of a list + // that is usually untouched — the unconditional write was charged 224ms of + // forced reflow. A fresh row still gets its tabindex: it arrives at the + // default 0, which differs from -1 for every row but the active one. + for (const [index, row] of rows.entries()) { + const desired = index === active ? 0 : -1; + if (row.tabIndex !== desired) row.tabIndex = desired; + } }); const focusRow = useCallback(