mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
fix(client): address localization review findings
This commit is contained in:
@@ -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 }),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]')
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user