Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 8 additions & 10 deletions apps/desktop/src/renderer/chat-message-surface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -240,9 +237,10 @@ export function ChatMessageSurface({
<ChatView
{...chatViewRest}
liveTurn={seededLiveTurn}
initialLiveContentSnapshot={activation.sessionId === activeSessionId
? activation.initialLiveContent
: liveContentSeedRevision > 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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
6 changes: 5 additions & 1 deletion apps/desktop/src/renderer/features/workbar/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `testing`,
// 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';
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/renderer/features/workbar/testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ export * from './tools/terminal/session-terminal-frame.js';
export * from './tools/inspector/use-session-trace.js';
export * from './controller/use-workbar-controller.js';
export { SideChatCloseConfirmation } from './ui/side-chat-close-confirmation.js';
// The production entry deliberately omits `WorkbarSurface`: `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, so the boundary holds here.
export { WorkbarSurface } from './ui/workbar-surface.js';

const noopSubscription = (): (() => void) => () => undefined;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,14 @@ 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,
createSessionWorkbarTabsState,
openStaticSessionWorkbarTab,
terminalSessionWorkbarTabId,
WorkbarServicesProvider,
WorkbarSurface,
type WorkbarServices,
} from '../src/renderer/features/workbar/testing';
import { RemoteProjectDirectoryDialog } from '../src/renderer/remote-project-directory-dialog';
Expand Down
6 changes: 2 additions & 4 deletions apps/desktop/stories/session-workbar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,10 @@ 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 {
createFakeWorkbarServices,
WorkbarSurface,
createSessionWorkbarPanelsState,
createSessionWorkbarTabsState,
openStaticSessionWorkbarTab,
Expand Down
221 changes: 31 additions & 190 deletions packages/ui/src/assistant-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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<ApplyAssistantOptions, 'maxTotalChars' | 'locale'> = {},
): 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,
});
}
Loading
Loading