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
2026-08-10 19:50:20 +08:00
parent beb1d0601f
commit 53d9810461
9 changed files with 130 additions and 54 deletions

View File

@@ -10,11 +10,15 @@ import { sessionLogZipFilename } from '../src/client/export-log.ts'
describe('sessionLogZipFilename', () => {
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', () => {
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')
})
it('strips dots so a dot-only id cannot shape a dot segment', () => {
expect(sessionLogZipFilename('..')).toBe('dsh-session-__.zip')
})
})

View File

@@ -3,7 +3,12 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
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 { 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(() => {
cleanup()
@@ -25,6 +30,7 @@ function baseProps(overrides: Partial<TrajectoryToolbarProps> = {}): TrajectoryT
exporting: false,
onExport: vi.fn(),
exportError: null,
t: zhT,
...overrides,
}
}

View File

@@ -28,6 +28,8 @@ import {
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 { 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 as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory'
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. */
function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps {
function standaloneProps(
nodes: ConversationSnapshot['nodes'],
): ConvViewProps & { t: (key: LocaleKeysOf<'trajectory'>) => string } {
return {
sessionId: SID,
useSession: fakeSession(nodes).useSession,
useSessions: emptySessions(),
useWorkspaces: emptyWorkspaces(),
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. */
@@ -230,6 +236,7 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
exportLog: trajectory.exportLog,
useHistory: bindSnapshotSelector(trajectory.hooks.history),
useDuration: bindSnapshotSelector(trajectory.hooks.duration),
t: (key: TrajectoryKey) => zh[key],
}
})()
: injected
@@ -324,12 +331,12 @@ describe('tab switching in ConversationRoot', () => {
expect(screen.queryByText(/turns ·/)).toBeNull()
expect(view.container.querySelectorAll('tr[data-turn-start="true"]')).toHaveLength(2)
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(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()
fireEvent.click(screen.getByRole('button', { name: 'Expand turns' }))
fireEvent.click(screen.getByRole('button', { name: '展开轮次' }))
expect(screen.getByRole('row', { name: /USER/ })).toBeTruthy()
expect(screen.queryByTestId('chat-body')).toBeNull()
await vi.waitFor(() => {
@@ -540,13 +547,13 @@ describe('tab switching in ConversationRoot', () => {
const b = await bench(historySnapshot([]))
mount(b.slots)
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.getByRole<HTMLButtonElement>('button', {
name: 'Collapse turns',
name: '折叠轮次',
}).disabled).toBe(false)
expect(screen.getByRole<HTMLButtonElement>('button', {
name: 'Collapse calls',
name: '折叠调用',
}).disabled).toBe(false)
expect(screen.queryByRole('row')).toBeNull()
expect(screen.queryByText(/turns ·/)).toBeNull()
@@ -1090,7 +1097,7 @@ describe('timeline projection', () => {
...standaloneExport(),
},
))
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
expect(screen.getByRole('toolbar', { name: '轨迹工具栏' })).toBeTruthy()
expect(screen.queryByRole('row')).toBeNull()
})
})
@@ -1123,7 +1130,6 @@ describe('session log export', () => {
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.
await vi.waitFor(() => {
expect(createObjectURL).toHaveBeenCalled()
})
@@ -1159,7 +1165,7 @@ describe('TrajectoryView branches', () => {
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')
fireEvent.click(duration)
@@ -1175,7 +1181,7 @@ describe('TrajectoryView branches', () => {
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')
})