From 0877e0226b395dc7ad24d0490e94aae3ff1530fc Mon Sep 17 00:00:00 2001 From: NekoPunch Date: Thu, 3 Sep 2026 01:55:53 -0700 Subject: [PATCH] fix(i18n): require an explicit locale on presentation helpers A locale default let any caller that forgot the argument render the wrong language without a type error: 'zh' defaults showed Chinese to English users, 'en' defaults the reverse. Generated-by: Claude Code --- apps/desktop/renderer-architecture.json | 2 +- .../app-shell-command-actions.test.ts | 6 ++-- .../__tests__/attachment-preflight.test.ts | 18 +++++++--- .../__tests__/conversation-markdown.test.ts | 3 +- .../main/__tests__/interrupted-resume.test.ts | 2 +- .../__tests__/model-catalog-choices.test.ts | 6 ++++ .../session-error-presentation.test.ts | 8 +++-- .../renderer/app-shell-context-compaction.ts | 2 +- .../src/renderer/attachment-preflight.ts | 2 +- .../src/renderer/conversation-markdown.ts | 2 +- .../renderer/derive-turn-lineage-badges.ts | 4 +-- .../provider-panel-shared.ts | 8 ++--- .../model/session-project-grouping.ts | 2 +- .../src/renderer/model-catalog-choices.ts | 2 +- .../src/renderer/model-connection-errors.ts | 6 ++-- .../renderer/session-error-presentation.ts | 2 +- .../renderer/session-status-presentation.ts | 4 +-- .../src/renderer/settings/bot-chat-shared.tsx | 4 +-- .../settings/provider-connection-status.ts | 2 +- .../renderer/settings/settings-error-copy.ts | 2 +- .../src/renderer/turn-footer-actions.ts | 4 +-- packages/core/src/relative-time.ts | 20 +++-------- packages/core/src/tool-quiet-preview.ts | 8 ++--- .../__tests__/live-turn-projection.test.ts | 2 +- packages/ui/src/__tests__/materialize.test.ts | 34 +++++++++--------- .../__tests__/shell-run-projection.test.ts | 16 ++++----- .../__tests__/transcript-projection.test.ts | 35 ++++++++++++------- packages/ui/src/artifact-preview-registry.ts | 2 +- packages/ui/src/chat-model-helpers.ts | 4 +-- packages/ui/src/materialize.ts | 4 +-- .../ui/src/session-status-presentation.ts | 4 +-- packages/ui/src/transcript-projection.ts | 2 +- packages/ui/stories/model-picker.stories.tsx | 2 +- 33 files changed, 120 insertions(+), 104 deletions(-) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index bd18f206df..4cb963918a 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -427,7 +427,7 @@ "@maka/runtime-host/protocol": 1 }, "importSpecifiers": 4, - "nonTriviaTokens": 612 + "nonTriviaTokens": 610 }, "src/renderer/app-shell-copy.ts": { "importDeclarations": 5, diff --git a/apps/desktop/src/main/__tests__/app-shell-command-actions.test.ts b/apps/desktop/src/main/__tests__/app-shell-command-actions.test.ts index d5b95ee74f..0fe95e1419 100644 --- a/apps/desktop/src/main/__tests__/app-shell-command-actions.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-command-actions.test.ts @@ -70,17 +70,17 @@ test('targets manual diagnostics to the current task or new-task Host profile', }); test('presents every successful-frame context compaction outcome', () => { - assert.deepEqual(contextCompactionNotice({ kind: 'compacted', checkpointId: 'checkpoint-1' }), { + assert.deepEqual(contextCompactionNotice({ kind: 'compacted', checkpointId: 'checkpoint-1' }, 'en'), { level: 'success', title: 'Context compacted', description: 'Older context was replaced with a checkpoint summary.', }); - assert.deepEqual(contextCompactionNotice({ kind: 'unchanged', reason: 'already_compacted' }), { + assert.deepEqual(contextCompactionNotice({ kind: 'unchanged', reason: 'already_compacted' }, 'en'), { level: 'info', title: 'Nothing to compact', description: 'The task already uses the latest checkpoint.', }); - assert.deepEqual(contextCompactionNotice({ kind: 'failed', reason: 'write_failed' }), { + assert.deepEqual(contextCompactionNotice({ kind: 'failed', reason: 'write_failed' }, 'en'), { level: 'error', title: 'Compaction failed', description: 'The task could not be compacted. Try again later.', diff --git a/apps/desktop/src/main/__tests__/attachment-preflight.test.ts b/apps/desktop/src/main/__tests__/attachment-preflight.test.ts index d38c2c008c..f7b7aee817 100644 --- a/apps/desktop/src/main/__tests__/attachment-preflight.test.ts +++ b/apps/desktop/src/main/__tests__/attachment-preflight.test.ts @@ -29,19 +29,19 @@ describe('attachment preflight (before session create)', () => { size: 100, source: { type: 'file' as const, file: { size: 100 } }, })); - assert.throws(() => preflightAttachmentItems(items), /8/); + assert.throws(() => preflightAttachmentItems(items, 'zh'), /8/); }); test('rejects an oversized File so no empty session is created', () => { assert.throws( - () => preflightAttachmentItems([{ size: CAP + 1, source: { type: 'file', file: { size: CAP + 1 } } }]), + () => preflightAttachmentItems([{ size: CAP + 1, source: { type: 'file', file: { size: CAP + 1 } } }], 'zh'), /50MB/, ); }); test('rejects an oversized approval-token attachment by pending size', () => { assert.throws( - () => preflightAttachmentItems([{ size: CAP + 1, source: { type: 'approval', approvalId: 'a1' } }]), + () => preflightAttachmentItems([{ size: CAP + 1, source: { type: 'approval', approvalId: 'a1' } }], 'zh'), /50MB/, ); }); @@ -52,9 +52,17 @@ describe('attachment preflight (before session create)', () => { preflightAttachmentItems([ { size: 10, source: { type: 'approval', approvalId: 'dup' } }, { size: 10, source: { type: 'approval', approvalId: 'dup' } }, - ]), + ], 'zh'), /重复/, ); + assert.throws( + () => + preflightAttachmentItems([ + { size: 10, source: { type: 'approval', approvalId: 'dup' } }, + { size: 10, source: { type: 'approval', approvalId: 'dup' } }, + ], 'en'), + /already added/, + ); }); test('passes approval tokens and files under the cap', () => { @@ -62,7 +70,7 @@ describe('attachment preflight (before session create)', () => { preflightAttachmentItems([ { size: 100, source: { type: 'approval', approvalId: 'a1' } }, { size: 100, source: { type: 'file', file: { size: 100 } } }, - ]), + ], 'zh'), ); }); }); \ No newline at end of file diff --git a/apps/desktop/src/main/__tests__/conversation-markdown.test.ts b/apps/desktop/src/main/__tests__/conversation-markdown.test.ts index cd8cafacec..7d56f293d2 100644 --- a/apps/desktop/src/main/__tests__/conversation-markdown.test.ts +++ b/apps/desktop/src/main/__tests__/conversation-markdown.test.ts @@ -58,10 +58,11 @@ describe('renderConversationMarkdown', () => { modelId: 'fake', }, ]; - const md = renderConversationMarkdown('skill session', messages); + const md = renderConversationMarkdown('skill session', messages, 'zh'); assert.match(md, /## 你/); assert.ok(md.includes(typed), 'export shows the typed prompt'); assert.ok(!md.includes(' { content: { kind: 'text', text: 'ok' }, }, { type: 'tool_call', id: 'call-2', turnId: 'turn-1', ts: 5, toolName: 'Read', args: {} }, - ]); + ], 'en'); assert.deepEqual(turn?.tools.map((tool) => tool.status), ['completed', 'interrupted']); assert.equal(latestInterruptedResumeTurnId(turn ? [turn] : []), undefined); diff --git a/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts b/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts index 382d26e64f..b962bebaad 100644 --- a/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts +++ b/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts @@ -186,6 +186,7 @@ describe('model catalog picker helpers', () => { }), ], '', + 'zh', ); const keys = options.map(([key]) => key); assert.ok( @@ -198,4 +199,9 @@ describe('model catalog picker helpers', () => { `unsupported Codex model was offered: ${JSON.stringify(keys)}`, ); }); + + it('labels a saved-but-unavailable selection in the UI locale', () => { + const [, label] = buildCatalogDailyReviewModelOptions([], 'codex::gone', 'en').at(-1)!; + assert.equal(label, 'gone · codex · Currently unavailable'); + }); }); diff --git a/apps/desktop/src/main/__tests__/session-error-presentation.test.ts b/apps/desktop/src/main/__tests__/session-error-presentation.test.ts index 750d1d3cda..652622d42d 100644 --- a/apps/desktop/src/main/__tests__/session-error-presentation.test.ts +++ b/apps/desktop/src/main/__tests__/session-error-presentation.test.ts @@ -26,12 +26,14 @@ import { describeTurnErrorClass } from '../../renderer/session-status-presentati describe('provider capacity presentation', () => { it('uses capacity-specific copy instead of the unknown error fallback', () => { - assert.match(describeSessionErrorReason('provider_capacity') ?? '', /满载/); - assert.match(describeTurnErrorClass('provider_capacity'), /满载/); + assert.match(describeSessionErrorReason('provider_capacity', 'zh') ?? '', /满载/); + assert.match(describeSessionErrorReason('provider_capacity', 'en') ?? '', /at capacity/); + assert.match(describeTurnErrorClass('provider_capacity', 'zh'), /满载/); + assert.match(describeTurnErrorClass('provider_capacity', 'en'), /at capacity/); }); it('does not recommend an immediate direct retry', () => { - const label = describeTurnErrorClass('provider_capacity'); + const label = describeTurnErrorClass('provider_capacity', 'zh'); assert.match(label, /等几分钟|换一个模型/); assert.doesNotMatch(label, /直接重试/); }); diff --git a/apps/desktop/src/renderer/app-shell-context-compaction.ts b/apps/desktop/src/renderer/app-shell-context-compaction.ts index ebe5c3ea06..36c1a1ec92 100644 --- a/apps/desktop/src/renderer/app-shell-context-compaction.ts +++ b/apps/desktop/src/renderer/app-shell-context-compaction.ts @@ -32,7 +32,7 @@ export interface ContextCompactionNotice { export function contextCompactionNotice( outcome: ContextCompactionOutcome, - uiLocale: UiLocale = 'en', + uiLocale: UiLocale, ): ContextCompactionNotice { const copy = getShellCopy(uiLocale).app; if (outcome.kind === 'compacted') { diff --git a/apps/desktop/src/renderer/attachment-preflight.ts b/apps/desktop/src/renderer/attachment-preflight.ts index 0af114c7a9..33891c2fdc 100644 --- a/apps/desktop/src/renderer/attachment-preflight.ts +++ b/apps/desktop/src/renderer/attachment-preflight.ts @@ -38,7 +38,7 @@ type PreflightItem = { * File blobs are sized by the browser File object; approval-token attachments * are sized by the pending size stamped at pick time (main re-stats). */ -export function preflightAttachmentItems(items: readonly PreflightItem[], locale: UiLocale = 'zh'): void { +export function preflightAttachmentItems(items: readonly PreflightItem[], locale: UiLocale): void { const copy = getDesktopConversationCopy(locale).attachments; if (items.length > MAX_ATTACHMENT_COUNT) throw new Error(copy.tooMany); const seen = new Set(); diff --git a/apps/desktop/src/renderer/conversation-markdown.ts b/apps/desktop/src/renderer/conversation-markdown.ts index 526f41d701..d89d291fd7 100644 --- a/apps/desktop/src/renderer/conversation-markdown.ts +++ b/apps/desktop/src/renderer/conversation-markdown.ts @@ -43,7 +43,7 @@ import { getShellRemainingCopy } from './locales/shell-remaining-copy.js'; * export that the user is going to paste somewhere public. * - **user text** left untouched (the user typed it, they own it). */ -export function renderConversationMarkdown(sessionName: string, messages: StoredMessage[], locale: UiLocale = 'zh'): string { +export function renderConversationMarkdown(sessionName: string, messages: StoredMessage[], locale: UiLocale): string { const copy = getShellRemainingCopy(locale).conversationExport; const lines: string[] = []; lines.push(`# ${sessionName}`); diff --git a/apps/desktop/src/renderer/derive-turn-lineage-badges.ts b/apps/desktop/src/renderer/derive-turn-lineage-badges.ts index f6aa1d8373..c90ab67484 100644 --- a/apps/desktop/src/renderer/derive-turn-lineage-badges.ts +++ b/apps/desktop/src/renderer/derive-turn-lineage-badges.ts @@ -46,11 +46,11 @@ export interface TurnLineageBadgeInput { regeneratedToTurnId?: string; /** True when the target turn id still exists in the materialized view. */ existsTurn(turnId: string): boolean; - locale?: UiLocale; + locale: UiLocale; } export function deriveTurnLineageBadges(input: TurnLineageBadgeInput): TurnLineageBadge[] { - const copy = getDesktopConversationCopy(input.locale ?? 'zh').lineage; + const copy = getDesktopConversationCopy(input.locale).lineage; const badges: TurnLineageBadge[] = []; const forwardFrom = input.regeneratedFromTurnId ?? input.retriedFromTurnId; diff --git a/apps/desktop/src/renderer/features/connection-settings/provider-panel-shared.ts b/apps/desktop/src/renderer/features/connection-settings/provider-panel-shared.ts index 0a470b680d..6ab858ef06 100644 --- a/apps/desktop/src/renderer/features/connection-settings/provider-panel-shared.ts +++ b/apps/desktop/src/renderer/features/connection-settings/provider-panel-shared.ts @@ -25,7 +25,7 @@ import { cleanErrorMessage } from '../../application/contracts/connection-error- export type CredentialPresenceStatus = boolean | 'loading' | 'error'; -export function providerPanelActionErrorMessage(error: unknown, locale: UiLocale = 'zh'): string { +export function providerPanelActionErrorMessage(error: unknown, locale: UiLocale): string { const shared = getProviderSettingsCopy(locale).shared; // Electron wraps ipcMain.handle rejections as "Error invoking remote method // '': Error: ". Classify the original message, not the @@ -61,7 +61,7 @@ export interface ConnectionTestTroubleshootingCopy { export function connectionTestFailureFallback( result: ConnectionTestResult, copy: ConnectionTestTroubleshootingCopy, - locale: UiLocale = 'zh', + locale: UiLocale, ): string { const shared = getProviderSettingsCopy(locale).shared; if (result.statusCode === 429) return shared.rateLimit; @@ -79,7 +79,7 @@ export function connectionTestFailureFallback( export function connectionTestFailureMessage( result: ConnectionTestResult, copy: ConnectionTestTroubleshootingCopy, - locale: UiLocale = 'zh', + locale: UiLocale, ): string { const fallback = connectionTestFailureFallback(result, copy, locale); if (!result.errorMessage) return fallback; @@ -88,7 +88,7 @@ export function connectionTestFailureMessage( : generalizedErrorMessage(new Error(result.errorMessage), fallback); } -export function connectionLastTestMessageDisplay(message: string | undefined, locale: UiLocale = 'zh'): string | undefined { +export function connectionLastTestMessageDisplay(message: string | undefined, locale: UiLocale): string | undefined { if (!message) return undefined; const trimmed = message.trim(); if (!trimmed) return undefined; diff --git a/apps/desktop/src/renderer/features/session-navigation/model/session-project-grouping.ts b/apps/desktop/src/renderer/features/session-navigation/model/session-project-grouping.ts index 8fa85cf5a6..146d1bcd4f 100644 --- a/apps/desktop/src/renderer/features/session-navigation/model/session-project-grouping.ts +++ b/apps/desktop/src/renderer/features/session-navigation/model/session-project-grouping.ts @@ -28,7 +28,7 @@ const UNGROUPED_KEY = '__ungrouped__'; export function deriveProjectGroups( sessions: ReadonlyArray, projects: ReadonlyArray, - locale: UiLocale = 'zh', + locale: UiLocale, ): SessionHistoryGroup[] { const sessionsByProject = new Map(); const canonicalProjectIds = new Map(); diff --git a/apps/desktop/src/renderer/model-catalog-choices.ts b/apps/desktop/src/renderer/model-catalog-choices.ts index d1d8b09a46..aafd68e3b0 100644 --- a/apps/desktop/src/renderer/model-catalog-choices.ts +++ b/apps/desktop/src/renderer/model-catalog-choices.ts @@ -48,7 +48,7 @@ export function buildCatalogRecommendedDefaultModel(providerType: ProviderType): export function buildCatalogDailyReviewModelOptions( connections: readonly (LlmConnection & HostResolvedConnectionCatalog)[], currentModelKey: string, - locale: UiLocale = 'zh', + locale: UiLocale, ): Array { const current = parseDailyReviewModelKey(currentModelKey); const candidates: Array<{ key: string; label: string; safeSourceLabel: string }> = []; diff --git a/apps/desktop/src/renderer/model-connection-errors.ts b/apps/desktop/src/renderer/model-connection-errors.ts index fe3fdf5568..d399fcaa41 100644 --- a/apps/desktop/src/renderer/model-connection-errors.ts +++ b/apps/desktop/src/renderer/model-connection-errors.ts @@ -46,7 +46,7 @@ export function noRealConnectionReasonFromEvent(event: Extract, - locale: UiLocale = 'zh', + locale: UiLocale, ): string { if (isNoRealConnectionEvent(event)) { return noRealConnectionSetupDescription(noRealConnectionReasonFromEvent(event), locale); @@ -69,7 +69,7 @@ export function sessionEventErrorMessage( export function modelSetupToastCopy( reason: string | undefined, fallback: string, - locale: UiLocale = 'zh', + locale: UiLocale, ): { title: string; description: string } { const copy = getDesktopConversationCopy(locale).model; if (reason === 'connection_missing') { diff --git a/apps/desktop/src/renderer/session-error-presentation.ts b/apps/desktop/src/renderer/session-error-presentation.ts index f3bccf1e7b..b1c5fa85a2 100644 --- a/apps/desktop/src/renderer/session-error-presentation.ts +++ b/apps/desktop/src/renderer/session-error-presentation.ts @@ -25,7 +25,7 @@ import { getDesktopConversationCopy } from './locales/conversation-copy.js'; * runtime. Unknown reasons intentionally return undefined so callers can use * their existing safe fallback instead of displaying raw provider text. */ -export function describeSessionErrorReason(reason: string | undefined, locale: UiLocale = 'zh'): string | undefined { +export function describeSessionErrorReason(reason: string | undefined, locale: UiLocale): string | undefined { const copy = getDesktopConversationCopy(locale).turnError; switch (reason?.toLowerCase()) { case 'context_overflow': diff --git a/apps/desktop/src/renderer/session-status-presentation.ts b/apps/desktop/src/renderer/session-status-presentation.ts index c039d73e30..36b7993054 100644 --- a/apps/desktop/src/renderer/session-status-presentation.ts +++ b/apps/desktop/src/renderer/session-status-presentation.ts @@ -97,7 +97,7 @@ export function normalizeSessionSummaryForDisplay(sess * the UI; they just fall through to the catch-all until the mapping * is extended. */ -export function describeTurnErrorClass(errorClass: string | undefined, locale: UiLocale = 'zh'): string { +export function describeTurnErrorClass(errorClass: string | undefined, locale: UiLocale): string { const copy = getDesktopConversationCopy(locale).turnError; if (!errorClass) return copy.unknown; const reasonDescription = describeSessionErrorReason(errorClass, locale); @@ -169,7 +169,7 @@ export interface FailedTurnExecutionState { */ export function describeFailedTurnExecutionState( state: FailedTurnExecutionState, - locale: UiLocale = 'zh', + locale: UiLocale, ): string | undefined { const copy = getDesktopConversationCopy(locale).turnError.executionState; if (state.erroredToolCount > 0) return copy.erroredTool; diff --git a/apps/desktop/src/renderer/settings/bot-chat-shared.tsx b/apps/desktop/src/renderer/settings/bot-chat-shared.tsx index 101d069aa7..5ec2b5dee9 100644 --- a/apps/desktop/src/renderer/settings/bot-chat-shared.tsx +++ b/apps/desktop/src/renderer/settings/bot-chat-shared.tsx @@ -60,7 +60,7 @@ export const BOT_LABELS: Record; - locale?: UiLocale; + locale: UiLocale; } /** @@ -113,7 +113,7 @@ export interface TurnFooterContext { */ export function deriveTurnFooterActions(input: TurnFooterContext): TurnFooterAction[] { const { status, hasContent, alreadyRegenerated, pendingActions, metaSummary } = input; - const copyText = getDesktopConversationCopy(input.locale ?? 'zh').footer; + const copyText = getDesktopConversationCopy(input.locale).footer; const actionLabel = copyText.labels; const isPending = (id: TurnFooterActionId) => pendingActions?.has(id) ?? false; const PENDING_TOOLTIP = copyText.pending; diff --git a/packages/core/src/relative-time.ts b/packages/core/src/relative-time.ts index 5ce6f475bd..846ae650cd 100644 --- a/packages/core/src/relative-time.ts +++ b/packages/core/src/relative-time.ts @@ -93,7 +93,7 @@ function getAbsoluteFormat(uiLocale: UiLocale): Intl.DateTimeFormat { * reading a relative label falls back to and a tooltip shows; `@maka/ui` had * its own uncached copy of the same `Intl` options until this became public. */ -export function formatAbsoluteTimestamp(ts: number, locale: UiLocale = 'zh'): string { +export function formatAbsoluteTimestamp(ts: number, locale: UiLocale): string { return getAbsoluteFormat(locale).format(new Date(ts)); } @@ -102,11 +102,7 @@ export function formatAbsoluteTimestamp(ts: number, locale: UiLocale = 'zh'): st * absolute date string. `now` is injectable so tests pin a deterministic clock; * future timestamps (clock skew) snap to the just-now label. */ -export function formatRelativeTimestamp( - ts: number, - now: number = Date.now(), - locale: UiLocale = 'zh', -): string { +export function formatRelativeTimestamp(ts: number, now: number, locale: UiLocale): string { const diffMs = relativeAgeMs(ts, now); if (diffMs < JUST_NOW_MS) { return JUST_NOW[locale]; @@ -155,11 +151,7 @@ function getCompactFormats(uiLocale: UiLocale): { * Compact variant for wider list rows: relative inside the seven-day horizon, * then a localized date-only label. */ -export function formatCompactTimestamp( - ts: number, - now: number = Date.now(), - locale: UiLocale = 'zh', -): string { +export function formatCompactTimestamp(ts: number, now: number, locale: UiLocale): string { const diffMs = relativeAgeMs(ts, now); if (diffMs <= RELATIVE_HORIZON_MS) return formatRelativeTimestamp(ts, now, locale); const { sameYear, otherYear } = getCompactFormats(locale); @@ -175,11 +167,7 @@ export function formatCompactTimestamp( * Unit tokens stay deliberately locale-neutral so the trailing column remains * stable across UI languages: "46min", "13h", "17d", "1mo", "1y". */ -export function formatSidebarTimestamp( - ts: number, - now: number = Date.now(), - locale: UiLocale = 'zh', -): string { +export function formatSidebarTimestamp(ts: number, now: number, locale: UiLocale): string { const diffMs = relativeAgeMs(ts, now); if (diffMs < JUST_NOW_MS) return JUST_NOW[locale]; const bucket = sidebarTimeBucket(diffMs); diff --git a/packages/core/src/tool-quiet-preview.ts b/packages/core/src/tool-quiet-preview.ts index e2adb0d7b6..b7677064b4 100644 --- a/packages/core/src/tool-quiet-preview.ts +++ b/packages/core/src/tool-quiet-preview.ts @@ -230,7 +230,7 @@ export interface ToolInvocationInput { */ export function formatToolInvocationLine( item: ToolInvocationInput, - locale: UiLocale = 'zh', + locale: UiLocale, ): string | undefined { const s = strings(locale); const args = asRecord(item.args); @@ -518,7 +518,7 @@ export interface QuietPreview { * Primary list/text fields become the main body; remaining fields (error, ok, * truncated, …) are appended so diagnostics cannot vanish. */ -export function formatQuietJsonValue(value: unknown, locale: UiLocale = 'zh'): QuietPreview { +export function formatQuietJsonValue(value: unknown, locale: UiLocale): QuietPreview { const s = strings(locale); if (value === null || value === undefined) { return { body: s.empty }; @@ -676,8 +676,8 @@ function formatArrayAsBody(values: unknown[], locale: UiLocale): string { */ export function formatAsKeyValueLines( record: Record, - depth = 0, - locale: UiLocale = 'zh', + depth: number, + locale: UiLocale, ): string { const s = strings(locale); if (depth > 3) return redactSecrets(String(record)); diff --git a/packages/ui/src/__tests__/live-turn-projection.test.ts b/packages/ui/src/__tests__/live-turn-projection.test.ts index 63d788c85f..94b80d3132 100644 --- a/packages/ui/src/__tests__/live-turn-projection.test.ts +++ b/packages/ui/src/__tests__/live-turn-projection.test.ts @@ -1026,7 +1026,7 @@ describe('tool_result_preview live projection', () => { type: 'turn_state', id: 'state-1', turnId: 'turn-1', ts: 3, status: 'running', partialOutputRetained: true, }, - ]); + ], 'en'); const started = applyLiveTurnEvent(undefined, { type: 'tool_start', id: 'start-1', turnId: 'turn-1', stepId: 'step-1', toolUseId: 'tool-1', toolName: 'Read', args: { path: 'README.md' }, ts: 4, diff --git a/packages/ui/src/__tests__/materialize.test.ts b/packages/ui/src/__tests__/materialize.test.ts index 179be9ea18..b3d2d2d2ad 100644 --- a/packages/ui/src/__tests__/materialize.test.ts +++ b/packages/ui/src/__tests__/materialize.test.ts @@ -75,7 +75,7 @@ describe("steering timeline", () => { text: "after", modelId: "fixture", }, - ]); + ], "en"); assert.deepEqual(timelineText(turn), [ "text:before", @@ -85,7 +85,7 @@ describe("steering timeline", () => { }); test("renders one live steering message while its persisted row catches up", () => { - const settled = materializeTurns([originalUser]); + const settled = materializeTurns([originalUser], "en"); const before = applyLiveTurnEvent(armLiveTurn("t1"), { type: "text_complete", id: "event-before", @@ -110,7 +110,7 @@ describe("steering timeline", () => { originalUser, beforeAssistant, { type: "user", id: "steer-1", turnId: "t1", ts: 2, text: "inserted instruction" }, - ]); + ], "en"); const [deduplicated] = overlayLiveTurn(persisted, live); assert.deepEqual(timelineText(deduplicated), ["text:before", "user:inserted instruction"]); }); @@ -126,7 +126,7 @@ describe("steering timeline", () => { text: "inserted instruction", steeringEventId: "event-steer", }, - ]); + ], "en"); const live = applyLiveTurnEvent(armLiveTurn("t1"), { type: "text_delta", id: "event-before", @@ -154,7 +154,7 @@ describe("steering timeline", () => { args: {}, }, steeringUser, - ]); + ], "en"); const tool = applyLiveTurnEvent(armLiveTurn("t1"), { type: "tool_start", id: "tool-event", @@ -229,8 +229,8 @@ describe("materializeChat message metadata", () => { inlineReferences: [], }, ]; - assert.deepEqual(materializeChat(messages)[0]?.inlineReferences, []); - assert.deepEqual(materializeTurns(messages)[0]?.user?.inlineReferences, []); + assert.deepEqual(materializeChat(messages, "en")[0]?.inlineReferences, []); + assert.deepEqual(materializeTurns(messages, "en")[0]?.user?.inlineReferences, []); }); test("preserves Host provenance on a Goal continuation", () => { @@ -245,11 +245,11 @@ describe("materializeChat message metadata", () => { }, ]; - assert.deepEqual(materializeChat(messages)[0]?.hostOrigin, { + assert.deepEqual(materializeChat(messages, "en")[0]?.hostOrigin, { kind: "goal", goalId: "goal-1", }); - assert.deepEqual(materializeTurns(messages)[0]?.user?.hostOrigin, { + assert.deepEqual(materializeTurns(messages, "en")[0]?.user?.hostOrigin, { kind: "goal", goalId: "goal-1", }); @@ -345,7 +345,7 @@ describe("flat timeline under tool projection (#1307 P1 regression)", () => { content: shellRunResult(1), }, userMsg("t2", 3, "q"), - ]); + ], "en"); const turns = overlayLiveTurn(settled, { turnId: "t2", phase: "streamed", @@ -404,7 +404,7 @@ describe("live content over persisted partial rows", () => { thinking: { text: "persisted partial" }, contentOrder: ["thinking"], }, - ]); + ], "en"); const turns = overlayLiveTurn(settled, { turnId: "t1", phase: "streamed", @@ -458,7 +458,7 @@ describe("unfinished tools take their status from the turn", () => { toolName: "Bash", args: { command: "sleep 600" }, }, - ]); + ], "en"); assert.equal(turn?.status, "running"); assert.equal(turn?.tools[0]?.status, "running"); }); @@ -482,7 +482,7 @@ describe("unfinished tools take their status from the turn", () => { toolName: "Bash", args: { command: "sleep 600" }, }, - ]); + ], "en"); assert.equal(turn?.tools[0]?.status, "interrupted"); }); }); @@ -509,7 +509,7 @@ describe("live tool status over persisted", () => { toolName: "Bash", args: { command: "sleep 60" }, }, - ]); + ], "en"); const turns = overlayLiveTurn(settled, { turnId: "t1", phase: "streamed", @@ -556,7 +556,7 @@ describe("live tool status over persisted", () => { toolName: "Bash", args: { command: "sleep 60" }, }, - ]); + ], "en"); const turns = overlayLiveTurn(settled, { turnId: "t1", phase: "streamed", @@ -620,7 +620,7 @@ describe("live tool status over persisted", () => { isError: false, content: { kind: "text", text: "unsupported_action" }, }, - ]); + ], "en"); const live = applyLiveTurnEvent(undefined, { type: "tool_start", @@ -671,7 +671,7 @@ describe("live tool status over persisted", () => { toolName: "Bash", args: { command: "sleep 60" }, }, - ]); + ], "en"); const started = applyLiveTurnEvent(armLiveTurn("t1"), { type: "tool_start", id: "event-1", diff --git a/packages/ui/src/__tests__/shell-run-projection.test.ts b/packages/ui/src/__tests__/shell-run-projection.test.ts index 04e6768cfb..eb3aad0b5e 100644 --- a/packages/ui/src/__tests__/shell-run-projection.test.ts +++ b/packages/ui/src/__tests__/shell-run-projection.test.ts @@ -53,7 +53,7 @@ describe('ShellRun UI projection', () => { }), 4), ]; - const turns = materializeTurns(messages); + const turns = materializeTurns(messages, 'en'); const bash = turns[0]?.tools[0]; const write = turns[1]?.tools[0]; assert.equal(bash?.toolName, 'Bash'); @@ -86,7 +86,7 @@ describe('ShellRun UI projection', () => { { type: 'user', id: 'user-2', turnId: 'turn-2', ts: 3, text: 'next' }, ]; const projection = createTranscriptProjection(); - const unrelatedTurn = projection.project({ messages })[1]; + const unrelatedTurn = projection.project({ locale: 'en', messages })[1]; const update: ShellRunUpdate = { sessionId: 'session-1', ownership: { kind: 'local' }, @@ -94,7 +94,7 @@ describe('ShellRun UI projection', () => { sourceToolCallId: 'bash-1', result: shellRunSnapshot(3, { status: 'completed', completedAt: 5, exitCode: 0 }), }; - const durable = projection.project({ messages, shellRunUpdates: [update] }); + const durable = projection.project({ locale: 'en', messages, shellRunUpdates: [update] }); assert.equal(durable[1], unrelatedTurn); assert.equal(durable[0]?.tools[0]?.status, 'completed'); @@ -113,7 +113,7 @@ describe('ShellRun UI projection', () => { }], }], }; - const overlaid = projection.project({ messages, liveTurn: live, shellRunUpdates: [update] }); + const overlaid = projection.project({ locale: 'en', messages, liveTurn: live, shellRunUpdates: [update] }); assert.equal(overlaid[1], unrelatedTurn, 'the live overlay must not disturb an unrelated turn'); const result = overlaid[0]?.tools[0]?.result; assert.equal(result?.kind === 'shell_run' ? result.revision : undefined, 3); @@ -154,7 +154,7 @@ describe('ShellRun UI projection', () => { }; const tool = createTranscriptProjection() - .project({ messages, liveTurn: live })[0]?.tools[0]; + .project({ locale: 'en', messages, liveTurn: live })[0]?.tools[0]; assert.equal(tool?.result?.kind === 'shell_run' ? tool.result.revision : undefined, 2); assert.equal( tool?.result?.kind === 'shell_run' && tool.result.output?.mode === 'pty' @@ -189,7 +189,7 @@ describe('ShellRun UI projection', () => { }; const turns = createTranscriptProjection() - .project({ messages: [], liveTurn: live, shellRunUpdates: [update] }); + .project({ locale: 'en', messages: [], liveTurn: live, shellRunUpdates: [update] }); const result = turns[0]?.tools[0]?.result; assert.equal(result?.kind, 'shell_run'); assert.equal(result?.kind === 'shell_run' ? result.output?.mode : undefined, 'pty'); @@ -206,7 +206,7 @@ describe('ShellRun UI projection', () => { toolCall('bash-1', 'turn-1', 'Bash', { command: 'job', pty: true }, 1), toolResult('bash-1', 'turn-1', shellRun(1), 2), ]; - const turns = createTranscriptProjection().project({ messages, shellRunUpdates: [{ + const turns = createTranscriptProjection().project({ locale: 'en', messages, shellRunUpdates: [{ sessionId: 'branch-session', ownership: { kind: 'source_owned', @@ -223,7 +223,7 @@ describe('ShellRun UI projection', () => { ? turns[0]?.tools[0]?.result?.status : undefined, 'running'); - const unavailable = createTranscriptProjection().project({ messages, shellRunUpdates: [{ + const unavailable = createTranscriptProjection().project({ locale: 'en', messages, shellRunUpdates: [{ sessionId: 'branch-session', ownership: { kind: 'source_unavailable', sourceSessionId: 'source-session' }, sourceTurnId: 'turn-1', diff --git a/packages/ui/src/__tests__/transcript-projection.test.ts b/packages/ui/src/__tests__/transcript-projection.test.ts index 559ce849fe..e4ca1bbce0 100644 --- a/packages/ui/src/__tests__/transcript-projection.test.ts +++ b/packages/ui/src/__tests__/transcript-projection.test.ts @@ -103,7 +103,7 @@ describe('incremental transcript projection', () => { test('a shell-run update whose semantics are unchanged affects nothing', () => { const projection = createTranscriptProjection(); const messages = history(); - const base = { sessionId: SESSION, messages, liveTurn: streamingTurn('he') }; + const base = { locale: 'en' as const, sessionId: SESSION, messages, liveTurn: streamingTurn('he') }; const settled = projection.project({ ...base, shellRunUpdates: [backgroundUpdate] }); // A new update object carrying an already-merged revision says nothing new. @@ -127,8 +127,8 @@ describe('incremental transcript projection', () => { test('a turn missing from the durable snapshot is dropped, leaving the rest identical', () => { const projection = createTranscriptProjection(); - const before = projection.project({ sessionId: SESSION, messages: history() }); - const after = projection.project({ sessionId: SESSION, messages: history().slice(0, 4) }); + const before = projection.project({ locale: 'en', sessionId: SESSION, messages: history() }); + const after = projection.project({ locale: 'en', sessionId: SESSION, messages: history().slice(0, 4) }); assert.notStrictEqual(after, before, 'a dropped turn must move the published list'); assert.deepEqual(after.map((turn) => turn.turnId), ['turn-1']); assert.strictEqual(after[0], before[0]); @@ -139,6 +139,7 @@ describe('incremental transcript projection', () => { // turn is recognised by value, wherever it lands. const projection = createTranscriptProjection(); const before = projection.project({ + locale: 'en', sessionId: SESSION, messages: [ ...history(), @@ -147,6 +148,7 @@ describe('incremental transcript projection', () => { ], }); const after = projection.project({ + locale: 'en', sessionId: SESSION, messages: [ ...history().slice(0, 4), @@ -161,8 +163,9 @@ describe('incremental transcript projection', () => { test('an edited-and-resent prompt invalidates its own turn and nothing before it', () => { const projection = createTranscriptProjection(); - const before = projection.project({ sessionId: SESSION, messages: history() }); + const before = projection.project({ locale: 'en', sessionId: SESSION, messages: history() }); const after = projection.project({ + locale: 'en', sessionId: SESSION, messages: [ ...history().slice(0, 4), @@ -178,6 +181,7 @@ describe('incremental transcript projection', () => { test('a live step handed off to durable messages rebuilds only its own turn', () => { const projection = createTranscriptProjection(); const live = projection.project({ + locale: 'en', sessionId: SESSION, messages: [...history(), { type: 'user', id: 'u3', turnId: 'turn-3', ts: 7, text: 'third' }], liveTurn: streamingTurn('half an ans'), @@ -186,6 +190,7 @@ describe('incremental transcript projection', () => { // Handoff: the answer lands in messages and the live projection retires. const settled = projection.project({ + locale: 'en', sessionId: SESSION, messages: [ ...history(), @@ -220,12 +225,13 @@ describe('incremental transcript projection', () => { sourceToolCallId: 'bash-1', result: shellRunSnapshot(1), }; - const before = projection.project({ sessionId: SESSION, messages, shellRunUpdates: [owned] }); + const before = projection.project({ locale: 'en', sessionId: SESSION, messages, shellRunUpdates: [owned] }); const bash = before[0]?.tools[0]; assert.equal(bash?.shellRunSource, 'owned'); assert.equal(bash?.result?.kind === 'shell_run' ? bash.result.revision : undefined, 1); const after = projection.project({ + locale: 'en', sessionId: SESSION, messages, shellRunUpdates: [{ @@ -255,6 +261,7 @@ describe('incremental transcript projection', () => { }, }); projection.project({ + locale: 'en', sessionId: SESSION, messages: counted, liveTurn: streamingTurn('h'), @@ -270,6 +277,7 @@ describe('incremental transcript projection', () => { const baseline = reads; for (const text of ['he', 'hel', 'hell', 'hello']) { projection.project({ + locale: 'en', sessionId: SESSION, messages: counted, liveTurn: streamingTurn(text), @@ -286,11 +294,11 @@ describe('incremental transcript projection', () => { const projection = createTranscriptProjection(); const messages = history(); const updates: ShellRunUpdate[] = [backgroundUpdate]; - const before = projection.project({ sessionId: SESSION, messages, shellRunUpdates: updates }); + const before = projection.project({ locale: 'en', sessionId: SESSION, messages, shellRunUpdates: updates }); assert.equal(revisionOf(before[0]), 9); updates.push({ ...backgroundUpdate, result: shellRunSnapshot(21) }); - const after = projection.project({ sessionId: SESSION, messages, shellRunUpdates: updates }); + const after = projection.project({ locale: 'en', sessionId: SESSION, messages, shellRunUpdates: updates }); assert.equal(revisionOf(after[0]), 21, 'an update appended to the same array must be applied'); }); @@ -301,8 +309,9 @@ describe('incremental transcript projection', () => { // sessionId reset is hygiene, bounding what the projection holds on to // rather than standing between the user and a stale transcript. const projection = createTranscriptProjection(); - const first = projection.project({ sessionId: SESSION, messages: history() }); + const first = projection.project({ locale: 'en', sessionId: SESSION, messages: history() }); const other = projection.project({ + locale: 'en', sessionId: 'session-2', messages: [ ...history().slice(0, 5), @@ -313,7 +322,7 @@ describe('incremental transcript projection', () => { assert.equal(other[1]?.assistant?.text, 'a different answer'); // Nothing of session-1 survived: re-projecting it rebuilds every turn. - const back = projection.project({ sessionId: SESSION, messages: history() }); + const back = projection.project({ locale: 'en', sessionId: SESSION, messages: history() }); assert.notStrictEqual(back[0], first[0]); assert.notStrictEqual(back[1], first[1]); }); @@ -325,8 +334,9 @@ describe('incremental transcript projection', () => { // affects — which is why the affected set is derived from the projection's // own output rather than passed through from the event. const projection = createTranscriptProjection(); - const before = projection.project({ sessionId: SESSION, messages: history() }); + const before = projection.project({ locale: 'en', sessionId: SESSION, messages: history() }); const after = projection.project({ + locale: 'en', sessionId: SESSION, messages: [ ...history(), @@ -373,6 +383,7 @@ describe('incremental transcript projection', () => { // over the live-merged turns, where the live-only tool already exists. const projection = createTranscriptProjection(); const turns = projection.project({ + locale: 'en', sessionId: SESSION, messages: [], liveTurn: { @@ -453,10 +464,10 @@ describe('turn identity moves across structural change classes', () => { for (const { field, from, refresh } of cases) { test(`a refresh that only changes \`${field}\` moves the turn`, () => { const projection = createTranscriptProjection(); - const before = projection.project({ sessionId: SESSION, messages: from ?? base }); + const before = projection.project({ locale: 'en', sessionId: SESSION, messages: from ?? base }); // A refresh re-reads the ledger over IPC: all-new objects either way, so // identity can only come from the value comparison under test. - const after = projection.project({ sessionId: SESSION, messages: refresh }); + const after = projection.project({ locale: 'en', sessionId: SESSION, messages: refresh }); // Self-check: the row really does move the field it names, so a row that // stops isolating its field fails loudly instead of passing vacuously. diff --git a/packages/ui/src/artifact-preview-registry.ts b/packages/ui/src/artifact-preview-registry.ts index def9198076..89158dff5c 100644 --- a/packages/ui/src/artifact-preview-registry.ts +++ b/packages/ui/src/artifact-preview-registry.ts @@ -81,7 +81,7 @@ function exceedsImagePayloadCap(base64: string): boolean { return base64.length > IMAGE_PAYLOAD_MAX_BASE64_LENGTH; } -export function formatPreviewSize(sizeBytes: number | undefined, locale: UiLocale = 'zh'): string { +export function formatPreviewSize(sizeBytes: number | undefined, locale: UiLocale): string { if (sizeBytes === undefined || sizeBytes < 0 || !Number.isFinite(sizeBytes)) return getSharedUiCopy(locale).artifact.unknownSize; if (sizeBytes < 1024) return `${sizeBytes} B`; if (sizeBytes < 1024 * 1024) return `${(sizeBytes / 1024).toFixed(1)} KB`; diff --git a/packages/ui/src/chat-model-helpers.ts b/packages/ui/src/chat-model-helpers.ts index 385dc167da..3edc61911f 100644 --- a/packages/ui/src/chat-model-helpers.ts +++ b/packages/ui/src/chat-model-helpers.ts @@ -49,7 +49,7 @@ export type { ChatModelChoice } from '@maka/core/chat-model-choice'; export function modelChoiceDescription( choice: Pick, - locale: UiLocale = 'zh', + locale: UiLocale, ): string | undefined { const description = choice.description?.trim(); const knowledge = choice.knowledgeCutoff?.trim(); @@ -85,7 +85,7 @@ export interface ModelMenuGroup { * account email `connection.name` carries for `claude-subscription` / * `openai-codex`. */ -export function modelMenuGroups(choices: ChatModelChoice[], locale: UiLocale = 'zh'): ModelMenuGroup[] { +export function modelMenuGroups(choices: ChatModelChoice[], locale: UiLocale): ModelMenuGroup[] { const copy = getSharedUiCopy(locale).providers; const localizedLabels: Partial> = { 'MiniMax-cn': copy.minimaxChina, diff --git a/packages/ui/src/materialize.ts b/packages/ui/src/materialize.ts index e55f6283e9..204338117c 100644 --- a/packages/ui/src/materialize.ts +++ b/packages/ui/src/materialize.ts @@ -189,7 +189,7 @@ function systemNoteLabel(kind: string, data: unknown, locale: UiLocale): string export function materializeChat( messages: readonly StoredMessage[], - locale: UiLocale = "en", + locale: UiLocale, ): ChatItem[] { const items: ChatItem[] = []; for (const message of messages) { @@ -703,7 +703,7 @@ const SHELL_RUN_PRESENTATION_STATUS = { */ export function materializeTurns( messages: readonly StoredMessage[], - locale: UiLocale = "en", + locale: UiLocale, ): TurnViewModel[] { const turnRecords = deriveTurnRecords(messages); const turnRecordById = new Map( diff --git a/packages/ui/src/session-status-presentation.ts b/packages/ui/src/session-status-presentation.ts index a2f5c5cf9a..9037b7db3a 100644 --- a/packages/ui/src/session-status-presentation.ts +++ b/packages/ui/src/session-status-presentation.ts @@ -66,7 +66,7 @@ const STATUS_SEMANTIC: Record = { export function presentSessionStatus( status: SessionStatus, - locale: UiLocale = 'zh', + locale: UiLocale, ): SessionStatusPresentation { const semantic = STATUS_SEMANTIC[status]; return { @@ -84,7 +84,7 @@ export function presentSessionStatus( */ export function describeBlockedReason( reason: SessionBlockedReason | undefined, - locale: UiLocale = 'zh', + locale: UiLocale, ): string { const copy = getConversationCopy(locale).sessions.blockedReason; return reason ? copy[reason] : copy.unknown; diff --git a/packages/ui/src/transcript-projection.ts b/packages/ui/src/transcript-projection.ts index 08ab6cf75a..19575cad6f 100644 --- a/packages/ui/src/transcript-projection.ts +++ b/packages/ui/src/transcript-projection.ts @@ -60,7 +60,7 @@ export interface TranscriptProjectionInput { * turn is only reused when its value matches. */ sessionId?: string; - locale?: UiLocale; + locale: UiLocale; messages: readonly StoredMessage[]; liveTurn?: LiveTurnProjection; shellRunUpdates?: readonly ShellRunUpdate[]; diff --git a/packages/ui/stories/model-picker.stories.tsx b/packages/ui/stories/model-picker.stories.tsx index b8cd7f7fd9..7774685f20 100644 --- a/packages/ui/stories/model-picker.stories.tsx +++ b/packages/ui/stories/model-picker.stories.tsx @@ -354,7 +354,7 @@ export const SavingDefaultModel: Story = { render: () => (