1
0
Эх сурвалжийг харах

fix(client): address localization review findings

Tianyi Cui 3 долоо хоног өмнө
parent
commit
ac4ade3aaa
25 өөрчлөгдсөн 324 нэмэгдсэн , 131 устгасан
  1. 10 2
      packages/client/ui-chat/src/client/chat/MessageItem.tsx
  2. 4 3
      packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts
  3. 1 1
      packages/client/ui-chat/src/client/index.ts
  4. 2 0
      packages/client/ui-chat/src/client/locale.ts
  5. 1 1
      packages/client/ui-chat/tests/chat-view.client.spec.tsx
  6. 2 0
      packages/client/ui-conversation/src/client/contract/records.ts
  7. 2 0
      packages/client/ui-conversation/src/client/contract/request-inspection.ts
  8. 18 6
      packages/client/ui-conversation/src/client/conversation/failure-display.ts
  9. 2 1
      packages/client/ui-conversation/src/client/index.ts
  10. 23 0
      packages/client/ui-conversation/tests/failure-display.client.spec.ts
  11. 2 3
      packages/client/ui-message-feedback/src/client/controller.ts
  12. 4 6
      packages/client/ui-message-feedback/tests/controller.client.spec.ts
  13. 7 8
      packages/client/ui-primitives/src/JsonTree.tsx
  14. 11 6
      packages/client/ui-tool/src/client/tool/components/ToolRow.tsx
  15. 76 69
      packages/client/ui-trajectory/src/client/TrajectoryTable.tsx
  16. 2 0
      packages/client/ui-trajectory/src/client/TrajectoryView.tsx
  17. 2 0
      packages/client/ui-trajectory/src/client/locales.ts
  18. 13 3
      packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts
  19. 1 0
      packages/client/ui-trajectory/src/client/trajectory-contract.ts
  20. 9 2
      packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts
  21. 2 0
      packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts
  22. 4 1
      packages/client/ui-trajectory/tests/snapshot-builder.client.spec.ts
  23. 90 1
      packages/client/ui-trajectory/tests/table.client.spec.tsx
  24. 15 2
      scripts/verify-client-ui-i18n.spec.ts
  25. 21 16
      scripts/verify-client-ui-i18n.ts

+ 10 - 2
packages/client/ui-chat/src/client/chat/MessageItem.tsx

@@ -39,6 +39,14 @@ interface RetryCountdown {
   seconds: number
 }
 
