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