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/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} 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/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/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, 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, 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, + }); } 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(