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
}
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>
@@ -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
View File
@@ -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. */
@@ -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.',
@@ -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',
])
})
@@ -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
}
@@ -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
@@ -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),
}
}
@@ -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'
@@ -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
} 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),
},
}
}
@@ -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' },
})
})
@@ -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
@@ -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 && (
@@ -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
const selectedRequestNumber = selectedRequestInfo?.number
const selectedRequestState: RecordState | undefined = selectedRequestInfo === undefined
? undefined
: requestNumbers.get(requestKey(selectedRequest.turn, selectedRequest.group))
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
: 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}
@@ -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
? {}
@@ -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',
@@ -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 */
@@ -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. */
@@ -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 }),
})
}
@@ -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,
@@ -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 },
])
@@ -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
View File
@@ -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
View File
@@ -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()
}