fix(client): address localization review findings

This commit is contained in:
Tianyi Cui
2026-08-23 21:22:41 +08:00
parent 174c672f45
commit ac4ade3aaa
25 changed files with 324 additions and 131 deletions
@@ -39,6 +39,14 @@ interface RetryCountdown {
seconds: number 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 }: { function ModelRetryItem({ node, active, t }: {
node: ModelRetryNode node: ModelRetryNode
active: boolean active: boolean
@@ -98,7 +106,7 @@ function ModelRetryItem({ node, active, t }: {
</div> </div>
<div> <div>
<span className={css.retryDetailLabel}>{t('message.retry.failure')}</span> <span className={css.retryDetailLabel}>{t('message.retry.failure')}</span>
{node.failure.message} {failureMessage(node.failure.message, node.failure.code, t)}
</div> </div>
</div> </div>
</details> </details>
@@ -115,7 +123,7 @@ function TurnErrorItem({ node, t }: {
<StateDot state="error" className={css.turnErrorDot} /> <StateDot state="error" className={css.turnErrorDot} />
<div className={css.turnErrorCopy}> <div className={css.turnErrorCopy}>
<span className={css.turnErrorTitle}>{t('message.turnError')}</span> <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> </div>
{node.code !== undefined && <code className={css.turnErrorCode}>{node.code}</code>} {node.code !== undefined && <code className={css.turnErrorCode}>{node.code}</code>}
</div> </div>
@@ -3,7 +3,7 @@ import type {
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition,
} from '@deepseek-ai/dsh-client-ui-conversation/client' } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { TurnErrorNode } from '../contract/snapshot.ts' 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' import { chatNode } from './common.ts'
declare module '../contract/chat-nodes.ts' { declare module '../contract/chat-nodes.ts' {
@@ -32,11 +32,12 @@ function lastStep(context: ConversationNodeContext<TurnErrorState>): number {
function failureFrom(match: ConversationMatch): TurnErrorState['failure'] | undefined { function failureFrom(match: ConversationMatch): TurnErrorState['failure'] | undefined {
if (match.event.type !== 'turn/end' || match.event.data.reason.kind !== 'error') return undefined if (match.event.type !== 'turn/end' || match.event.data.reason.kind !== 'error') return undefined
const failure = match.event.data.reason.error const failure = match.event.data.reason.error
const display = displayFailure(failure)
return { return {
seq: match.event.seq, seq: match.event.seq,
time: match.event.time, time: match.event.time,
message: displayFailureMessage(failure), message: display.message,
code: failure.code, ...(display.code === undefined ? {} : { code: display.code }),
} }
} }
+1 -1
View File
@@ -43,7 +43,7 @@ export type {
export { isRunningTool, isSettledTool } from './contract/chat-nodes.ts' export { isRunningTool, isSettledTool } from './contract/chat-nodes.ts'
export { EMPTY_CHAT_SNAPSHOT, toAssistantBlock, toAssistantBlocks } from './contract/snapshot.ts' export { EMPTY_CHAT_SNAPSHOT, toAssistantBlock, toAssistantBlocks } from './contract/snapshot.ts'
export { export {
contextForm, contextProvenance, displayFailureMessage, emptyAssistantBlock, isTokenDelta, contextForm, contextProvenance, displayFailure, emptyAssistantBlock, isTokenDelta,
} from '@deepseek-ai/dsh-client-ui-conversation/client' } from '@deepseek-ai/dsh-client-ui-conversation/client'
/** Public merge surface for Chat renderer payloads contributed by other plugins. */ /** Public merge surface for Chat renderer payloads contributed by other plugins. */
@@ -66,6 +66,7 @@ export const zh = {
'message.retry.status': '{label}{retry}/{maximum} · {seconds}s', 'message.retry.status': '{label}{retry}/{maximum} · {seconds}s',
'message.retry.delay': '重试延迟:', 'message.retry.delay': '重试延迟:',
'message.retry.failure': '失败原因:', 'message.retry.failure': '失败原因:',
'message.failure.auth': 'API 密钥无效',
'message.turnError': '本轮运行失败', 'message.turnError': '本轮运行失败',
'message.maxTokens': '已达到输出 token 上限', 'message.maxTokens': '已达到输出 token 上限',
'message.maxTokens.hint': '回答被截断,已有输出保留在对话中。发送“继续”可让模型接着输出。', 'message.maxTokens.hint': '回答被截断,已有输出保留在对话中。发送“继续”可让模型接着输出。',
@@ -151,6 +152,7 @@ export const en = {
'message.retry.status': '{label} ({retry}/{maximum}) · {seconds}s', 'message.retry.status': '{label} ({retry}/{maximum}) · {seconds}s',
'message.retry.delay': 'Retry delay: ', 'message.retry.delay': 'Retry delay: ',
'message.retry.failure': 'Failure reason: ', 'message.retry.failure': 'Failure reason: ',
'message.failure.auth': 'API key is invalid',
'message.turnError': 'This turn failed', 'message.turnError': 'This turn failed',
'message.maxTokens': 'Output token limit reached', '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.', 'message.maxTokens.hint': 'The reply was cut off; earlier output is preserved in the conversation. Send "continue" to let the model resume.',
@@ -625,7 +625,7 @@ describe('ChatView', () => {
const view = render(<h.ChatView {...h.props} />) const view = render(<h.ChatView {...h.props} />)
const statuses = view.getAllByRole('status') const statuses = view.getAllByRole('status')
expect(statuses.map(status => status.textContent)).toEqual([ expect(statuses.map(status => status.textContent)).toEqual([
'本轮运行失败API key is invalidAUTH', '本轮运行失败API 密钥无效AUTH',
'本轮运行失败plugin exploded', '本轮运行失败plugin exploded',
]) ])
}) })
@@ -176,7 +176,9 @@ export interface TurnErrorNode {
time: number time: number
turn: number turn: number
step: number step: number
/** Sanitized provider message; empty when a known code owns localized copy. */
message: string message: string
/** Stable provider failure code, when recorded. */
code?: string code?: string
} }
@@ -37,6 +37,8 @@ interface RequestViewBase {
completedAt: number | null completedAt: number | null
status: 'running' | 'complete' | 'error' status: 'running' | 'complete' | 'error'
error?: string error?: string
/** Stable provider code for localized presentation of known failures. */
errorCode?: string
provenance?: AssistantProvenanceView provenance?: AssistantProvenanceView
requestConfig?: AssistantRequestConfig requestConfig?: AssistantRequestConfig
usage?: unknown usage?: unknown
@@ -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. * @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 { export function displayFailure(failure: unknown): DisplayFailure {
if (failure === null || typeof failure !== 'object') return String(failure) if (failure === null || typeof failure !== 'object') return { message: String(failure) }
const record = failure as { code?: unknown; message?: unknown } 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. // 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. // Keep the raw diagnostic in the session log, but never project it into UI state.
if (record.code === 'AUTH') return 'API key is invalid' if (code === 'AUTH') return { code, message: '' }
return typeof record.message === 'string' ? record.message : JSON.stringify(failure) return {
...(code === undefined ? {} : { code }),
message: typeof record.message === 'string' ? record.message : JSON.stringify(failure),
}
} }
@@ -48,7 +48,8 @@ export type { AssistantStepMetadata } from './conversation/assistant-timing.ts'
export { export {
assistantStepKey, indexAssistantStepTiming, isTokenDelta, settledAssistantTiming, assistantStepKey, indexAssistantStepTiming, isTokenDelta, settledAssistantTiming,
} from './conversation/assistant-timing.ts' } 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 type { ConversationStoreState, ConversationViewRequest, ViewTab } from './contract/views.ts'
export { ConversationNodeAssembler } from './conversation/assembler.ts' export { ConversationNodeAssembler } from './conversation/assembler.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: '',
})
})
})
@@ -306,7 +306,7 @@ export class MessageFeedbackController implements HostObservable<MessageFeedback
return OK return OK
} catch (error) { } catch (error) {
if (this.disposed) return OK 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 }) this.publish({ status: 'error', items: this.view.items, error: message })
return { ok: false, error: { code: 'transport', message } } return { ok: false, error: { code: 'transport', message } }
} }
@@ -328,7 +328,6 @@ export class MessageFeedbackController implements HostObservable<MessageFeedback
if (!loaded.ok) return loaded if (!loaded.ok) return loaded
// Disposal can land while the seeding read is in flight; without this // Disposal can land while the seeding read is in flight; without this
// second check the fiber would still reach the wire after unloading. // 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 if (this.disposed) return DISPOSED
} }
try { try {
@@ -338,7 +337,7 @@ export class MessageFeedbackController implements HostObservable<MessageFeedback
ok: false, ok: false,
error: { error: {
code: 'transport', code: 'transport',
message: error instanceof Error ? error.message : 'message feedback mutation failed', message: error instanceof Error ? error.message : String(error),
}, },
} }
} }
@@ -344,25 +344,23 @@ describe('MessageFeedbackController', () => {
expect(controller.getSnapshot().status).not.toBe('error') expect(controller.getSnapshot().status).not.toBe('error')
}) })
it('describes a non-Error list rejection with a stable message', async () => { it('preserves a non-Error list rejection as a diagnostic string', async () => {
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario under test.
const { remote } = fakeRemote({ list: () => Promise.reject('socket string') }) const { remote } = fakeRemote({ list: () => Promise.reject('socket string') })
const controller = new MessageFeedbackController(remote, SESSION) const controller = new MessageFeedbackController(remote, SESSION)
expect(await controller.ensure()).toEqual({ expect(await controller.ensure()).toEqual({
ok: false, 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 () => { it('preserves a non-Error mutation rejection as a diagnostic string', async () => {
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario under test.
const { remote } = fakeRemote({ put: () => Promise.reject('nope') }) const { remote } = fakeRemote({ put: () => Promise.reject('nope') })
const controller = new MessageFeedbackController(remote, SESSION) const controller = new MessageFeedbackController(remote, SESSION)
expect(await controller.rate(MSG, 'positive')).toEqual({ expect(await controller.rate(MSG, 'positive')).toEqual({
ok: false, ok: false,
error: { code: 'transport', message: 'message feedback mutation failed' }, error: { code: 'transport', message: 'nope' },
}) })
}) })
@@ -397,7 +397,6 @@ export function JsonTree({
expandTopLevel = true, expandTopLevel = true,
labels, labels,
}: JsonTreeProps) { }: JsonTreeProps) {
const copyLabels = labels
const rootEntries = entriesOf(data) const rootEntries = entriesOf(data)
const firstExpandableIndex = rootEntries.findIndex(([, value]) => ( const firstExpandableIndex = rootEntries.findIndex(([, value]) => (
isExpandableValue(value) && entriesOf(value).length > 0 isExpandableValue(value) && entriesOf(value).length > 0
@@ -526,10 +525,10 @@ export function JsonTree({
const copyTargetIsObject = typeof copyTarget?.value === 'object' && copyTarget.value !== null const copyTargetIsObject = typeof copyTarget?.value === 'object' && copyTarget.value !== null
const defaultCopyMode = copyTargetIsObject ? 'prettyJson' : 'value' const defaultCopyMode = copyTargetIsObject ? 'prettyJson' : 'value'
const copyTitle = copyState === 'copied' const copyTitle = copyState === 'copied'
? copyLabels.copied ? labels.copied
: copyState === 'failed' : copyState === 'failed'
? copyLabels.copyFailed ? labels.copyFailed
: copyTargetIsObject ? copyLabels.copyPrettyJson : copyLabels.copyValue : copyTargetIsObject ? labels.copyPrettyJson : labels.copyValue
return ( return (
<div <div
@@ -565,7 +564,7 @@ export function JsonTree({
field={key} field={key}
value={value} value={value}
path={[Array.isArray(data) ? index : key]} path={[Array.isArray(data) ? index : key]}
labels={copyLabels} labels={labels}
lastElement={index === rootEntries.length - 1} lastElement={index === rootEntries.length - 1}
initialExpanded={false} initialExpanded={false}
tabStopId={tabStopId} tabStopId={tabStopId}
@@ -584,7 +583,7 @@ export function JsonTree({
<JsonTreeNode <JsonTreeNode
value={data} value={data}
path={[]} path={[]}
labels={copyLabels} labels={labels}
lastElement lastElement
initialExpanded initialExpanded
tabStopId={tabStopId} tabStopId={tabStopId}
@@ -612,7 +611,7 @@ export function JsonTree({
data-json-copy-button data-json-copy-button
data-state={copyState} data-state={copyState}
aria-label={copyTitle} aria-label={copyTitle}
title={copyLabels.copyButtonTitle(copyTitle)} title={labels.copyButtonTitle(copyTitle)}
onClick={() => void copy(defaultCopyMode)} onClick={() => void copy(defaultCopyMode)}
onContextMenu={(event) => { onContextMenu={(event) => {
event.preventDefault() event.preventDefault()
@@ -626,7 +625,7 @@ export function JsonTree({
: <IconCopyOutline16 size={12} />} : <IconCopyOutline16 size={12} />}
</button> </button>
)} )}
items={copyTargetIsObject ? objectCopyMenuItems(copyLabels) : valueCopyMenuItems(copyLabels)} items={copyTargetIsObject ? objectCopyMenuItems(labels) : valueCopyMenuItems(labels)}
onSelect={(id) => { onSelect={(id) => {
void copy(id as 'json' | 'path' | 'prettyJson' | 'value') void copy(id as 'json' | 'path' | 'prettyJson' | 'value')
copyMenuOpenRef.current = false copyMenuOpenRef.current = false
@@ -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 clsx from 'clsx'
import { import {
CodeBlock, DiffBlock, DisclosureRow, IconInspectOutline12, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock, CodeBlock, DiffBlock, DisclosureRow, IconInspectOutline12, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock,
@@ -101,6 +101,11 @@ export function ToolRow({
inspect, inspect,
}: ToolRowProps) { }: ToolRowProps) {
const [expanded, setExpanded] = useState(false) 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 terminalBody = terminal ?? null
const diffBody = diff ?? null const diffBody = diff ?? null
const readBody = read ?? null const readBody = read ?? null
@@ -179,20 +184,20 @@ export function ToolRow({
<TerminalBlock <TerminalBlock
{...terminalBody.card} {...terminalBody.card}
maxLines={Infinity} maxLines={Infinity}
labels={terminalBlockLabels(t)} labels={terminalLabels}
className={css.terminalBody} className={css.terminalBody}
/> />
) )
: diffBody !== null : 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 : 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 : searchBody !== null
? ( ? (
<> <>
<SearchBlock <SearchBlock
{...searchBody.card} {...searchBody.card}
labels={searchBlockLabels(t)} labels={searchLabels}
maxLines={CHAT_SEARCH_MAX_LINES} maxLines={CHAT_SEARCH_MAX_LINES}
className={css.searchBody} className={css.searchBody}
/> />
@@ -204,7 +209,7 @@ export function ToolRow({
</> </>
) )
: webBody !== null : webBody !== null
? <WebBlock {...webBody} labels={webBlockLabels(t)} className={css.webBody} /> ? <WebBlock {...webBody} labels={webLabels} className={css.webBody} />
: ( : (
<> <>
{variant === 'code' && body !== null && ( {variant === 'code' && body !== null && (
@@ -187,9 +187,7 @@ interface ToolCallTextParts {
} }
interface SelectedRequest { interface SelectedRequest {
turn: number | null identity: string
group: string
seq?: number
} }
interface DetailsResizeDrag { interface DetailsResizeDrag {
@@ -425,14 +423,13 @@ export interface TrajectoryTableProps {
/** Request-inspector fields shared by ordinary generation and compaction. */ /** Request-inspector fields shared by ordinary generation and compaction. */
interface TrajectoryRequestNumberBase { interface TrajectoryRequestNumberBase {
/** Request anchor event sequence; absent for the currently streaming ordinary request. */
seq?: number
group: string group: string
number: number number: number
status?: 'complete' | 'running' | 'error' status?: 'complete' | 'running' | 'error'
startedAt?: number startedAt?: number
completedAt?: number | null completedAt?: number | null
error?: string error?: string
errorCode?: string
retry?: number retry?: number
maxRetries?: number maxRetries?: number
retryDelayMs?: number retryDelayMs?: number
@@ -448,11 +445,15 @@ interface TrajectoryRequestNumberBase {
export type TrajectoryRequestNumber = TrajectoryRequestNumberBase & ( export type TrajectoryRequestNumber = TrajectoryRequestNumberBase & (
| { | {
purpose?: 'assistant' purpose?: 'assistant'
/** Request anchor event sequence; absent for the currently streaming request. */
seq?: number
turn: number turn: number
step: number step: number
} }
| { | {
purpose: 'compaction' purpose: 'compaction'
/** Request anchor event sequence and stable compaction identity. */
seq: number
turn: number | null turn: number | null
step: 0 step: 0
} }
@@ -523,6 +524,12 @@ function requestKey(turn: number | null, group: string): string {
return `${turn}\u0000${group}` 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( function indexRequestBoundaries(
records: readonly TableRecord[], records: readonly TableRecord[],
requestGroups: ReadonlySet<string>, requestGroups: ReadonlySet<string>,
@@ -647,19 +654,23 @@ function assistantToolCalls(
return calls return calls
} }
function summarizeAssistantTools(records: readonly TableRecord[]): string { function summarizeAssistantTools(
records: readonly TableRecord[],
t: TrajectoryTranslate,
): string {
const names = [...new Set(records.map((record) => { const names = [...new Set(records.map((record) => {
const separator = record.cell.text.indexOf(' · ') const separator = record.cell.text.indexOf(' · ')
return separator === -1 ? record.cell.text : record.cell.text.slice(0, separator) return separator === -1 ? record.cell.text : record.cell.text.slice(0, separator)
}).filter(name => name !== ''))] }).filter(name => name !== ''))]
const count = records.length 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 return names.length > 0 ? `${summary} · ${names.join(', ')}` : summary
} }
function collapseAssistantRecords( function collapseAssistantRecords(
records: readonly TableRecord[], records: readonly TableRecord[],
collapsedAssistants: ReadonlySet<string>, collapsedAssistants: ReadonlySet<string>,
t: TrajectoryTranslate,
): TableRecord[] { ): TableRecord[] {
const out: TableRecord[] = [] const out: TableRecord[] = []
for (let i = 0; i < records.length; i++) { for (let i = 0; i < records.length; i++) {
@@ -688,7 +699,7 @@ function collapseAssistantRecords(
groupStart: false, groupStart: false,
turnStart: false, turnStart: false,
turnEnd: last?.turnEnd ?? false, turnEnd: last?.turnEnd ?? false,
collapsedSummary: summarizeAssistantTools(calls), collapsedSummary: summarizeAssistantTools(calls, t),
collapsedSummaryKind: 'assistant', collapsedSummaryKind: 'assistant',
}) })
i += calls.length i += calls.length
@@ -712,6 +723,15 @@ function statusLabel(state: RecordState, t: TrajectoryTranslate): string {
return t('status.completed') 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 }) { function TokenRows({ cell, t }: { cell: TrajectoryCellProps; t: TrajectoryTranslate }) {
const content = cell.output !== undefined && cell.think !== undefined const content = cell.output !== undefined && cell.think !== undefined
? Math.max(0, cell.output - cell.think) ? Math.max(0, cell.output - cell.think)
@@ -1083,10 +1103,11 @@ function MarkdownFragment({
preview: boolean preview: boolean
t: TrajectoryTranslate t: TrajectoryTranslate
}) { }) {
const labels = useMemo(() => markdownLabels(t), [t])
if (rendered) { if (rendered) {
return ( return (
<div className={preview ? css.markdownPreview : css.markdownPayload}> <div className={preview ? css.markdownPreview : css.markdownPayload}>
<MarkdownText text={text} labels={markdownLabels(t)} /> <MarkdownText text={text} labels={labels} />
</div> </div>
) )
} }
@@ -1849,7 +1870,7 @@ export function TrajectoryTable({
: collapseTurnRecords(allRecords, collapsedTurns, requestGroups, t) : collapseTurnRecords(allRecords, collapsedTurns, requestGroups, t)
return collapsedAssistants.size === 0 return collapsedAssistants.size === 0
? turnRecords ? turnRecords
: collapseAssistantRecords(turnRecords, collapsedAssistants) : collapseAssistantRecords(turnRecords, collapsedAssistants, t)
}, [allRecords, collapsedAssistants, collapsedTurns, requestGroups, searchMatchIndexes, t]) }, [allRecords, collapsedAssistants, collapsedTurns, requestGroups, searchMatchIndexes, t])
const projectedVirtualRows = useMemo( const projectedVirtualRows = useMemo(
() => groupTrajectoryVirtualRows(records), () => groupTrajectoryVirtualRows(records),
@@ -1932,28 +1953,25 @@ export function TrajectoryTable({
: undefined : undefined
const promptSelected = selectedPrompt !== undefined const promptSelected = selectedPrompt !== undefined
const selectedState = selected === undefined ? undefined : stateOf(selected) 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 => : allRecords.filter(record =>
record.turn === selectedRequest.turn record.turn === selectedRequestInfo.turn
&& record.group === selectedRequest.group, && record.group === selectedRequestInfo.group,
), [allRecords, selectedRequest]) ), [allRecords, selectedRequestInfo])
const selectedRequestRecords = selectedRequestRecordTemplates.map(currentRecord) const selectedRequestRecords = selectedRequestRecordTemplates.map(currentRecord)
const selectedRequestAssistant = selectedRequestRecords.find( const selectedRequestAssistant = selectedRequestRecords.find(
record => record.cell.kind === 'message', record => record.cell.kind === 'message',
) )
const selectedRequestAnchor = selectedRequestAssistant ?? selectedRequestRecords[0] const selectedRequestAnchor = selectedRequestAssistant ?? selectedRequestRecords[0]
const selectedRequestNumber = selectedRequest === null const selectedRequestNumber = selectedRequestInfo?.number
const selectedRequestState: RecordState | undefined = selectedRequestInfo === undefined
? undefined ? undefined
: requestNumbers.get(requestKey(selectedRequest.turn, selectedRequest.group)) : selectedRequestInfo.status
const selectedRequestInfo = selectedRequest === null
? 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
?? (selectedRequestAssistant?.cell.assistantMetrics?.completedTime === null ?? (selectedRequestAssistant?.cell.assistantMetrics?.completedTime === null
? 'running' ? 'running'
: selectedRequestAssistant === undefined : selectedRequestAssistant === undefined
@@ -1996,11 +2014,11 @@ export function TrajectoryTable({
const selectedRequestCumulativeUsage = const selectedRequestCumulativeUsage =
selectedRequestInfo?.cumulativeUsage ?? selectedRequestUsage selectedRequestInfo?.cumulativeUsage ?? selectedRequestUsage
const selectedRequestOptions = selectedRequestInfo?.requestConfig const selectedRequestOptions = selectedRequestInfo?.requestConfig
const activeTurn = selectedRequest === null ? selected?.turn : selectedRequest.turn const activeTurn = selectedRequestInfo === undefined ? selected?.turn : selectedRequestInfo.turn
const activeSection = selectedRequest === null const activeSection = selectedRequestInfo === undefined
? selected?.section ? selected?.section
: selectedRequestRecords[0]?.section : selectedRequestRecords[0]?.section
const selectedTabs = selectedRequest !== null const selectedTabs = selectedRequestInfo !== undefined
? REQUEST_TABS.filter(tab => tab.id !== 'options' || selectedRequestOptions !== undefined) ? REQUEST_TABS.filter(tab => tab.id !== 'options' || selectedRequestOptions !== undefined)
: selected === undefined ? [] : detailTabs(selected) : selected === undefined ? [] : detailTabs(selected)
const selectedParents: ParentRecords = selected === undefined const selectedParents: ParentRecords = selected === undefined
@@ -2015,15 +2033,9 @@ export function TrajectoryTable({
? undefined ? undefined
: sessionRequestNumbers?.find(request => request.number === selectedAssistantRequest) : sessionRequestNumbers?.find(request => request.number === selectedAssistantRequest)
const selectedAssistantRequestTarget: SelectedRequest | undefined = const selectedAssistantRequestTarget: SelectedRequest | undefined =
selected !== undefined && selectedAssistantRequest !== undefined selectedAssistantRequestInfo === undefined
? { ? undefined
turn: selected.turn, : { identity: requestIdentity(selectedAssistantRequestInfo) }
group: selected.group,
...(selectedAssistantRequestInfo?.seq === undefined
? {}
: { seq: selectedAssistantRequestInfo.seq }),
}
: undefined
const hasSelectedHierarchy = selectedAssistantRequestTarget !== undefined const hasSelectedHierarchy = selectedAssistantRequestTarget !== undefined
|| selectedParents.message !== undefined || selectedParents.message !== undefined
|| selectedParents.tool !== undefined || selectedParents.tool !== undefined
@@ -2385,9 +2397,8 @@ export function TrajectoryTable({
: t(requestInfo?.purpose === 'compaction' : t(requestInfo?.purpose === 'compaction'
? 'request.labelCompaction' ? 'request.labelCompaction'
: 'request.label', { request }) : 'request.label', { request })
const requestSelected = request !== undefined const requestSelected = requestInfo !== undefined
&& selectedRequest?.turn === record.turn && selectedRequest?.identity === requestIdentity(requestInfo)
&& selectedRequest.group === record.group
const sectionActive = record.turn === null const sectionActive = record.turn === null
? activeSection === record.section ? activeSection === record.section
: activeTurn === record.turn : activeTurn === record.turn
@@ -2489,11 +2500,9 @@ export function TrajectoryTable({
style={requestBoundaryStyle} style={requestBoundaryStyle}
onClick={(event) => { onClick={(event) => {
event.stopPropagation() event.stopPropagation()
selectRequest({ if (requestInfo !== undefined) {
turn: record.turn, selectRequest({ identity: requestIdentity(requestInfo) })
group: record.group, }
...(requestInfo?.seq === undefined ? {} : { seq: requestInfo.seq }),
})
}} }}
onDoubleClick={(event) => { event.stopPropagation() }} onDoubleClick={(event) => { event.stopPropagation() }}
/> />
@@ -2625,7 +2634,7 @@ export function TrajectoryTable({
</tbody> </tbody>
</table> </table>
</div> </div>
{(selectedRequest !== null {(selectedRequestInfo !== undefined
|| promptSelected || promptSelected
|| (selected !== undefined && selectedState !== undefined)) && ( || (selected !== undefined && selectedState !== undefined)) && (
<aside <aside
@@ -2711,7 +2720,7 @@ export function TrajectoryTable({
/> />
<div className={css.detailsHeader}> <div className={css.detailsHeader}>
<div className={css.detailsTitle}> <div className={css.detailsTitle}>
{selectedRequest !== null {selectedRequestInfo !== undefined
? ( ? (
<> <>
<span className={css.requestDetailsDot} aria-hidden="true" /> <span className={css.requestDetailsDot} aria-hidden="true" />
@@ -2719,9 +2728,9 @@ export function TrajectoryTable({
{t('request.label', { request: selectedRequestNumber ?? '—' })} {t('request.label', { request: selectedRequestNumber ?? '—' })}
</span> </span>
<span className={css.detailsLocation}> <span className={css.detailsLocation}>
{selectedRequestInfo?.purpose === 'compaction' {selectedRequestInfo.purpose === 'compaction'
? t('request.compaction', { section: sectionLabel(selectedRequest.turn, t) }) ? t('request.compaction', { section: sectionLabel(selectedRequestInfo.turn, t) })
: sectionLabel(selectedRequest.turn, t)} : sectionLabel(selectedRequestInfo.turn, t)}
</span> </span>
</> </>
) )
@@ -2791,7 +2800,7 @@ export function TrajectoryTable({
role="tabpanel" role="tabpanel"
aria-labelledby={`trajectory-detail-${activeTab}`} aria-labelledby={`trajectory-detail-${activeTab}`}
> >
{selectedRequest !== null {selectedRequestInfo !== undefined
&& selectedRequestState !== undefined && selectedRequestState !== undefined
&& activeTab === 'overview' && ( && activeTab === 'overview' && (
<> <>
@@ -2805,29 +2814,29 @@ export function TrajectoryTable({
{statusLabel(selectedRequestState, t)} {statusLabel(selectedRequestState, t)}
</dd> </dd>
</div> </div>
{selectedRequestInfo?.purpose === 'compaction' && ( {selectedRequestInfo.purpose === 'compaction' && (
<div> <div>
<dt>{t('details.purpose')}</dt> <dt>{t('details.purpose')}</dt>
<dd>{t('request.compactionPurpose')}</dd> <dd>{t('request.compactionPurpose')}</dd>
</div> </div>
)} )}
{(selectedRequestInfo?.provider {(selectedRequestInfo.provider
?? selectedRequestInfo?.requestConfig?.provider) !== undefined && ( ?? selectedRequestInfo.requestConfig?.provider) !== undefined && (
<div> <div>
<dt>{t('details.provider')}</dt> <dt>{t('details.provider')}</dt>
<dd> <dd>
{selectedRequestInfo?.provider {selectedRequestInfo.provider
?? selectedRequestInfo?.requestConfig?.provider} ?? selectedRequestInfo.requestConfig?.provider}
</dd> </dd>
</div> </div>
)} )}
{(selectedRequestInfo?.model {(selectedRequestInfo.model
?? selectedRequestInfo?.requestConfig?.model) !== undefined && ( ?? selectedRequestInfo.requestConfig?.model) !== undefined && (
<div> <div>
<dt>{t('details.model')}</dt> <dt>{t('details.model')}</dt>
<dd> <dd>
{selectedRequestInfo?.model {selectedRequestInfo.model
?? selectedRequestInfo?.requestConfig?.model} ?? selectedRequestInfo.requestConfig?.model}
</dd> </dd>
</div> </div>
)} )}
@@ -2841,15 +2850,13 @@ export function TrajectoryTable({
<dd>{selectedRequestSubtoolCalls}</dd> <dd>{selectedRequestSubtoolCalls}</dd>
</div> </div>
)} )}
{selectedRequestInfo?.error !== undefined && ( {selectedRequestInfo.error !== undefined && (
<div> <div>
<dt>{t('details.error')}</dt> <dt>{t('details.error')}</dt>
<dd className={css.error}>{selectedRequestInfo.error === COMPACTION_INTERRUPTED_ERROR <dd className={css.error}>{requestErrorMessage(selectedRequestInfo, t)}</dd>
? t('layout.compactionInterrupted')
: selectedRequestInfo.error}</dd>
</div> </div>
)} )}
{selectedRequestInfo?.retry !== undefined && ( {selectedRequestInfo.retry !== undefined && (
<div> <div>
<dt>{t('details.retry')}</dt> <dt>{t('details.retry')}</dt>
<dd> <dd>
@@ -2862,7 +2869,7 @@ export function TrajectoryTable({
</dd> </dd>
</div> </div>
)} )}
{selectedRequestInfo?.retryDelayMs !== undefined && ( {selectedRequestInfo.retryDelayMs !== undefined && (
<div> <div>
<dt>{t('details.retryDelay')}</dt> <dt>{t('details.retryDelay')}</dt>
<dd>{formatDurationMs(selectedRequestInfo.retryDelayMs, t)}</dd> <dd>{formatDurationMs(selectedRequestInfo.retryDelayMs, t)}</dd>
@@ -2880,7 +2887,7 @@ export function TrajectoryTable({
}} }}
> >
<span> <span>
{selectedRequestInfo?.purpose === 'compaction' {selectedRequestInfo.purpose === 'compaction'
? t('details.compacted') ? t('details.compacted')
: t('details.assistantMessage')} : t('details.assistantMessage')}
</span> </span>
@@ -2913,17 +2920,17 @@ export function TrajectoryTable({
</div> </div>
</> </>
)} )}
{selectedRequest !== null && activeTab === 'options' && ( {selectedRequestInfo !== undefined && activeTab === 'options' && (
<RequestOptions options={selectedRequestOptions} t={t} /> <RequestOptions options={selectedRequestOptions} t={t} />
)} )}
{selectedRequest !== null && activeTab === 'usage' && ( {selectedRequestInfo !== undefined && activeTab === 'usage' && (
<RequestUsagePanel <RequestUsagePanel
usage={selectedRequestUsage} usage={selectedRequestUsage}
cumulative={selectedRequestCumulativeUsage} cumulative={selectedRequestCumulativeUsage}
t={t} t={t}
/> />
)} )}
{selectedRequest !== null && activeTab === 'timing' && ( {selectedRequestInfo !== undefined && activeTab === 'timing' && (
<RequestTiming <RequestTiming
assistant={selectedRequestAssistant} assistant={selectedRequestAssistant}
anchor={selectedRequestAnchor} anchor={selectedRequestAnchor}
@@ -206,6 +206,7 @@ export function TrajectoryView({
...(request?.startedAt === undefined ? {} : { startedAt: request.startedAt }), ...(request?.startedAt === undefined ? {} : { startedAt: request.startedAt }),
...(request?.completedAt === undefined ? {} : { completedAt: request.completedAt }), ...(request?.completedAt === undefined ? {} : { completedAt: request.completedAt }),
...(request?.error === undefined ? {} : { error: request.error }), ...(request?.error === undefined ? {} : { error: request.error }),
...(request?.errorCode === undefined ? {} : { errorCode: request.errorCode }),
...(request?.resultSeq === undefined ? {} : { resultSeq: request.resultSeq }), ...(request?.resultSeq === undefined ? {} : { resultSeq: request.resultSeq }),
...(request?.retry === undefined ? {} : { retry: request.retry }), ...(request?.retry === undefined ? {} : { retry: request.retry }),
...(request?.maxRetries === undefined ? {} : { maxRetries: request.maxRetries }), ...(request?.maxRetries === undefined ? {} : { maxRetries: request.maxRetries }),
@@ -232,6 +233,7 @@ export function TrajectoryView({
startedAt: request.startedAt, startedAt: request.startedAt,
completedAt: request.completedAt, completedAt: request.completedAt,
...(request.error === undefined ? {} : { error: request.error }), ...(request.error === undefined ? {} : { error: request.error }),
...(request.errorCode === undefined ? {} : { errorCode: request.errorCode }),
resultSeq: request.startSeq, resultSeq: request.startSeq,
...(request.provenance?.provider === undefined ...(request.provenance?.provider === undefined
? {} ? {}
@@ -154,6 +154,7 @@ export const zh = {
'details.toolCalls': '工具调用', 'details.toolCalls': '工具调用',
'details.subtoolCalls': '子工具调用', 'details.subtoolCalls': '子工具调用',
'details.error': '错误', 'details.error': '错误',
'details.failure.auth': 'API 密钥无效',
'details.retry': '重试', 'details.retry': '重试',
'details.scheduled': '已计划', 'details.scheduled': '已计划',
'details.retryDelay': '重试延迟', 'details.retryDelay': '重试延迟',
@@ -345,6 +346,7 @@ export const en: Record<TrajectoryKey, string> = {
'details.toolCalls': 'Tool calls', 'details.toolCalls': 'Tool calls',
'details.subtoolCalls': 'Subtool calls', 'details.subtoolCalls': 'Subtool calls',
'details.error': 'Error', 'details.error': 'Error',
'details.failure.auth': 'API key is invalid',
'details.retry': 'Retry', 'details.retry': 'Retry',
'details.scheduled': 'Scheduled', 'details.scheduled': 'Scheduled',
'details.retryDelay': 'Retry delay', 'details.retryDelay': 'Retry delay',
@@ -1,6 +1,6 @@
import type { Context } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis'
import { import {
displayFailureMessage, emptyAssistantBlock, isTokenDelta, toAssistantBlock, displayFailure, emptyAssistantBlock, isTokenDelta, toAssistantBlock,
toAssistantBlocks, toAssistantBlocks,
type AssistantBlock, type AssistantMessageNode, type ConversationLocation, type AssistantBlock, type AssistantMessageNode, type ConversationLocation,
type ConversationMatch, type ConversationNodeContext, type ConversationNodeDefinition, type ConversationMatch, type ConversationNodeContext, type ConversationNodeDefinition,
@@ -21,6 +21,7 @@ interface UsageValue {
interface RetryValue { interface RetryValue {
readonly message: string readonly message: string
readonly code?: string
readonly retry: number readonly retry: number
readonly maxRetries?: number readonly maxRetries?: number
readonly delayMs: number readonly delayMs: number
@@ -260,6 +261,7 @@ function assistantRequest(
? {} ? {}
: { : {
error: state.retry.message, error: state.retry.message,
...(state.retry.code === undefined ? {} : { errorCode: state.retry.code }),
retry: state.retry.retry, retry: state.retry.retry,
...(state.retry.maxRetries === undefined ? {} : { maxRetries: state.retry.maxRetries }), ...(state.retry.maxRetries === undefined ? {} : { maxRetries: state.retry.maxRetries }),
retryDelayMs: state.retry.delayMs, 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 === 'step/end') return { ...context.state, stepEnd: match }
if (match.event.type !== 'llm/retry') return context.state if (match.event.type !== 'llm/retry') return context.state
const data = match.event.data const data = match.event.data
const failure = displayFailure(data.failure)
return { return {
...initialState( ...initialState(
context.state.turn, context.state.turn,
@@ -326,7 +329,8 @@ const trajectoryAssistantDefinition: ConversationNodeDefinition<AssistantState>
firstTokenTime: context.state.firstTokenTime, firstTokenTime: context.state.firstTokenTime,
usage: context.state.usage, usage: context.state.usage,
retry: { retry: {
message: displayFailureMessage(data.failure), message: failure.message,
...(failure.code === undefined ? {} : { code: failure.code }),
retry: data.retry, retry: data.retry,
...(data.mode === 'normal' ? { maxRetries: data.maxRetries } : {}), ...(data.mode === 'normal' ? { maxRetries: data.maxRetries } : {}),
delayMs: data.delayMs, delayMs: data.delayMs,
@@ -363,6 +367,7 @@ interface TurnEndState {
readonly seq: number readonly seq: number
readonly time: number readonly time: number
readonly error?: string readonly error?: string
readonly errorCode?: string
} }
const trajectoryTurnEndDefinition: ConversationNodeDefinition<TurnEndState> = { const trajectoryTurnEndDefinition: ConversationNodeDefinition<TurnEndState> = {
@@ -376,11 +381,15 @@ const trajectoryTurnEndDefinition: ConversationNodeDefinition<TurnEndState> = {
throw new Error('trajectory-turn-end start requires turn/end') throw new Error('trajectory-turn-end start requires turn/end')
} }
const reason = match.event.data.reason const reason = match.event.data.reason
const failure = reason.kind === 'error' ? displayFailure(reason.error) : undefined
return { return {
turn: match.event.data.turn, turn: match.event.data.turn,
seq: match.event.seq, seq: match.event.seq,
time: match.event.time, 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, update: context => context.state,
@@ -391,6 +400,7 @@ const trajectoryTurnEndDefinition: ConversationNodeDefinition<TurnEndState> = {
turn: context.state.turn, turn: context.state.turn,
time: context.state.time, time: context.state.time,
...(context.state.error === undefined ? {} : { error: context.state.error }), ...(context.state.error === undefined ? {} : { error: context.state.error }),
...(context.state.errorCode === undefined ? {} : { errorCode: context.state.errorCode }),
}), }),
} }
/* jscpd:ignore-end */ /* jscpd:ignore-end */
@@ -48,6 +48,7 @@ export type TrajectoryContribution =
readonly turn: number readonly turn: number
readonly time: number readonly time: number
readonly error?: string readonly error?: string
readonly errorCode?: string
} }
/** Target envelope consumed by the Trajectory snapshot builder. */ /** Target envelope consumed by the Trajectory snapshot builder. */
@@ -113,7 +113,7 @@ function interruptCompactions(
function applyTurnErrors( function applyTurnErrors(
requests: RequestView[], requests: RequestView[],
endings: readonly { turn: number; time: number; error?: string }[], endings: readonly { turn: number; time: number; error?: string; errorCode?: string }[],
): void { ): void {
const lastAssistantByTurn = new Map<number, number>() const lastAssistantByTurn = new Map<number, number>()
for (const [index, request] of requests.entries()) { for (const [index, request] of requests.entries()) {
@@ -130,6 +130,7 @@ function applyTurnErrors(
completedAt: request.completedAt ?? ending.time, completedAt: request.completedAt ?? ending.time,
status: 'error', status: 'error',
error: ending.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 eventLocations = new Map<number, TrajectoryConversationViewNode['location']>()
const requests: RequestView[] = [] const requests: RequestView[] = []
const boundaries: { seq: number; time: number }[] = [] 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 callSchemas = new Map<string, ToolSchema>()
const consumedPromptChanges = new Set<number>() const consumedPromptChanges = new Set<number>()
let previousHeader: TrajectoryRequestHeaderState | undefined let previousHeader: TrajectoryRequestHeaderState | undefined
@@ -237,6 +243,7 @@ export class TrajectorySnapshotBuilder implements ConversationViewBuilder<
turn: data.turn, turn: data.turn,
time: data.time, time: data.time,
...(data.error === undefined ? {} : { error: data.error }), ...(data.error === undefined ? {} : { error: data.error }),
...(data.errorCode === undefined ? {} : { errorCode: data.errorCode }),
}) })
} }
@@ -143,6 +143,8 @@ describe('Trajectory conversation Definitions', () => {
expect(settled.requests).toMatchObject([{ expect(settled.requests).toMatchObject([{
purpose: 'assistant', purpose: 'assistant',
status: 'error', status: 'error',
error: 'temporary failure',
errorCode: 'TRANSPORT',
retry: 1, retry: 1,
maxRetries: 2, maxRetries: 2,
retryDelayMs: 25, retryDelayMs: 25,
@@ -186,6 +186,7 @@ describe('TrajectorySnapshotBuilder', () => {
turn: 1, turn: 1,
time: 5, time: 5,
error: 'turn failed', error: 'turn failed',
errorCode: 'AUTH',
}), }),
contribution('compact:10', 10, { contribution('compact:10', 10, {
kind: 'compaction', kind: 'compaction',
@@ -203,7 +204,9 @@ describe('TrajectorySnapshotBuilder', () => {
expect(snapshot.requests).toMatchObject([ expect(snapshot.requests).toMatchObject([
{ purpose: 'assistant', step: 1, status: 'complete' }, { 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: 10, status: 'error', completedAt: 16 },
{ purpose: 'compaction', startSeq: 12, status: 'error', completedAt: 14 }, { purpose: 'compaction', startSeq: 12, status: 'error', completedAt: 14 },
]) ])
@@ -6,7 +6,8 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
import type { ComponentProps } from 'react' import type { ComponentProps } from 'react'
import { TrajectoryTable as LocalizedTrajectoryTable } from '../src/client/TrajectoryTable.tsx' import { TrajectoryTable as LocalizedTrajectoryTable } from '../src/client/TrajectoryTable.tsx'
import type { TrajectoryTurnModel } from '../src/client/layout.ts' 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'>) { function TrajectoryTable(props: Omit<ComponentProps<typeof LocalizedTrajectoryTable>, 't'>) {
const inferred: Array<NonNullable<typeof props.requestNumbers>[number] & { firstIndex: number }> = [] const inferred: Array<NonNullable<typeof props.requestNumbers>[number] & { firstIndex: number }> = []
@@ -19,6 +20,7 @@ function TrajectoryTable(props: Omit<ComponentProps<typeof LocalizedTrajectoryTa
inferred.push({ inferred.push({
turn: turn.turn, turn: turn.turn,
step: 0, step: 0,
seq: Number(compaction),
group: group.title, group: group.title,
number: 0, number: 0,
purpose: 'compaction', purpose: 'compaction',
@@ -122,6 +124,22 @@ describe('TrajectoryTable', () => {
expect(screen.getByText('(tool call only)')).toBeTruthy() 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', () => { it('shows assistant timing facts after keyboard selection', () => {
render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />) render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
fireEvent.keyDown(screen.getByRole('row', { name: /ASSISTANT/ }), { key: 'Enter' }) fireEvent.keyDown(screen.getByRole('row', { name: /ASSISTANT/ }), { key: 'Enter' })
@@ -343,6 +361,41 @@ describe('TrajectoryTable', () => {
expect(screen.getByText('Request #2')).toBeTruthy() 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', () => { it('places the request boundary after leading steering input', () => {
const turns: readonly TrajectoryTurnModel[] = [{ const turns: readonly TrajectoryTurnModel[] = [{
turn: 1, turn: 1,
@@ -773,6 +826,42 @@ describe('TrajectoryTable', () => {
expect(recovered.style.getPropertyValue('--request-boundary-offset')).toBe('16px') 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', () => { it('shows the custom role tooltip only from the responsive icon', () => {
const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />) const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
const toolTag = view.container.querySelector<HTMLElement>('[data-role-kind="tool"]') const toolTag = view.container.querySelector<HTMLElement>('[data-role-kind="tool"]')
+15 -2
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' 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[] { function messages(source: string): string[] {
return findUiI18nViolations('packages/client/ui-example/src/client/View.tsx', source) 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 duration(): string { return 'Not recorded' }
function mode(): string { return 'compact' } 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 } 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', () => { it('accepts translated copy, dynamic values, structural attributes, and language tokens', () => {
+21 -16
View File
@@ -8,7 +8,7 @@
*/ */
import { globSync, readFileSync } from 'node:fs' import { globSync, readFileSync } from 'node:fs'
import { resolve, sep } from 'node:path' import { resolve } from 'node:path'
import ts from 'typescript' import ts from 'typescript'
const root = resolve(import.meta.dirname, '..') 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_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_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|placeholder|title|tooltip|tabs)$/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([ const IMMUTABLE_LANGUAGE_TOKENS = new Set([
'Function', 'Function',
'K', 'K',
'M', 'M',
'MB',
'Symbol', 'Symbol',
'false', 'false',
'function()', 'function()',
@@ -78,13 +79,6 @@ function containsProductText(text: string): boolean {
&& /\p{L}/u.test(normalized) && /\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 { function propertyName(node: ts.PropertyName | ts.BindingName): string | undefined {
return ts.isIdentifier(node) || ts.isStringLiteral(node) ? node.text : undefined return ts.isIdentifier(node) || ts.isStringLiteral(node) ? node.text : undefined
} }
@@ -161,7 +155,7 @@ export function findUiI18nViolations(file: string, sourceText: string): UiI18nVi
return return
} }
if (ts.isCallExpression(node)) { if (ts.isCallExpression(node)) {
if (translationCall(node)) return // A call result is dynamic; copy-bearing arguments are visited through their own syntax.
return return
} }
if ( 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) 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[] { function sourceFiles(): string[] {
const clientComponentRoots = new Set( const clientComponentRoots = new Set(
globSync('packages/*/*/src/client/**/*.tsx', { cwd: root }).map((file) => { globSync('packages/*/*/src/client/**/*.tsx', { cwd: root })
const marker = '/src/client/' .map(clientSourceRoot)
return file.slice(0, file.indexOf(marker) + marker.length - 1) .filter((clientRoot): clientRoot is string => clientRoot !== undefined),
}),
) )
return [...new Set([ return [...new Set([
...globSync('packages/client/*/src/**/*.tsx', { cwd: root }), ...globSync('packages/client/*/src/**/*.tsx', { cwd: root }),
@@ -307,7 +312,7 @@ function sourceFiles(): string[] {
globSync(`${clientRoot}/**/*.{ts,tsx}`, { cwd: root })), globSync(`${clientRoot}/**/*.{ts,tsx}`, { cwd: root })),
...globSync('apps/web/src/**/*.{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')) .filter(file => !file.endsWith('.d.ts'))
.sort() .sort()
} }