fix(web): localize the trajectory toolbar and simplify the download

Toolbar strings route through the locale dictionary's standard t seat (the
export button no longer mixes languages in the English golden), exportLog
passes the response blob straight to the browser save instead of copying it
three times, the client id sanitizer rejects dot segments like the host one,
and the fixture stub comment no longer misattributes the 404.
This commit is contained in:
_Kerman committed 2026-08-10 19:50:20 +08:00
1 parent beb1d0601f
commit 53d9810461
9 files changed
+130 -54

No files matched your search

@@ -2834,9 +2834,9 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
}) })
return Promise.resolve({ accepted: true }) return Promise.resolve({ accepted: true })
}, },
// The host-only streaming download has no in-memory counterpart: fixture // Satisfies the ApiProxy contract type only: the browser export button
// mode answers 404 so the export button's error bar explains the gap // fetches GET /api/session.export directly (window.fetch), so this stub is
// instead of hanging. // never reached through the fixture's dispatch.
downloads: { downloads: {
sessionLog: () => Promise.resolve(new Response('fixture mode does not serve session export', { status: 404 })), sessionLog: () => Promise.resolve(new Response('fixture mode does not serve session export', { status: 404 })),
}, },
@@ -1,6 +1,8 @@
/** Trajectory toolbar: timeline and ledger fold controls. */ /** Trajectory toolbar: timeline and ledger fold controls. */
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
import { IconSearchOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import { IconSearchOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { NS } from './locales.ts'
import css from './TrajectoryToolbar.module.css' import css from './TrajectoryToolbar.module.css'
export interface TrajectoryToolbarProps { export interface TrajectoryToolbarProps {
@@ -30,6 +32,8 @@ export interface TrajectoryToolbarProps {
onExport: () => void onExport: () => void
/** Export failure message, shown while set; null while idle or successful. */ /** Export failure message, shown while set; null while idle or successful. */
exportError: string | null exportError: string | null
/** Translate a toolbar dictionary key. */
t: TranslateNS<typeof NS>
} }
/** /**
@@ -51,17 +55,18 @@ export function TrajectoryToolbar({
exporting, exporting,
onExport, onExport,
exportError, exportError,
t,
}: TrajectoryToolbarProps) { }: TrajectoryToolbarProps) {
return ( return (
<div className={css.root} role="toolbar" aria-label="Trajectory toolbar"> <div className={css.root} role="toolbar" aria-label={t('toolbar.aria')}>
<div className={css.inner}> <div className={css.inner}>
<div className={css.actions}> <div className={css.actions}>
<button <button
type="button" type="button"
className={css.toggle} className={css.toggle}
aria-label="Use actual duration" aria-label={t('toolbar.useActualDuration')}
aria-pressed={actualDuration} aria-pressed={actualDuration}
title={actualDuration ? 'Use equal-width operations' : 'Use actual duration'} title={actualDuration ? t('toolbar.useEqualWidth') : t('toolbar.useActualDuration')}
onClick={() => { onActualDurationChange(!actualDuration) }} onClick={() => { onActualDurationChange(!actualDuration) }}
> >
<svg <svg
@@ -73,7 +78,7 @@ export function TrajectoryToolbar({
<circle cx="8" cy="8" r="5.25" /> <circle cx="8" cy="8" r="5.25" />
<path d="M8 4.75V8l2.25 1.5" /> <path d="M8 4.75V8l2.25 1.5" />
</svg> </svg>
Duration {t('toolbar.duration')}
</button> </button>
<button <button
type="button" type="button"
@@ -83,7 +88,7 @@ export function TrajectoryToolbar({
hidden hidden
onClick={() => { onActualTimeChange(!actualTime) }} onClick={() => { onActualTimeChange(!actualTime) }}
> >
<span>Actual time</span> <span>{t('toolbar.actualTime')}</span>
<span className={css.controlTrack} data-on={actualTime || undefined} aria-hidden="true"> <span className={css.controlTrack} data-on={actualTime || undefined} aria-hidden="true">
<span className={css.controlThumb} /> <span className={css.controlThumb} />
</span> </span>
@@ -91,34 +96,34 @@ export function TrajectoryToolbar({
<button <button
type="button" type="button"
className={css.action} className={css.action}
aria-label={allTurnsCollapsed ? 'Expand turns' : 'Collapse turns'} aria-label={allTurnsCollapsed ? t('toolbar.expandTurns') : t('toolbar.collapseTurns')}
aria-pressed={allTurnsCollapsed} aria-pressed={allTurnsCollapsed}
title={allTurnsCollapsed ? 'Expand turns' : 'Collapse turns'} title={allTurnsCollapsed ? t('toolbar.expandTurns') : t('toolbar.collapseTurns')}
onClick={onToggleAllTurns} onClick={onToggleAllTurns}
> >
<span className={css.actionIcon} aria-hidden="true"> <span className={css.actionIcon} aria-hidden="true">
{allTurnsCollapsed ? '⊞' : '⊟'} {allTurnsCollapsed ? '⊞' : '⊟'}
</span> </span>
Turns {t('toolbar.turns')}
</button> </button>
<button <button
type="button" type="button"
className={css.action} className={css.action}
aria-label={allAssistantsCollapsed ? 'Expand calls' : 'Collapse calls'} aria-label={allAssistantsCollapsed ? t('toolbar.expandCalls') : t('toolbar.collapseCalls')}
aria-pressed={allAssistantsCollapsed} aria-pressed={allAssistantsCollapsed}
title={allAssistantsCollapsed ? 'Expand calls' : 'Collapse calls'} title={allAssistantsCollapsed ? t('toolbar.expandCalls') : t('toolbar.collapseCalls')}
onClick={onToggleAllAssistants} onClick={onToggleAllAssistants}
> >
<span className={css.actionIcon} aria-hidden="true"> <span className={css.actionIcon} aria-hidden="true">
{allAssistantsCollapsed ? '⊞' : '⊟'} {allAssistantsCollapsed ? '⊞' : '⊟'}
</span> </span>
Calls {t('toolbar.calls')}
</button> </button>
<button <button
type="button" type="button"
className={css.export} className={css.export}
aria-label="导出会话日志" aria-label={t('toolbar.exportAria')}
title={exportError ?? (exporting ? '导出中…' : '导出会话日志(ZIP,含子代理)')} title={exportError ?? (exporting ? t('toolbar.exporting') : t('toolbar.exportTitle'))}
disabled={exporting} disabled={exporting}
onClick={onExport} onClick={onExport}
> >
@@ -126,7 +131,7 @@ export function TrajectoryToolbar({
<path d="M8 3v7m0 0 3-3m-3 3L5 7" /> <path d="M8 3v7m0 0 3-3m-3 3L5 7" />
<path d="M3 11.5V13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-1.5" /> <path d="M3 11.5V13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-1.5" />
</svg> </svg>
导出 {t('toolbar.export')}
</button> </button>
</div> </div>
<div className={css.search}> <div className={css.search}>
@@ -134,8 +139,8 @@ export function TrajectoryToolbar({
<input <input
type="search" type="search"
className={css.searchInput} className={css.searchInput}
aria-label="Search trajectory" aria-label={t('toolbar.search')}
placeholder="Search" placeholder={t('toolbar.searchPlaceholder')}
value={searchQuery} value={searchQuery}
onChange={(event) => { onSearchQueryChange(event.currentTarget.value) }} onChange={(event) => { onSearchQueryChange(event.currentTarget.value) }}
/> />
@@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots' import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { import type {
AssistantBlock, AssistantMessageNode, ConversationContext, ConversationSnapshot, AssistantBlock, AssistantMessageNode, ConversationContext, ConversationSnapshot,
SessionHistoryFace, SnapshotStore, SessionHistoryFace, SnapshotStore,
@@ -187,8 +187,8 @@ function mergeSearchMatches(
export function TrajectoryView({ export function TrajectoryView({
useHistory, useDuration, loadHistoryTail, loadOlderHistory, setActualDuration, exportLog, useHistory, useDuration, loadHistoryTail, loadOlderHistory, setActualDuration, exportLog,
inspect, onInspectDone, inspect, onInspectDone, t,
}: ConvViewProps & InjectFace<TrajectoryViewInjected>) { }: ConvViewProps & InjectFace<TrajectoryViewInjected> & PropsLocale<'trajectory'>) {
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_TURN_IDS) const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_TURN_IDS)
const [collapsedAssistants, setCollapsedAssistants] = const [collapsedAssistants, setCollapsedAssistants] =
useState<ReadonlySet<string>>(EMPTY_RECORD_IDS) useState<ReadonlySet<string>>(EMPTY_RECORD_IDS)
@@ -563,6 +563,7 @@ export function TrajectoryView({
exporting={exporting} exporting={exporting}
onExport={onExport} onExport={onExport}
exportError={exportError} exportError={exportError}
t={t}
/> />
{exportError !== null && ( {exportError !== null && (
<div className={css.exportError} role="alert"> <div className={css.exportError} role="alert">
@@ -7,11 +7,13 @@
/** /**
* Collapse an untrusted session id into one safe path/filename segment. * Collapse an untrusted session id into one safe path/filename segment.
* Distinct ids may collapse onto one segment (impossible for the host-minted
* UUIDs, so no uniqueness suffix is kept).
* @param id - the raw session id. * @param id - the raw session id.
* @returns a filesystem-safe single segment. * @returns a filesystem-safe single segment.
*/ */
function safeSessionIdSegment(id: string): string { function safeSessionIdSegment(id: string): string {
return id.replace(/[^A-Za-z0-9._-]/g, '_') return id.replace(/[^A-Za-z0-9_-]/g, '_')
} }
/** /**
@@ -25,16 +27,16 @@ export function sessionLogZipFilename(sessionId: string): string {
} }
/** /**
* Trigger a browser download of raw bytes. * Trigger a browser download of a blob response.
* @param bytes - the file content. * @param blob - the response body to save (passed straight through, no copy).
* @param filename - the download filename. * @param filename - the download filename.
* @param type - the MIME type.
*/ */
export function downloadBytes(bytes: Uint8Array<ArrayBuffer>, filename: string, type: string): void { export function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(new Blob([bytes], { type })) const url = URL.createObjectURL(blob)
const anchor = document.createElement('a') const anchor = document.createElement('a')
anchor.href = url anchor.href = url
anchor.download = filename anchor.download = filename
anchor.click() anchor.click()
URL.revokeObjectURL(url) // Revoke one tick later: some browsers read the blob URL after click().
setTimeout(() => URL.revokeObjectURL(url), 0)
} }
@@ -12,7 +12,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { createTrajectoryDurationStore } from './duration-store.ts' import { createTrajectoryDurationStore } from './duration-store.ts'
import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx' import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx'
import { en, NS, zh } from './locales.ts' import { en, NS, zh } from './locales.ts'
import { downloadBytes, sessionLogZipFilename } from './export-log.ts' import { downloadBlob, sessionLogZipFilename } from './export-log.ts'
/** Required services: the conversation view slot, the independent history source, and the locale service. */ /** Required services: the conversation view slot, the independent history source, and the locale service. */
export const inject = ['slots', 'sessionHistory', 'locale'] export const inject = ['slots', 'sessionHistory', 'locale']
@@ -33,6 +33,7 @@ export function apply(ctx: Context): void {
name: 'conversation.view', name: 'conversation.view',
id: 'trajectory', id: 'trajectory',
order: 10, order: 10,
locale: NS,
label: () => t('view.trajectory'), label: () => t('view.trajectory'),
inject: (sessionId: SessionId): TrajectoryViewInjected => { inject: (sessionId: SessionId): TrajectoryViewInjected => {
const history = ctx.sessionHistory.source(sessionId) const history = ctx.sessionHistory.source(sessionId)
@@ -44,7 +45,11 @@ export function apply(ctx: Context): void {
exportLog: async () => { exportLog: async () => {
// The host streams the ZIP (root + descendant artifacts verbatim) // The host streams the ZIP (root + descendant artifacts verbatim)
// from GET /api/session.export; the browser downloads the response. // from GET /api/session.export; the browser downloads the response.
const url = new URL('/api/session.export', window.location.origin) // A null origin (no-location Node contexts) falls back like the
// carrier's resolveBase so the URL stays valid.
const loc = (globalThis as { location?: { origin?: string } }).location
const origin = loc?.origin !== undefined && loc.origin !== 'null' ? loc.origin : 'http://dsh.internal'
const url = new URL('/api/session.export', origin)
url.searchParams.set('sessionId', sessionId) url.searchParams.set('sessionId', sessionId)
url.searchParams.set('includeDescendants', 'true') url.searchParams.set('includeDescendants', 'true')
const response = await fetch(url) const response = await fetch(url)
@@ -52,12 +57,7 @@ export function apply(ctx: Context): void {
const detail = await response.text().catch(() => '') const detail = await response.text().catch(() => '')
throw new Error(`导出失败:HTTP ${response.status}${detail === '' ? '' : ` ${detail}`}`) throw new Error(`导出失败:HTTP ${response.status}${detail === '' ? '' : ` ${detail}`}`)
} }
const blob = await response.blob() downloadBlob(await response.blob(), sessionLogZipFilename(sessionId))
downloadBytes(
new Uint8Array(await blob.arrayBuffer()),
sessionLogZipFilename(sessionId),
'application/zip',
)
}, },
} }
}, },
@@ -1,14 +1,32 @@
/** `trajectory` namespace dictionaries (the view tab label). */ /** `trajectory` namespace dictionaries (view tab label + toolbar strings). */
/** Dictionary namespace owned by this plugin. */ /** Dictionary namespace owned by this plugin. */
export const NS = 'trajectory' export const NS = 'trajectory'
/** The trajectory dictionary key set (the source of truth for both locales). */ /** The trajectory dictionary key set (the source of truth for both locales). */
export type TrajectoryKey = 'view.trajectory' export type TrajectoryKey =
| 'view.trajectory'
| 'toolbar.aria'
| 'toolbar.duration'
| 'toolbar.useActualDuration'
| 'toolbar.useEqualWidth'
| 'toolbar.actualTime'
| 'toolbar.turns'
| 'toolbar.expandTurns'
| 'toolbar.collapseTurns'
| 'toolbar.calls'
| 'toolbar.expandCalls'
| 'toolbar.collapseCalls'
| 'toolbar.export'
| 'toolbar.exportAria'
| 'toolbar.exporting'
| 'toolbar.exportTitle'
| 'toolbar.search'
| 'toolbar.searchPlaceholder'
declare module '@deepseek-ai/dsh-client-ui-slots' { declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap { interface LocaleNamespaceMap {
/** The trajectory view tab label. */ /** The trajectory view tab label and toolbar strings. */
'trajectory': TrajectoryKey 'trajectory': TrajectoryKey
} }
} }
@@ -16,9 +34,43 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
/** Simplified Chinese dictionary (the key-set source of truth). */ /** Simplified Chinese dictionary (the key-set source of truth). */
export const zh: Record<TrajectoryKey, string> = { export const zh: Record<TrajectoryKey, string> = {
'view.trajectory': '轨迹', 'view.trajectory': '轨迹',
'toolbar.aria': '轨迹工具栏',
'toolbar.duration': '时长',
'toolbar.useActualDuration': '使用实际时长',
'toolbar.useEqualWidth': '使用等宽时长',
'toolbar.actualTime': '实际时间',
'toolbar.turns': '轮次',
'toolbar.expandTurns': '展开轮次',
'toolbar.collapseTurns': '折叠轮次',
'toolbar.calls': '调用',
'toolbar.expandCalls': '展开调用',
'toolbar.collapseCalls': '折叠调用',
'toolbar.export': '导出',
'toolbar.exportAria': '导出会话日志',
'toolbar.exporting': '导出中…',
'toolbar.exportTitle': '导出会话日志(ZIP,含子代理)',
'toolbar.search': '搜索轨迹',
'toolbar.searchPlaceholder': '搜索',
} }
/** English dictionary. */ /** English dictionary. */
export const en: Record<TrajectoryKey, string> = { export const en: Record<TrajectoryKey, string> = {
'view.trajectory': 'Trajectory', 'view.trajectory': 'Trajectory',
'toolbar.aria': 'Trajectory toolbar',
'toolbar.duration': 'Duration',
'toolbar.useActualDuration': 'Use actual duration',
'toolbar.useEqualWidth': 'Use equal-width operations',
'toolbar.actualTime': 'Actual time',
'toolbar.turns': 'Turns',
'toolbar.expandTurns': 'Expand turns',
'toolbar.collapseTurns': 'Collapse turns',
'toolbar.calls': 'Calls',
'toolbar.expandCalls': 'Expand calls',
'toolbar.collapseCalls': 'Collapse calls',
'toolbar.export': 'Export',
'toolbar.exportAria': 'Export session log',
'toolbar.exporting': 'Exporting…',
'toolbar.exportTitle': 'Export session log (ZIP, includes subagents)',
'toolbar.search': 'Search trajectory',
'toolbar.searchPlaceholder': 'Search',
} }
@@ -10,11 +10,15 @@ import { sessionLogZipFilename } from '../src/client/export-log.ts'
describe('sessionLogZipFilename', () => { describe('sessionLogZipFilename', () => {
it('keeps safe session ids verbatim', () => { it('keeps safe session ids verbatim', () => {
expect(sessionLogZipFilename('session-abc_1.2')).toBe('dsh-session-session-abc_1.2.zip') expect(sessionLogZipFilename('session-abc_1-2')).toBe('dsh-session-session-abc_1-2.zip')
}) })
it('neutralizes unsafe id characters that could shape the filename', () => { it('neutralizes unsafe id characters that could shape the filename', () => {
expect(sessionLogZipFilename('../evil')).toBe('dsh-session-.._evil.zip') expect(sessionLogZipFilename('../evil')).toBe('dsh-session-___evil.zip')
expect(sessionLogZipFilename('a/b')).toBe('dsh-session-a_b.zip') expect(sessionLogZipFilename('a/b')).toBe('dsh-session-a_b.zip')
}) })
it('strips dots so a dot-only id cannot shape a dot segment', () => {
expect(sessionLogZipFilename('..')).toBe('dsh-session-__.zip')
})
}) })
@@ -3,7 +3,12 @@
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import type { LocaleKeysOf } from '@deepseek-ai/dsh-client-ui-slots'
import { TrajectoryToolbar, type TrajectoryToolbarProps } from '../src/client/TrajectoryToolbar.tsx' import { TrajectoryToolbar, type TrajectoryToolbarProps } from '../src/client/TrajectoryToolbar.tsx'
import { zh, type TrajectoryKey } from '../src/client/locales.ts'
/** Test translator pinned to the Simplified Chinese dictionary. */
const zhT = (key: LocaleKeysOf<'trajectory'>): string => zh[key as TrajectoryKey] ?? key
afterEach(() => { afterEach(() => {
cleanup() cleanup()
@@ -25,6 +30,7 @@ function baseProps(overrides: Partial<TrajectoryToolbarProps> = {}): TrajectoryT
exporting: false, exporting: false,
onExport: vi.fn(), onExport: vi.fn(),
exportError: null, exportError: null,
t: zhT,
...overrides, ...overrides,
} }
} }
@@ -28,6 +28,8 @@ import {
import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts' import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts'
import { zh as conversationZh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' import { zh as conversationZh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
import { apply as localeApply, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' import { apply as localeApply, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
import type { LocaleKeysOf } from '@deepseek-ai/dsh-client-ui-slots'
import { zh, type TrajectoryKey } from '../src/client/locales.ts'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory' import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory'
import type { TrajectoryTurnModel } from '../src/client/layout.ts' import type { TrajectoryTurnModel } from '../src/client/layout.ts'
@@ -142,14 +144,18 @@ function emptyWorkspaces() {
} }
/** Standalone view props: the session-scope standard kit the outlet would bake. */ /** Standalone view props: the session-scope standard kit the outlet would bake. */
function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps { function standaloneProps(
nodes: ConversationSnapshot['nodes'],
): ConvViewProps & { t: (key: LocaleKeysOf<'trajectory'>) => string } {
return { return {
sessionId: SID, sessionId: SID,
useSession: fakeSession(nodes).useSession, useSession: fakeSession(nodes).useSession,
useSessions: emptySessions(), useSessions: emptySessions(),
useWorkspaces: emptyWorkspaces(), useWorkspaces: emptyWorkspaces(),
useProjection: (() => undefined) as never, useProjection: (() => undefined) as never,
} as unknown as ConvViewProps // The locale seat the outlet would inject for the declared namespace.
t: (key: LocaleKeysOf<'trajectory'>) => zh[key as TrajectoryKey] ?? key,
} as unknown as ConvViewProps & { t: (key: LocaleKeysOf<'trajectory'>) => string }
} }
/** Real-stack bench: root Context + real SlotsService ring + the plugin fiber. */ /** Real-stack bench: root Context + real SlotsService ring + the plugin fiber. */
@@ -230,6 +236,7 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
exportLog: trajectory.exportLog, exportLog: trajectory.exportLog,
useHistory: bindSnapshotSelector(trajectory.hooks.history), useHistory: bindSnapshotSelector(trajectory.hooks.history),
useDuration: bindSnapshotSelector(trajectory.hooks.duration), useDuration: bindSnapshotSelector(trajectory.hooks.duration),
t: (key: TrajectoryKey) => zh[key],
} }
})() })()
: injected : injected
@@ -324,12 +331,12 @@ describe('tab switching in ConversationRoot', () => {
expect(screen.queryByText(/turns ·/)).toBeNull() expect(screen.queryByText(/turns ·/)).toBeNull()
expect(view.container.querySelectorAll('tr[data-turn-start="true"]')).toHaveLength(2) expect(view.container.querySelectorAll('tr[data-turn-start="true"]')).toHaveLength(2)
expect(screen.queryByRole('columnheader')).toBeNull() expect(screen.queryByRole('columnheader')).toBeNull()
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy() expect(screen.getByRole('toolbar', { name: '轨迹工具栏' })).toBeTruthy()
expect(screen.getByRole('region', { name: 'Trajectory timeline' })).toBeTruthy() expect(screen.getByRole('region', { name: 'Trajectory timeline' })).toBeTruthy()
expect(view.container.querySelector('[data-conversation-composer-overlay]')).toBeTruthy() expect(view.container.querySelector('[data-conversation-composer-overlay]')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Collapse turns' })) fireEvent.click(screen.getByRole('button', { name: '折叠轮次' }))
expect(view.container.querySelector('[data-collapsed-summary="turn"]')).toBeTruthy() expect(view.container.querySelector('[data-collapsed-summary="turn"]')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Expand turns' })) fireEvent.click(screen.getByRole('button', { name: '展开轮次' }))
expect(screen.getByRole('row', { name: /USER/ })).toBeTruthy() expect(screen.getByRole('row', { name: /USER/ })).toBeTruthy()
expect(screen.queryByTestId('chat-body')).toBeNull() expect(screen.queryByTestId('chat-body')).toBeNull()
await vi.waitFor(() => { await vi.waitFor(() => {
@@ -540,13 +547,13 @@ describe('tab switching in ConversationRoot', () => {
const b = await bench(historySnapshot([])) const b = await bench(historySnapshot([]))
mount(b.slots) mount(b.slots)
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy() expect(screen.getByRole('toolbar', { name: '轨迹工具栏' })).toBeTruthy()
expect(screen.getByText('No timing data')).toBeTruthy() expect(screen.getByText('No timing data')).toBeTruthy()
expect(screen.getByRole<HTMLButtonElement>('button', { expect(screen.getByRole<HTMLButtonElement>('button', {
name: 'Collapse turns', name: '折叠轮次',
}).disabled).toBe(false) }).disabled).toBe(false)
expect(screen.getByRole<HTMLButtonElement>('button', { expect(screen.getByRole<HTMLButtonElement>('button', {
name: 'Collapse calls', name: '折叠调用',
}).disabled).toBe(false) }).disabled).toBe(false)
expect(screen.queryByRole('row')).toBeNull() expect(screen.queryByRole('row')).toBeNull()
expect(screen.queryByText(/turns ·/)).toBeNull() expect(screen.queryByText(/turns ·/)).toBeNull()
@@ -1090,7 +1097,7 @@ describe('timeline projection', () => {
...standaloneExport(), ...standaloneExport(),
}, },
)) ))
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy() expect(screen.getByRole('toolbar', { name: '轨迹工具栏' })).toBeTruthy()
expect(screen.queryByRole('row')).toBeNull() expect(screen.queryByRole('row')).toBeNull()
}) })
}) })
@@ -1123,7 +1130,6 @@ describe('session log export', () => {
expect(fetchMock).toHaveBeenCalledOnce() expect(fetchMock).toHaveBeenCalledOnce()
}) })
// The blob download lands a few microtasks after the fetch settles. // The blob download lands a few microtasks after the fetch settles.
// The blob download lands a few microtasks after the fetch settles.
await vi.waitFor(() => { await vi.waitFor(() => {
expect(createObjectURL).toHaveBeenCalled() expect(createObjectURL).toHaveBeenCalled()
}) })
@@ -1159,7 +1165,7 @@ describe('TrajectoryView branches', () => {
setActualDuration={(value) => { firstDuration.set(value) }} setActualDuration={(value) => { firstDuration.set(value) }}
/>, />,
) )
const duration = screen.getByRole('button', { name: 'Use actual duration' }) const duration = screen.getByRole('button', { name: '使用实际时长' })
expect(duration.getAttribute('aria-pressed')).toBe('false') expect(duration.getAttribute('aria-pressed')).toBe('false')
fireEvent.click(duration) fireEvent.click(duration)
@@ -1175,7 +1181,7 @@ describe('TrajectoryView branches', () => {
setActualDuration={(value) => { restoredDuration.set(value) }} setActualDuration={(value) => { restoredDuration.set(value) }}
/>, />,
) )
expect(screen.getByRole('button', { name: 'Use actual duration' }).getAttribute('aria-pressed')) expect(screen.getByRole('button', { name: '使用实际时长' }).getAttribute('aria-pressed'))
.toBe('true') .toBe('true')
}) })