+function failureMessage(
+  message: string,
+  code: unknown,
+  t: ChatViewSlotProps['t'],
+): string {
+  return code === 'AUTH' ? t('message.failure.auth') : message
+}
+
 function ModelRetryItem({ node, active, t }: {
   node: ModelRetryNode
   active: boolean
@@ -98,7 +106,7 @@ function ModelRetryItem({ node, active, t }: {
         </div>
         <div>
           <span className={css.retryDetailLabel}>{t('message.retry.failure')}</span>
-          {node.failure.message}
+          {failureMessage(node.failure.message, node.failure.code, t)}
         </div>
       </div>
     </details>
@@ -115,7 +123,7 @@ function TurnErrorItem({ node, t }: {
       <StateDot state="error" className={css.turnErrorDot} />
       <div className={css.turnErrorCopy}>
         <span className={css.turnErrorTitle}>{t('message.turnError')}</span>
-        <span className={css.turnErrorMessage}>{node.message}</span>
+        <span className={css.turnErrorMessage}>{failureMessage(node.message, node.code, t)}</span>
       </div>
       {node.code !== undefined && <code className={css.turnErrorCode}>{node.code}</code>}
     </div>

+ 4 - 3
packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts

@@ -3,7 +3,7 @@ import type {
   ConversationMatch, ConversationNodeContext, ConversationNodeDefinition,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { TurnErrorNode } from '../contract/snapshot.ts'
-import { displayFailureMessage } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import { displayFailure } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { chatNode } from './common.ts'
 
 declare module '../contract/chat-nodes.ts' {
@@ -32,11 +32,12 @@ function lastStep(context: ConversationNodeContext<TurnErrorState>): number {
 function failureFrom(match: ConversationMatch): TurnErrorState['failure'] | undefined {
   if (match.event.type !== 'turn/end' || match.event.data.reason.kind !== 'error') return undefined
   const failure = match.event.data.reason.error
+  const display = displayFailure(failure)
   return {
     seq: match.event.seq,
     time: match.event.time,
-    message: displayFailureMessage(failure),
-    code: failure.code,
+    message: display.message,
+    ...(display.code === undefined ? {} : { code: display.code }),
   }
 }
 

+ 1 - 1
packages/client/ui-chat/src/client/index.ts

@@ -43,7 +43,7 @@ export type {
 export { isRunningTool, isSettledTool } from './contract/chat-nodes.ts'
 export { EMPTY_CHAT_SNAPSHOT, toAssistantBlock, toAssistantBlocks } from './contract/snapshot.ts'
 export {
-  contextForm, contextProvenance, displayFailureMessage, emptyAssistantBlock, isTokenDelta,
+  contextForm, contextProvenance, displayFailure, emptyAssistantBlock, isTokenDelta,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 
 /** Public merge surface for Chat renderer payloads contributed by other plugins. */

+ 2 - 0
packages/client/ui-chat/src/client/locale.ts

@@ -66,6 +66,7 @@ export const zh = {
   'message.retry.status': '{label}({retry}/{maximum}) · {seconds}s',
   'message.retry.delay': '重试延迟:',
   'message.retry.failure': '失败原因:',
+  'message.failure.auth': 'API 密钥无效',
   'message.turnError': '本轮运行失败',
   'message.maxTokens': '已达到输出 token 上限',
   'message.maxTokens.hint': '回答被截断,已有输出保留在对话中。发送“继续”可让模型接着输出。',
@@ -151,6 +152,7 @@ export const en = {
   'message.retry.status': '{label} ({retry}/{maximum}) · {seconds}s',
   'message.retry.delay': 'Retry delay: ',
   'message.retry.failure': 'Failure reason: ',
+  'message.failure.auth': 'API key is invalid',
   'message.turnError': 'This turn failed',
   'message.maxTokens': 'Output token limit reached',
   'message.maxTokens.hint': 'The reply was cut off; earlier output is preserved in the conversation. Send "continue" to let the model resume.',

+ 1 - 1
packages/client/ui-chat/tests/chat-view.client.spec.tsx

@@ -625,7 +625,7 @@ describe('ChatView', () => {
     const view = render(<h.ChatView {...h.props} />)
     const statuses = view.getAllByRole('status')
     expect(statuses.map(status => status.textContent)).toEqual([
-      '本轮运行失败API key is invalidAUTH',
+      '本轮运行失败API 密钥无效AUTH',
       '本轮运行失败plugin exploded',
     ])
   })

+ 2 - 0
packages/client/ui-conversation/src/client/contract/records.ts

@@ -176,7 +176,9 @@ export interface TurnErrorNode {
   time: number
   turn: number
   step: number
+  /** Sanitized provider message; empty when a known code owns localized copy. */
   message: string
+  /** Stable provider failure code, when recorded. */
   code?: string
 }
 

+ 2 - 0
packages/client/ui-conversation/src/client/contract/request-inspection.ts

@@ -37,6 +37,8 @@ interface RequestViewBase {
   completedAt: number | null
   status: 'running' | 'complete' | 'error'
   error?: string
+  /** Stable provider code for localized presentation of known failures. */
+  errorCode?: string
   provenance?: AssistantProvenanceView
   requestConfig?: AssistantRequestConfig
   usage?: unknown

+ 18 - 6
packages/client/ui-conversation/src/client/conversation/failure-display.ts

@@ -1,13 +1,25 @@
+/** Display-safe failure fields retained by locale-independent projections. */
+export interface DisplayFailure {
+  /** Stable provider failure code used for localized known-error copy. */
+  code?: string
+  /** Sanitized provider message; empty when the code owns the display copy. */
+  message: string
+}
+
 /**
- * Convert a durable failure into copy that is safe to expose in the GUI.
+ * Convert a durable failure into locale-independent fields safe for GUI projections.
  * @param failure - Failure value preserved by the session event.
- * @returns Display-safe copy for client projections.
+ * @returns Sanitized message and optional stable provider code.
  */
-export function displayFailureMessage(failure: unknown): string {
-  if (failure === null || typeof failure !== 'object') return String(failure)
+export function displayFailure(failure: unknown): DisplayFailure {
+  if (failure === null || typeof failure !== 'object') return { message: String(failure) }
   const record = failure as { code?: unknown; message?: unknown }
+  const code = typeof record.code === 'string' ? record.code : undefined
   // Provider AUTH messages may echo a masked or partially preserved credential.
   // Keep the raw diagnostic in the session log, but never project it into UI state.
-  if (record.code === 'AUTH') return 'API key is invalid'
-  return typeof record.message === 'string' ? record.message : JSON.stringify(failure)
+  if (code === 'AUTH') return { code, message: '' }
+  return {
+    ...(code === undefined ? {} : { code }),
+    message: typeof record.message === 'string' ? record.message : JSON.stringify(failure),
+  }
 }

+ 2 - 1
packages/client/ui-conversation/src/client/index.ts

@@ -48,7 +48,8 @@ export type { AssistantStepMetadata } from './conversation/assistant-timing.ts'
 export {
   assistantStepKey, indexAssistantStepTiming, isTokenDelta, settledAssistantTiming,
 } from './conversation/assistant-timing.ts'
-export { displayFailureMessage } from './conversation/failure-display.ts'
+export { displayFailure } from './conversation/failure-display.ts'
+export type { DisplayFailure } from './conversation/failure-display.ts'
 export type { ConversationStoreState, ConversationViewRequest, ViewTab } from './contract/views.ts'
 
 export { ConversationNodeAssembler } from './conversation/assembler.ts'

+ 23 - 0
packages/client/ui-conversation/tests/failure-display.client.spec.ts

@@ -0,0 +1,23 @@
+import { describe, expect, it } from 'vitest'
+import { displayFailure } from '../src/client/conversation/failure-display.ts'
+
+describe('displayFailure', () => {
+  it('keeps ordinary diagnostics and stable provider codes', () => {
+    expect(displayFailure(null)).toEqual({ message: 'null' })
+    expect(displayFailure('disconnected')).toEqual({ message: 'disconnected' })
+    expect(displayFailure({ code: 'RATE_LIMIT', message: 'try later' })).toEqual({
+      code: 'RATE_LIMIT',
+      message: 'try later',
+    })
+    expect(displayFailure({ detail: 'unknown' })).toEqual({
+      message: '{"detail":"unknown"}',
+    })
+  })
+
+  it('removes the provider message when AUTH owns localized display copy', () => {
+    expect(displayFailure({ code: 'AUTH', message: 'credential sk-secret failed' })).toEqual({
+      code: 'AUTH',
+      message: '',
+    })
+  })
+})

+ 2 - 3
packages/client/ui-message-feedback/src/client/controller.ts

@@ -306,7 +306,7 @@ export class MessageFeedbackController implements HostObservable<MessageFeedback
       return OK
     } catch (error) {
       if (this.disposed) return OK
-      const message = error instanceof Error ? error.message : 'message feedback list failed'
+      const message = error instanceof Error ? error.message : String(error)
       this.publish({ status: 'error', items: this.view.items, error: message })
       return { ok: false, error: { code: 'transport', message } }
     }
@@ -328,7 +328,6 @@ export class MessageFeedbackController implements HostObservable<MessageFeedback
         if (!loaded.ok) return loaded
         // Disposal can land while the seeding read is in flight; without this
         // second check the fiber would still reach the wire after unloading.
-        // oxlint-disable-next-line typescript/no-unnecessary-condition -- dispose() can run during the await.
         if (this.disposed) return DISPOSED
       }
       try {
@@ -338,7 +337,7 @@ export class MessageFeedbackController implements HostObservable<MessageFeedback
           ok: false,
           error: {
             code: 'transport',
-            message: error instanceof Error ? error.message : 'message feedback mutation failed',
+            message: error instanceof Error ? error.message : String(error),
           },
         }
       }

+ 4 - 6
packages/client/ui-message-feedback/tests/controller.client.spec.ts

@@ -344,25 +344,23 @@ describe('MessageFeedbackController', () => {
     expect(controller.getSnapshot().status).not.toBe('error')
   })
 
-  it('describes a non-Error list rejection with a stable message', async () => {
-    // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario under test.
+  it('preserves a non-Error list rejection as a diagnostic string', async () => {
     const { remote } = fakeRemote({ list: () => Promise.reject('socket string') })
     const controller = new MessageFeedbackController(remote, SESSION)
 
     expect(await controller.ensure()).toEqual({
       ok: false,
-      error: { code: 'transport', message: 'message feedback list failed' },
+      error: { code: 'transport', message: 'socket string' },
     })
   })
 
-  it('describes a non-Error mutation rejection with a stable message', async () => {
-    // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario under test.
+  it('preserves a non-Error mutation rejection as a diagnostic string', async () => {
     const { remote } = fakeRemote({ put: () => Promise.reject('nope') })
     const controller = new MessageFeedbackController(remote, SESSION)
 
     expect(await controller.rate(MSG, 'positive')).toEqual({
       ok: false,
-      error: { code: 'transport', message: 'message feedback mutation failed' },
+      error: { code: 'transport', message: 'nope' },
     })
   })
 

+ 7 - 8
packages/client/ui-primitives/src/JsonTree.tsx

@@ -397,7 +397,6 @@ export function JsonTree({
   expandTopLevel = true,
   labels,
 }: JsonTreeProps) {
-  const copyLabels = labels
   const rootEntries = entriesOf(data)
   const firstExpandableIndex = rootEntries.findIndex(([, value]) => (
     isExpandableValue(value) && entriesOf(value).length > 0
@@ -526,10 +525,10 @@ export function JsonTree({
   const copyTargetIsObject = typeof copyTarget?.value === 'object' && copyTarget.value !== null
   const defaultCopyMode = copyTargetIsObject ? 'prettyJson' : 'value'
   const copyTitle = copyState === 'copied'
-    ? copyLabels.copied
+    ? labels.copied
     : copyState === 'failed'
-      ? copyLabels.copyFailed
-      : copyTargetIsObject ? copyLabels.copyPrettyJson : copyLabels.copyValue
+      ? labels.copyFailed
+      : copyTargetIsObject ? labels.copyPrettyJson : labels.copyValue
 
   return (
     <div
@@ -565,7 +564,7 @@ export function JsonTree({
                   field={key}
                   value={value}
                   path={[Array.isArray(data) ? index : key]}
-                  labels={copyLabels}
+                  labels={labels}
                   lastElement={index === rootEntries.length - 1}
                   initialExpanded={false}
                   tabStopId={tabStopId}
@@ -584,7 +583,7 @@ export function JsonTree({
             <JsonTreeNode
               value={data}
               path={[]}
-              labels={copyLabels}
+              labels={labels}
               lastElement
               initialExpanded
               tabStopId={tabStopId}
@@ -612,7 +611,7 @@ export function JsonTree({
                 data-json-copy-button
                 data-state={copyState}
                 aria-label={copyTitle}
-                title={copyLabels.copyButtonTitle(copyTitle)}
+                title={labels.copyButtonTitle(copyTitle)}
                 onClick={() => void copy(defaultCopyMode)}
                 onContextMenu={(event) => {
                   event.preventDefault()
@@ -626,7 +625,7 @@ export function JsonTree({
                   : <IconCopyOutline16 size={12} />}
               </button>
             )}
-            items={copyTargetIsObject ? objectCopyMenuItems(copyLabels) : valueCopyMenuItems(copyLabels)}
+            items={copyTargetIsObject ? objectCopyMenuItems(labels) : valueCopyMenuItems(labels)}
             onSelect={(id) => {
               void copy(id as 'json' | 'path' | 'prettyJson' | 'value')
               copyMenuOpenRef.current = false

+ 11 - 6
packages/client/ui-tool/src/client/tool/components/ToolRow.tsx

@@ -1,4 +1,4 @@
-import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
+import { useMemo, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
 import clsx from 'clsx'
 import {
   CodeBlock, DiffBlock, DisclosureRow, IconInspectOutline12, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock,
@@ -101,6 +101,11 @@ export function ToolRow({
   inspect,
 }: ToolRowProps) {
   const [expanded, setExpanded] = useState(false)
+  const terminalLabels = useMemo(() => terminalBlockLabels(t), [t])
+  const diffLabels = useMemo(() => diffBlockLabels(t), [t])
+  const readLabels = useMemo(() => readBlockLabels(t), [t])
+  const searchLabels = useMemo(() => searchBlockLabels(t), [t])
+  const webLabels = useMemo(() => webBlockLabels(t), [t])
   const terminalBody = terminal ?? null
   const diffBody = diff ?? null
   const readBody = read ?? null
@@ -179,20 +184,20 @@ export function ToolRow({
               <TerminalBlock
                 {...terminalBody.card}
                 maxLines={Infinity}
-                labels={terminalBlockLabels(t)}
+                labels={terminalLabels}
                 className={css.terminalBody}
               />
             )
             : diffBody !== null
-              ? <DiffBlock {...diffBody.card} labels={diffBlockLabels(t)} maxLines={CHAT_DIFF_MAX_LINES} className={css.diffBody} />
+              ? <DiffBlock {...diffBody.card} labels={diffLabels} maxLines={CHAT_DIFF_MAX_LINES} className={css.diffBody} />
               : readBody !== null
-                ? <ReadBlock {...readBody} labels={readBlockLabels(t)} maxLines={CHAT_READ_MAX_LINES} className={css.readBody} />
+                ? <ReadBlock {...readBody} labels={readLabels} maxLines={CHAT_READ_MAX_LINES} className={css.readBody} />
                 : searchBody !== null
                   ? (
                     <>
                       <SearchBlock
                         {...searchBody.card}
-                        labels={searchBlockLabels(t)}
+                        labels={searchLabels}
                         maxLines={CHAT_SEARCH_MAX_LINES}
                         className={css.searchBody}
                       />
@@ -204,7 +209,7 @@ export function ToolRow({
                     </>
                   )
                   : webBody !== null
-                    ? <WebBlock {...webBody} labels={webBlockLabels(t)} className={css.webBody} />
+                    ? <WebBlock {...webBody} labels={webLabels} className={css.webBody} />
                     : (
                       <>
                         {variant === 'code' && body !== null && (

+ 76 - 69
packages/client/ui-trajectory/src/client/TrajectoryTable.tsx

@@ -187,9 +187,7 @@ interface ToolCallTextParts {
 }
 
 interface SelectedRequest {
-  turn: number | null
-  group: string
-  seq?: number
+  identity: string
 }
 
 interface DetailsResizeDrag {
@@ -425,14 +423,13 @@ export interface TrajectoryTableProps {
 
 /** Request-inspector fields shared by ordinary generation and compaction. */
 interface TrajectoryRequestNumberBase {
-  /** Request anchor event sequence; absent for the currently streaming ordinary request. */
-  seq?: number
   group: string
   number: number
   status?: 'complete' | 'running' | 'error'
   startedAt?: number
   completedAt?: number | null
   error?: string
+  errorCode?: string
   retry?: number
   maxRetries?: number
   retryDelayMs?: number
@@ -448,11 +445,15 @@ interface TrajectoryRequestNumberBase {
 export type TrajectoryRequestNumber = TrajectoryRequestNumberBase & (
   | {
     purpose?: 'assistant'
+    /** Request anchor event sequence; absent for the currently streaming request. */
+    seq?: number
     turn: number
     step: number
   }
   | {
     purpose: 'compaction'
+    /** Request anchor event sequence and stable compaction identity. */
+    seq: number
     turn: number | null
     step: 0
   }
@@ -523,6 +524,12 @@ function requestKey(turn: number | null, group: string): string {
   return `${turn}\u0000${group}`
 }
 
+function requestIdentity(request: TrajectoryRequestNumber): string {
+  return request.purpose === 'compaction'
+    ? `compaction\u0000${request.seq}`
+    : `assistant\u0000${request.turn}\u0000${request.step}`
+}
+
 function indexRequestBoundaries(
   records: readonly TableRecord[],
   requestGroups: ReadonlySet<string>,
@@ -647,19 +654,23 @@ function assistantToolCalls(
   return calls
 }
 
-function summarizeAssistantTools(records: readonly TableRecord[]): string {
+function summarizeAssistantTools(
+  records: readonly TableRecord[],
+  t: TrajectoryTranslate,
+): string {
   const names = [...new Set(records.map((record) => {
     const separator = record.cell.text.indexOf(' · ')
     return separator === -1 ? record.cell.text : record.cell.text.slice(0, separator)
   }).filter(name => name !== ''))]
   const count = records.length
-  const summary = `${count} tool ${count === 1 ? 'call' : 'calls'}`
+  const summary = t(count === 1 ? 'summary.toolCalls.one' : 'summary.toolCalls.other', { count })
   return names.length > 0 ? `${summary} · ${names.join(', ')}` : summary
 }
 
 function collapseAssistantRecords(
   records: readonly TableRecord[],
   collapsedAssistants: ReadonlySet<string>,
+  t: TrajectoryTranslate,
 ): TableRecord[] {
   const out: TableRecord[] = []
   for (let i = 0; i < records.length; i++) {
@@ -688,7 +699,7 @@ function collapseAssistantRecords(
       groupStart: false,
       turnStart: false,
       turnEnd: last?.turnEnd ?? false,
-      collapsedSummary: summarizeAssistantTools(calls),
+      collapsedSummary: summarizeAssistantTools(calls, t),
       collapsedSummaryKind: 'assistant',
     })
     i += calls.length
@@ -712,6 +723,15 @@ function statusLabel(state: RecordState, t: TrajectoryTranslate): string {
   return t('status.completed')
 }
 
+function requestErrorMessage(
+  request: Pick<TrajectoryRequestNumber, 'error' | 'errorCode'>,
+  t: TrajectoryTranslate,
+): string | undefined {
+  if (request.errorCode === 'AUTH') return t('details.failure.auth')
+  if (request.error === COMPACTION_INTERRUPTED_ERROR) return t('layout.compactionInterrupted')
+  return request.error
+}
+
 function TokenRows({ cell, t }: { cell: TrajectoryCellProps; t: TrajectoryTranslate }) {
   const content = cell.output !== undefined && cell.think !== undefined
     ? Math.max(0, cell.output - cell.think)
@@ -1083,10 +1103,11 @@ function MarkdownFragment({
   preview: boolean
   t: TrajectoryTranslate
 }) {
+  const labels = useMemo(() => markdownLabels(t), [t])
   if (rendered) {
     return (
       <div className={preview ? css.markdownPreview : css.markdownPayload}>
-        <MarkdownText text={text} labels={markdownLabels(t)} />
+        <MarkdownText text={text} labels={labels} />
       </div>
     )
   }
@@ -1849,7 +1870,7 @@ export function TrajectoryTable({
       : collapseTurnRecords(allRecords, collapsedTurns, requestGroups, t)
     return collapsedAssistants.size === 0
       ? turnRecords
-      : collapseAssistantRecords(turnRecords, collapsedAssistants)
+      : collapseAssistantRecords(turnRecords, collapsedAssistants, t)
   }, [allRecords, collapsedAssistants, collapsedTurns, requestGroups, searchMatchIndexes, t])
   const projectedVirtualRows = useMemo(
     () => groupTrajectoryVirtualRows(records),
@@ -1932,28 +1953,25 @@ export function TrajectoryTable({
     : undefined
   const promptSelected = selectedPrompt !== undefined
   const selectedState = selected === undefined ? undefined : stateOf(selected)
-  const selectedRequestRecordTemplates = useMemo(() => selectedRequest === null
+  const selectedRequestInfo = selectedRequest === null
+    ? undefined
+    : sessionRequestNumbers?.find(request =>
+      requestIdentity(request) === selectedRequest.identity)
+  const selectedRequestRecordTemplates = useMemo(() => selectedRequestInfo === undefined
     ? []
     : allRecords.filter(record =>
-      record.turn === selectedRequest.turn
-        && record.group === selectedRequest.group,
-    ), [allRecords, selectedRequest])
+      record.turn === selectedRequestInfo.turn
+        && record.group === selectedRequestInfo.group,
+    ), [allRecords, selectedRequestInfo])
   const selectedRequestRecords = selectedRequestRecordTemplates.map(currentRecord)
   const selectedRequestAssistant = selectedRequestRecords.find(
     record => record.cell.kind === 'message',
   )
   const selectedRequestAnchor = selectedRequestAssistant ?? selectedRequestRecords[0]
-  const selectedRequestNumber = selectedRequest === null
-    ? undefined
-    : requestNumbers.get(requestKey(selectedRequest.turn, selectedRequest.group))
-  const selectedRequestInfo = selectedRequest === null
+  const selectedRequestNumber = selectedRequestInfo?.number
+  const selectedRequestState: RecordState | undefined = selectedRequestInfo === undefined
     ? undefined
-    : sessionRequestNumbers?.find(request => selectedRequest.seq === undefined
-      ? request.turn === selectedRequest.turn && request.group === selectedRequest.group
-      : request.seq === selectedRequest.seq)
-  const selectedRequestState: RecordState | undefined = selectedRequest === null
-    ? undefined
-    : selectedRequestInfo?.status
+    : selectedRequestInfo.status
       ?? (selectedRequestAssistant?.cell.assistantMetrics?.completedTime === null
         ? 'running'
         : selectedRequestAssistant === undefined
@@ -1996,11 +2014,11 @@ export function TrajectoryTable({
   const selectedRequestCumulativeUsage =
     selectedRequestInfo?.cumulativeUsage ?? selectedRequestUsage
   const selectedRequestOptions = selectedRequestInfo?.requestConfig
-  const activeTurn = selectedRequest === null ? selected?.turn : selectedRequest.turn
-  const activeSection = selectedRequest === null
+  const activeTurn = selectedRequestInfo === undefined ? selected?.turn : selectedRequestInfo.turn
+  const activeSection = selectedRequestInfo === undefined
     ? selected?.section
     : selectedRequestRecords[0]?.section
-  const selectedTabs = selectedRequest !== null
+  const selectedTabs = selectedRequestInfo !== undefined
     ? REQUEST_TABS.filter(tab => tab.id !== 'options' || selectedRequestOptions !== undefined)
     : selected === undefined ? [] : detailTabs(selected)
   const selectedParents: ParentRecords = selected === undefined
@@ -2015,15 +2033,9 @@ export function TrajectoryTable({
     ? undefined
     : sessionRequestNumbers?.find(request => request.number === selectedAssistantRequest)
   const selectedAssistantRequestTarget: SelectedRequest | undefined =
-    selected !== undefined && selectedAssistantRequest !== undefined
-      ? {
-        turn: selected.turn,
-        group: selected.group,
-        ...(selectedAssistantRequestInfo?.seq === undefined
-          ? {}
-          : { seq: selectedAssistantRequestInfo.seq }),
-      }
-      : undefined
+    selectedAssistantRequestInfo === undefined
+      ? undefined
+      : { identity: requestIdentity(selectedAssistantRequestInfo) }
   const hasSelectedHierarchy = selectedAssistantRequestTarget !== undefined
     || selectedParents.message !== undefined
     || selectedParents.tool !== undefined
@@ -2385,9 +2397,8 @@ export function TrajectoryTable({
                     : t(requestInfo?.purpose === 'compaction'
                       ? 'request.labelCompaction'
                       : 'request.label', { request })
-                  const requestSelected = request !== undefined
-                && selectedRequest?.turn === record.turn
-                && selectedRequest.group === record.group
+                  const requestSelected = requestInfo !== undefined
+                && selectedRequest?.identity === requestIdentity(requestInfo)
                   const sectionActive = record.turn === null
                     ? activeSection === record.section
                     : activeTurn === record.turn
@@ -2489,11 +2500,9 @@ export function TrajectoryTable({
                             style={requestBoundaryStyle}
                             onClick={(event) => {
                               event.stopPropagation()
-                              selectRequest({
-                                turn: record.turn,
-                                group: record.group,
-                                ...(requestInfo?.seq === undefined ? {} : { seq: requestInfo.seq }),
-                              })
+                              if (requestInfo !== undefined) {
+                                selectRequest({ identity: requestIdentity(requestInfo) })
+                              }
                             }}
                             onDoubleClick={(event) => { event.stopPropagation() }}
                           />
@@ -2625,7 +2634,7 @@ export function TrajectoryTable({
           </tbody>
         </table>
       </div>
-      {(selectedRequest !== null
+      {(selectedRequestInfo !== undefined
         || promptSelected
         || (selected !== undefined && selectedState !== undefined)) && (
         <aside
@@ -2711,7 +2720,7 @@ export function TrajectoryTable({
           />
           <div className={css.detailsHeader}>
             <div className={css.detailsTitle}>
-              {selectedRequest !== null
+              {selectedRequestInfo !== undefined
                 ? (
                   <>
                     <span className={css.requestDetailsDot} aria-hidden="true" />
@@ -2719,9 +2728,9 @@ export function TrajectoryTable({
                       {t('request.label', { request: selectedRequestNumber ?? '—' })}
                     </span>
                     <span className={css.detailsLocation}>
-                      {selectedRequestInfo?.purpose === 'compaction'
-                        ? t('request.compaction', { section: sectionLabel(selectedRequest.turn, t) })
-                        : sectionLabel(selectedRequest.turn, t)}
+                      {selectedRequestInfo.purpose === 'compaction'
+                        ? t('request.compaction', { section: sectionLabel(selectedRequestInfo.turn, t) })
+                        : sectionLabel(selectedRequestInfo.turn, t)}
                     </span>
                   </>
                 )
@@ -2791,7 +2800,7 @@ export function TrajectoryTable({
             role="tabpanel"
             aria-labelledby={`trajectory-detail-${activeTab}`}
           >
-            {selectedRequest !== null
+            {selectedRequestInfo !== undefined
               && selectedRequestState !== undefined
               && activeTab === 'overview' && (
               <>
@@ -2805,29 +2814,29 @@ export function TrajectoryTable({
                       {statusLabel(selectedRequestState, t)}
                     </dd>
                   </div>
-                  {selectedRequestInfo?.purpose === 'compaction' && (
+                  {selectedRequestInfo.purpose === 'compaction' && (
                     <div>
                       <dt>{t('details.purpose')}</dt>
                       <dd>{t('request.compactionPurpose')}</dd>
                     </div>
                   )}
-                  {(selectedRequestInfo?.provider
-                    ?? selectedRequestInfo?.requestConfig?.provider) !== undefined && (
+                  {(selectedRequestInfo.provider
+                    ?? selectedRequestInfo.requestConfig?.provider) !== undefined && (
                     <div>
                       <dt>{t('details.provider')}</dt>
                       <dd>
-                        {selectedRequestInfo?.provider
-                          ?? selectedRequestInfo?.requestConfig?.provider}
+                        {selectedRequestInfo.provider
+                          ?? selectedRequestInfo.requestConfig?.provider}
                       </dd>
                     </div>
                   )}
-                  {(selectedRequestInfo?.model
-                    ?? selectedRequestInfo?.requestConfig?.model) !== undefined && (
+                  {(selectedRequestInfo.model
+                    ?? selectedRequestInfo.requestConfig?.model) !== undefined && (
                     <div>
                       <dt>{t('details.model')}</dt>
                       <dd>
-                        {selectedRequestInfo?.model
-                          ?? selectedRequestInfo?.requestConfig?.model}
+                        {selectedRequestInfo.model
+                          ?? selectedRequestInfo.requestConfig?.model}
                       </dd>
                     </div>
                   )}
@@ -2841,15 +2850,13 @@ export function TrajectoryTable({
                       <dd>{selectedRequestSubtoolCalls}</dd>
                     </div>
                   )}
-                  {selectedRequestInfo?.error !== undefined && (
+                  {selectedRequestInfo.error !== undefined && (
                     <div>
                       <dt>{t('details.error')}</dt>
-                      <dd className={css.error}>{selectedRequestInfo.error === COMPACTION_INTERRUPTED_ERROR
-                        ? t('layout.compactionInterrupted')
-                        : selectedRequestInfo.error}</dd>
+                      <dd className={css.error}>{requestErrorMessage(selectedRequestInfo, t)}</dd>
                     </div>
                   )}
-                  {selectedRequestInfo?.retry !== undefined && (
+                  {selectedRequestInfo.retry !== undefined && (
                     <div>
                       <dt>{t('details.retry')}</dt>
                       <dd>
@@ -2862,7 +2869,7 @@ export function TrajectoryTable({
                       </dd>
                     </div>
                   )}
-                  {selectedRequestInfo?.retryDelayMs !== undefined && (
+                  {selectedRequestInfo.retryDelayMs !== undefined && (
                     <div>
                       <dt>{t('details.retryDelay')}</dt>
                       <dd>{formatDurationMs(selectedRequestInfo.retryDelayMs, t)}</dd>
@@ -2880,7 +2887,7 @@ export function TrajectoryTable({
                           }}
                         >
                           <span>
-                            {selectedRequestInfo?.purpose === 'compaction'
+                            {selectedRequestInfo.purpose === 'compaction'
                               ? t('details.compacted')
                               : t('details.assistantMessage')}
                           </span>
@@ -2913,17 +2920,17 @@ export function TrajectoryTable({
                 </div>
               </>
             )}
-            {selectedRequest !== null && activeTab === 'options' && (
+            {selectedRequestInfo !== undefined && activeTab === 'options' && (
               <RequestOptions options={selectedRequestOptions} t={t} />
             )}
-            {selectedRequest !== null && activeTab === 'usage' && (
+            {selectedRequestInfo !== undefined && activeTab === 'usage' && (
               <RequestUsagePanel
                 usage={selectedRequestUsage}
                 cumulative={selectedRequestCumulativeUsage}
                 t={t}
               />
             )}
-            {selectedRequest !== null && activeTab === 'timing' && (
+            {selectedRequestInfo !== undefined && activeTab === 'timing' && (
               <RequestTiming
                 assistant={selectedRequestAssistant}
                 anchor={selectedRequestAnchor}

+ 2 - 0
packages/client/ui-trajectory/src/client/TrajectoryView.tsx

@@ -206,6 +206,7 @@ export function TrajectoryView({
           ...(request?.startedAt === undefined ? {} : { startedAt: request.startedAt }),
           ...(request?.completedAt === undefined ? {} : { completedAt: request.completedAt }),
           ...(request?.error === undefined ? {} : { error: request.error }),
+          ...(request?.errorCode === undefined ? {} : { errorCode: request.errorCode }),
           ...(request?.resultSeq === undefined ? {} : { resultSeq: request.resultSeq }),
           ...(request?.retry === undefined ? {} : { retry: request.retry }),
           ...(request?.maxRetries === undefined ? {} : { maxRetries: request.maxRetries }),
@@ -232,6 +233,7 @@ export function TrajectoryView({
         startedAt: request.startedAt,
         completedAt: request.completedAt,
         ...(request.error === undefined ? {} : { error: request.error }),
+        ...(request.errorCode === undefined ? {} : { errorCode: request.errorCode }),
         resultSeq: request.startSeq,
         ...(request.provenance?.provider === undefined
           ? {}

+ 2 - 0
packages/client/ui-trajectory/src/client/locales.ts

@@ -154,6 +154,7 @@ export const zh = {
   'details.toolCalls': '工具调用',
   'details.subtoolCalls': '子工具调用',
   'details.error': '错误',
+  'details.failure.auth': 'API 密钥无效',
   'details.retry': '重试',
   'details.scheduled': '已计划',
   'details.retryDelay': '重试延迟',
@@ -345,6 +346,7 @@ export const en: Record<TrajectoryKey, string> = {
   'details.toolCalls': 'Tool calls',
   'details.subtoolCalls': 'Subtool calls',
   'details.error': 'Error',
+  'details.failure.auth': 'API key is invalid',
   'details.retry': 'Retry',
   'details.scheduled': 'Scheduled',
   'details.retryDelay': 'Retry delay',

+ 13 - 3
packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts

@@ -1,6 +1,6 @@
 import type { Context } from '@deepseek-ai/cordis'
 import {
-  displayFailureMessage, emptyAssistantBlock, isTokenDelta, toAssistantBlock,
+  displayFailure, emptyAssistantBlock, isTokenDelta, toAssistantBlock,
   toAssistantBlocks,
   type AssistantBlock, type AssistantMessageNode, type ConversationLocation,
   type ConversationMatch, type ConversationNodeContext, type ConversationNodeDefinition,
@@ -21,6 +21,7 @@ interface UsageValue {
 
 interface RetryValue {
   readonly message: string
+  readonly code?: string
   readonly retry: number
   readonly maxRetries?: number
   readonly delayMs: number
@@ -260,6 +261,7 @@ function assistantRequest(
       ? {}
       : {
         error: state.retry.message,
+        ...(state.retry.code === undefined ? {} : { errorCode: state.retry.code }),
         retry: state.retry.retry,
         ...(state.retry.maxRetries === undefined ? {} : { maxRetries: state.retry.maxRetries }),
         retryDelayMs: state.retry.delayMs,
@@ -315,6 +317,7 @@ const trajectoryAssistantDefinition: ConversationNodeDefinition<AssistantState>
     if (match.event.type === 'step/end') return { ...context.state, stepEnd: match }
     if (match.event.type !== 'llm/retry') return context.state
     const data = match.event.data
+    const failure = displayFailure(data.failure)
     return {
       ...initialState(
         context.state.turn,
@@ -326,7 +329,8 @@ const trajectoryAssistantDefinition: ConversationNodeDefinition<AssistantState>
       firstTokenTime: context.state.firstTokenTime,
       usage: context.state.usage,
       retry: {
-        message: displayFailureMessage(data.failure),
+        message: failure.message,
+        ...(failure.code === undefined ? {} : { code: failure.code }),
         retry: data.retry,
         ...(data.mode === 'normal' ? { maxRetries: data.maxRetries } : {}),
         delayMs: data.delayMs,
@@ -363,6 +367,7 @@ interface TurnEndState {
   readonly seq: number
   readonly time: number
   readonly error?: string
+  readonly errorCode?: string
 }
 
 const trajectoryTurnEndDefinition: ConversationNodeDefinition<TurnEndState> = {
@@ -376,11 +381,15 @@ const trajectoryTurnEndDefinition: ConversationNodeDefinition<TurnEndState> = {
       throw new Error('trajectory-turn-end start requires turn/end')
     }
     const reason = match.event.data.reason
+    const failure = reason.kind === 'error' ? displayFailure(reason.error) : undefined
     return {
       turn: match.event.data.turn,
       seq: match.event.seq,
       time: match.event.time,
-      ...(reason.kind === 'error' ? { error: displayFailureMessage(reason.error) } : {}),
+      ...(failure === undefined ? {} : {
+        error: failure.message,
+        ...(failure.code === undefined ? {} : { errorCode: failure.code }),
+      }),
     }
   },
   update: context => context.state,
@@ -391,6 +400,7 @@ const trajectoryTurnEndDefinition: ConversationNodeDefinition<TurnEndState> = {
       turn: context.state.turn,
       time: context.state.time,
       ...(context.state.error === undefined ? {} : { error: context.state.error }),
+      ...(context.state.errorCode === undefined ? {} : { errorCode: context.state.errorCode }),
     }),
 }
 /* jscpd:ignore-end */

+ 1 - 0
packages/client/ui-trajectory/src/client/trajectory-contract.ts

@@ -48,6 +48,7 @@ export type TrajectoryContribution =
     readonly turn: number
     readonly time: number
     readonly error?: string
+    readonly errorCode?: string
   }
 
 /** Target envelope consumed by the Trajectory snapshot builder. */

+ 9 - 2
packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts

@@ -113,7 +113,7 @@ function interruptCompactions(
 
 function applyTurnErrors(
   requests: RequestView[],
-  endings: readonly { turn: number; time: number; error?: string }[],
+  endings: readonly { turn: number; time: number; error?: string; errorCode?: string }[],
 ): void {
   const lastAssistantByTurn = new Map<number, number>()
   for (const [index, request] of requests.entries()) {
@@ -130,6 +130,7 @@ function applyTurnErrors(
       completedAt: request.completedAt ?? ending.time,
       status: 'error',
       error: ending.error,
+      ...(ending.errorCode === undefined ? {} : { errorCode: ending.errorCode }),
     }
   }
 }
@@ -183,7 +184,12 @@ export class TrajectorySnapshotBuilder implements ConversationViewBuilder<
     const eventLocations = new Map<number, TrajectoryConversationViewNode['location']>()
     const requests: RequestView[] = []
     const boundaries: { seq: number; time: number }[] = []
-    const turnEndings: { turn: number; time: number; error?: string }[] = []
+    const turnEndings: {
+      turn: number
+      time: number
+      error?: string
+      errorCode?: string
+    }[] = []
     const callSchemas = new Map<string, ToolSchema>()
     const consumedPromptChanges = new Set<number>()
     let previousHeader: TrajectoryRequestHeaderState | undefined
@@ -237,6 +243,7 @@ export class TrajectorySnapshotBuilder implements ConversationViewBuilder<
         turn: data.turn,
         time: data.time,
         ...(data.error === undefined ? {} : { error: data.error }),
+        ...(data.errorCode === undefined ? {} : { errorCode: data.errorCode }),
       })
     }
 

+ 2 - 0
packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts

@@ -143,6 +143,8 @@ describe('Trajectory conversation Definitions', () => {
     expect(settled.requests).toMatchObject([{
       purpose: 'assistant',
       status: 'error',
+      error: 'temporary failure',
+      errorCode: 'TRANSPORT',
       retry: 1,
       maxRetries: 2,
       retryDelayMs: 25,

+ 4 - 1
packages/client/ui-trajectory/tests/snapshot-builder.client.spec.ts

@@ -186,6 +186,7 @@ describe('TrajectorySnapshotBuilder', () => {
         turn: 1,
         time: 5,
         error: 'turn failed',
+        errorCode: 'AUTH',
       }),
       contribution('compact:10', 10, {
         kind: 'compaction',
@@ -203,7 +204,9 @@ describe('TrajectorySnapshotBuilder', () => {
 
     expect(snapshot.requests).toMatchObject([
       { purpose: 'assistant', step: 1, status: 'complete' },
-      { purpose: 'assistant', step: 2, status: 'error', error: 'turn failed' },
+      {
+        purpose: 'assistant', step: 2, status: 'error', error: 'turn failed', errorCode: 'AUTH',
+      },
       { purpose: 'compaction', startSeq: 10, status: 'error', completedAt: 16 },
       { purpose: 'compaction', startSeq: 12, status: 'error', completedAt: 14 },
     ])

+ 90 - 1
packages/client/ui-trajectory/tests/table.client.spec.tsx

@@ -6,7 +6,8 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
 import type { ComponentProps } from 'react'
 import { TrajectoryTable as LocalizedTrajectoryTable } from '../src/client/TrajectoryTable.tsx'
 import type { TrajectoryTurnModel } from '../src/client/layout.ts'
-import { t } from './locale.client.ts'
+import { trajectoryRecordId } from '../src/client/trajectory-record.ts'
+import { t, tZh } from './locale.client.ts'
 
 function TrajectoryTable(props: Omit<ComponentProps<typeof LocalizedTrajectoryTable>, 't'>) {
   const inferred: Array<NonNullable<typeof props.requestNumbers>[number] & { firstIndex: number }> = []
@@ -19,6 +20,7 @@ function TrajectoryTable(props: Omit<ComponentProps<typeof LocalizedTrajectoryTa
         inferred.push({
           turn: turn.turn,
           step: 0,
+          seq: Number(compaction),
           group: group.title,
           number: 0,
           purpose: 'compaction',
@@ -122,6 +124,22 @@ describe('TrajectoryTable', () => {
     expect(screen.getByText('(tool call only)')).toBeTruthy()
   })
 
+  it('localizes the summary for folded Assistant tool calls', () => {
+    const assistant = TURNS[0]!.groups[0]!.cells[0]!
+    render(
+      <LocalizedTrajectoryTable
+        t={tZh}
+        turns={TURNS}
+        collapsedTurns={new Set<number>()}
+        onToggleTurn={() => {}}
+        collapsedAssistants={new Set([trajectoryRecordId(assistant)])}
+        onToggleAssistant={() => {}}
+      />,
+    )
+
+    expect(screen.getByText('2 个工具调用 · bash')).toBeTruthy()
+  })
+
   it('shows assistant timing facts after keyboard selection', () => {
     render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
     fireEvent.keyDown(screen.getByRole('row', { name: /ASSISTANT/ }), { key: 'Enter' })
@@ -343,6 +361,41 @@ describe('TrajectoryTable', () => {
     expect(screen.getByText('Request #2')).toBeTruthy()
   })
 
+  it('keeps a selected request when its localized group label changes', () => {
+    const turn = (group: string): TrajectoryTurnModel => ({
+      turn: 1,
+      groups: [{
+        title: group,
+        cells: [{
+          index: 1,
+          kind: 'message',
+          sourceSeq: 10,
+          text: 'response',
+          timeSeconds: 1,
+        }],
+      }],
+    })
+    const request = (group: string) => [{
+      turn: 1,
+      step: 1,
+      seq: 10,
+      group,
+      number: 1,
+    }] as const
+    const view = render(
+      <TrajectoryTable turns={[turn('Step 1')]} requestNumbers={request('Step 1')} {...FOLD_PROPS} />,
+    )
+    fireEvent.click(screen.getByRole('button', { name: 'Request #1' }))
+
+    view.rerender(
+      <TrajectoryTable turns={[turn('步骤 1')]} requestNumbers={request('步骤 1')} {...FOLD_PROPS} />,
+    )
+
+    expect(screen.getByRole('button', { name: 'Request #1' })
+      .getAttribute('aria-pressed')).toBe('true')
+    expect(screen.getByText('Request #1')).toBeTruthy()
+  })
+
   it('places the request boundary after leading steering input', () => {
     const turns: readonly TrajectoryTurnModel[] = [{
       turn: 1,
@@ -773,6 +826,42 @@ describe('TrajectoryTable', () => {
     expect(recovered.style.getPropertyValue('--request-boundary-offset')).toBe('16px')
   })
 
+  it('localizes a sanitized AUTH request failure from its stable code', () => {
+    const turns: readonly TrajectoryTurnModel[] = [{
+      turn: 1,
+      groups: [{
+        title: 'Step 1',
+        cells: [{
+          index: 1,
+          kind: 'message',
+          text: '',
+          requestOnly: true,
+          isError: true,
+          timeSeconds: 0.1,
+        }],
+      }],
+    }]
+    render(
+      <TrajectoryTable
+        turns={turns}
+        requestNumbers={[{
+          turn: 1,
+          step: 1,
+          seq: 1,
+          group: 'Step 1',
+          number: 1,
+          status: 'error',
+          error: '',
+          errorCode: 'AUTH',
+        }]}
+        {...FOLD_PROPS}
+      />,
+    )
+
+    fireEvent.click(screen.getByRole('button', { name: 'Request #1' }))
+    expect(screen.getByText('API key is invalid')).toBeTruthy()
+  })
+
   it('shows the custom role tooltip only from the responsive icon', () => {
     const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
     const toolTag = view.container.querySelector<HTMLElement>('[data-role-kind="tool"]')

+ 15 - 2
scripts/verify-client-ui-i18n.spec.ts

@@ -1,5 +1,5 @@
 import { describe, expect, it } from 'vitest'
-import { findUiI18nViolations } from './verify-client-ui-i18n.ts'
+import { clientSourceRoot, findUiI18nViolations } from './verify-client-ui-i18n.ts'
 
 function messages(source: string): string[] {
   return findUiI18nViolations('packages/client/ui-example/src/client/View.tsx', source)
@@ -26,8 +26,21 @@ describe('Client UI i18n source check', () => {
       }
       function duration(): string { return 'Not recorded' }
       function mode(): string { return 'compact' }
+      function displayFailureMessage(): string { return 'API key is invalid' }
+      const emptySummary = 'Nothing to show'
       function Dialog({ closeLabel = 'Close dialog' }: { closeLabel?: string }) { return closeLabel }
-    `)).toEqual(['Summary', 'Complete', 'Still running', 'Not recorded', 'Close dialog'])
+    `)).toEqual([
+      'Summary', 'Complete', 'Still running', 'Not recorded', 'API key is invalid',
+      'Nothing to show', 'Close dialog',
+    ])
+  })
+
+  it('normalizes native separators before deriving a Client source root', () => {
+    expect(clientSourceRoot('packages/extensions/sample/src/client/View.tsx'))
+      .toBe('packages/extensions/sample/src/client')
+    expect(clientSourceRoot('packages\\extensions\\sample\\src\\client\\View.tsx'))
+      .toBe('packages/extensions/sample/src/client')
+    expect(clientSourceRoot('packages/extensions/sample/src/server/index.ts')).toBeUndefined()
   })
 
   it('accepts translated copy, dynamic values, structural attributes, and language tokens', () => {

+ 21 - 16
scripts/verify-client-ui-i18n.ts

@@ -8,7 +8,7 @@
  */
 
 import { globSync, readFileSync } from 'node:fs'
-import { resolve, sep } from 'node:path'
+import { resolve } from 'node:path'
 import ts from 'typescript'
 
 const root = resolve(import.meta.dirname, '..')
@@ -32,12 +32,13 @@ const COPY_ATTRIBUTES = new Set([
 ])
 const COPY_ATTRIBUTE_SUFFIX = /(?:Aria|Copy|Description|Heading|Label|Message|Placeholder|Summary|Text|Title|Tooltip)$/
 
-const COPY_NAME = /(?:^|_)(?:aria|copy|description|empty|heading|label|placeholder|title|tooltip)(?:s|_.*)?$/i
-const COPY_SUFFIX = /(?:aria|copy|description|empty|heading|label|labels|placeholder|title|tooltip|tabs)$/i
+const COPY_NAME = /(?:^|_)(?:aria|copy|description|empty|heading|label|message|placeholder|summary|text|title|tooltip)(?:s|_.*)?$/i
+const COPY_SUFFIX = /(?:aria|copy|description|empty|heading|label|labels|message|placeholder|summary|text|title|tooltip|tabs)$/i
 const IMMUTABLE_LANGUAGE_TOKENS = new Set([
   'Function',
   'K',
   'M',
+  'MB',
   'Symbol',
   'false',
   'function()',
@@ -78,13 +79,6 @@ function containsProductText(text: string): boolean {
     && /\p{L}/u.test(normalized)
 }
 
-function translationCall(node: ts.CallExpression): boolean {
-  const callee = node.expression
-  return ts.isIdentifier(callee)
-    ? callee.text === 't'
-    : ts.isPropertyAccessExpression(callee) && callee.name.text === 't'
-}
-
 function propertyName(node: ts.PropertyName | ts.BindingName): string | undefined {
   return ts.isIdentifier(node) || ts.isStringLiteral(node) ? node.text : undefined
 }
@@ -161,7 +155,7 @@ export function findUiI18nViolations(file: string, sourceText: string): UiI18nVi
       return
     }
     if (ts.isCallExpression(node)) {
-      if (translationCall(node)) return
+      // A call result is dynamic; copy-bearing arguments are visited through their own syntax.
       return
     }
     if (
@@ -293,12 +287,23 @@ export function findUiI18nViolations(file: string, sourceText: string): UiI18nVi
   return [...violations.values()].sort((left, right) => left.line - right.line || left.column - right.column)
 }
 
+/**
+ * Resolve the normalized Client source root containing one TSX component.
+ * @param file - Glob result using native or POSIX separators.
+ * @returns Repository-relative `src/client` root, or undefined outside that tree.
+ */
+export function clientSourceRoot(file: string): string | undefined {
+  const normalized = file.replaceAll('\\', '/')
+  const marker = '/src/client/'
+  const index = normalized.indexOf(marker)
+  return index < 0 ? undefined : normalized.slice(0, index + marker.length - 1)
+}
+
 function sourceFiles(): string[] {
   const clientComponentRoots = new Set(
-    globSync('packages/*/*/src/client/**/*.tsx', { cwd: root }).map((file) => {
-      const marker = '/src/client/'
-      return file.slice(0, file.indexOf(marker) + marker.length - 1)
-    }),
+    globSync('packages/*/*/src/client/**/*.tsx', { cwd: root })
+      .map(clientSourceRoot)
+      .filter((clientRoot): clientRoot is string => clientRoot !== undefined),
   )
   return [...new Set([
     ...globSync('packages/client/*/src/**/*.tsx', { cwd: root }),
@@ -307,7 +312,7 @@ function sourceFiles(): string[] {
       globSync(`${clientRoot}/**/*.{ts,tsx}`, { cwd: root })),
     ...globSync('apps/web/src/**/*.{ts,tsx}', { cwd: root }),
   ])]
-    .map(file => file.split(sep).join('/'))
+    .map(file => file.replaceAll('\\', '/'))
     .filter(file => !file.endsWith('.d.ts'))
     .sort()
 }