fix(trajectory): use runtime preference persistence

This commit is contained in:
_Kerman
2026-07-31 14:09:56 +08:00
parent 4b8316dcd5
commit d9d1c3e079
5 changed files with 86 additions and 39 deletions
@@ -24,6 +24,7 @@
}, },
"dshClient": { "dshClient": {
"inject": [ "inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-conversation" "@deepseek-ai/dsh-client-ui-conversation"
], ],
"platform": "web" "platform": "web"
@@ -37,6 +38,7 @@
"diff": "^9.0.0" "diff": "^9.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"cordis": "^4.0.0-rc.7", "cordis": "^4.0.0-rc.7",
@@ -5,7 +5,7 @@ import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/clie
import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots' import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots'
import type { import type {
AssistantMessageNode, ConversationContext, AssistantMessageNode, ConversationContext,
SessionHistoryFace, SessionHistoryFace, SnapshotStore,
} from '@deepseek-ai/dsh-client-runtime/client' } from '@deepseek-ai/dsh-client-runtime/client'
import { import {
deriveTrajectoryContextBranches, trajectoryBranchContainsRequest, deriveTrajectoryContextBranches, trajectoryBranchContainsRequest,
@@ -26,34 +26,15 @@ import {
import css from './views.module.css' import css from './views.module.css'
const EMPTY_IDS: ReadonlySet<number> = new Set() const EMPTY_IDS: ReadonlySet<number> = new Set()
const DURATION_STORAGE_KEY = 'dsh.trajectory.duration'
/** Restore the browser-wide duration preference; absent or unreadable storage defaults off. */
function restoreActualDuration(): boolean {
if (typeof localStorage === 'undefined') return false
try {
return localStorage.getItem(DURATION_STORAGE_KEY) === 'true'
} catch {
// Storage access can throw in privacy mode; the default remains usable.
return false
}
}
/** Persist the browser-wide duration preference without making storage availability fatal. */
function persistActualDuration(actualDuration: boolean): void {
if (typeof localStorage === 'undefined') return
try {
localStorage.setItem(DURATION_STORAGE_KEY, String(actualDuration))
} catch {
// Storage access can throw in privacy mode or at quota; this mount still
// keeps the selected preference in React state.
}
}
/** Session-history paging needed by the event-complete trajectory view. */ /** Session-history paging needed by the event-complete trajectory view. */
export interface TrajectoryViewInjected { export interface TrajectoryViewInjected {
hooks: { history: SessionHistoryFace } hooks: {
history: SessionHistoryFace
duration: SnapshotStore<boolean>
}
loadAllHistory: (signal: AbortSignal) => Promise<void> loadAllHistory: (signal: AbortSignal) => Promise<void>
setActualDuration: (actualDuration: boolean) => void
} }
interface UsageLike { interface UsageLike {
@@ -157,7 +138,7 @@ function searchMatches(
} }
export function TrajectoryView({ export function TrajectoryView({
useHistory, loadAllHistory, inspect, onInspectDone, useHistory, useDuration, loadAllHistory, setActualDuration, inspect, onInspectDone,
}: ConvViewProps & InjectFace<TrajectoryViewInjected>) { }: ConvViewProps & InjectFace<TrajectoryViewInjected>) {
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_IDS) const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_IDS)
const [collapsedAssistants, setCollapsedAssistants] = const [collapsedAssistants, setCollapsedAssistants] =
@@ -166,7 +147,7 @@ export function TrajectoryView({
branchId: number branchId: number
range: TrajectoryTimeRange range: TrajectoryTimeRange
} | null>(null) } | null>(null)
const [actualDuration, setActualDuration] = useState(restoreActualDuration) const actualDuration = useDuration(value => value)
const [actualTime, setActualTime] = useState(false) const [actualTime, setActualTime] = useState(false)
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [selectedTimelineIndex, setSelectedTimelineIndex] = useState<number | null>(null) const [selectedTimelineIndex, setSelectedTimelineIndex] = useState<number | null>(null)
@@ -480,7 +461,6 @@ export function TrajectoryView({
<TrajectoryToolbar <TrajectoryToolbar
actualDuration={actualDuration} actualDuration={actualDuration}
onActualDurationChange={(nextActualDuration) => { onActualDurationChange={(nextActualDuration) => {
persistActualDuration(nextActualDuration)
setActualDuration(nextActualDuration) setActualDuration(nextActualDuration)
setTimelineSelection(null) setTimelineSelection(null)
}} }}
@@ -0,0 +1,13 @@
import {
createSnapshotStore, type SnapshotStore,
} from '@deepseek-ai/dsh-client-runtime/client'
/**
* Create the browser-wide trajectory duration preference source.
* @returns a persisted source shared by every session view in one plugin lifecycle.
*/
export function createTrajectoryDurationStore(): SnapshotStore<boolean> {
return createSnapshotStore(false, {
persist: { name: 'dsh.trajectory.duration' },
})
}
@@ -7,6 +7,7 @@ import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: the 'conversation.view' SlotMap row (declared by the slot's // Type-only: the 'conversation.view' SlotMap row (declared by the slot's
// owning package) must be in the program for the register calls to type. // owning package) must be in the program for the register calls to type.
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { createTrajectoryDurationStore } from './duration-store.ts'
import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx' import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx'
/** /**
@@ -24,6 +25,7 @@ export const inject = ['slots', 'conversation', 'sessionHistory']
* @param ctx - client root context. * @param ctx - client root context.
*/ */
export function apply(ctx: Context): void { export function apply(ctx: Context): void {
const duration = createTrajectoryDurationStore()
ctx.slots.register({ ctx.slots.register({
name: 'conversation.view', name: 'conversation.view',
id: 'trajectory', id: 'trajectory',
@@ -32,8 +34,9 @@ export function apply(ctx: Context): void {
inject: (sessionId: SessionId): TrajectoryViewInjected => { inject: (sessionId: SessionId): TrajectoryViewInjected => {
const history = ctx.sessionHistory.source(sessionId) const history = ctx.sessionHistory.source(sessionId)
return { return {
hooks: { history }, hooks: { history, duration },
loadAllHistory: signal => history.loadAll(signal), loadAllHistory: signal => history.loadAll(signal),
setActualDuration: (value) => { duration.set(value) },
} }
}, },
}, TrajectoryView) }, TrajectoryView)
@@ -31,6 +31,7 @@ import { TrajectoryTimeline } from '../src/client/TrajectoryTimeline.tsx'
import { import {
TrajectoryView, type TrajectoryViewInjected, TrajectoryView, type TrajectoryViewInjected,
} from '../src/client/TrajectoryView.tsx' } from '../src/client/TrajectoryView.tsx'
import { createTrajectoryDurationStore } from '../src/client/duration-store.ts'
import { deriveTrajectoryTimeline } from '../src/client/timeline.ts' import { deriveTrajectoryTimeline } from '../src/client/timeline.ts'
const SID = 's1' as SessionId const SID = 's1' as SessionId
@@ -96,6 +97,16 @@ function standaloneHistory(
} }
} }
function standaloneDuration(): Pick<
ComponentProps<typeof TrajectoryView>, 'useDuration' | 'setActualDuration'
> {
const duration = createSnapshotStore(false)
return {
useDuration: bindSnapshotSelector(duration),
setActualDuration: (value) => { duration.set(value) },
}
}
function fakeSession(nodes: ConversationSnapshot['nodes']) { function fakeSession(nodes: ConversationSnapshot['nodes']) {
const store = createSnapshotStore({ const store = createSnapshotStore({
nodes, pending: [], partial: null, nodes, pending: [], partial: null,
@@ -187,12 +198,15 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
? {} ? {}
: injectEntry(SID) : injectEntry(SID)
const injectedProps = 'hooks' in injected const injectedProps = 'hooks' in injected
? { ? (() => {
loadAllHistory: (injected as TrajectoryViewInjected).loadAllHistory, const trajectory = injected as TrajectoryViewInjected
useHistory: bindSnapshotSelector( return {
(injected as TrajectoryViewInjected).hooks.history, loadAllHistory: trajectory.loadAllHistory,
), setActualDuration: trajectory.setActualDuration,
useHistory: bindSnapshotSelector(trajectory.hooks.history),
useDuration: bindSnapshotSelector(trajectory.hooks.duration),
} }
})()
: injected : injected
return ( return (
<View <View
@@ -241,6 +255,24 @@ describe('plugin registration', () => {
await b.fiber.dispose() await b.fiber.dispose()
expect(tabsOf(b.slots).map(v => v.id)).toEqual(['chat']) expect(tabsOf(b.slots).map(v => v.id)).toEqual(['chat'])
}) })
it('shares one browser-wide duration preference across session injections', async () => {
const b = await bench()
const entry = b.slots.entries('conversation.view')
.find(candidate => candidate.options.id === 'trajectory')
expect(entry).toBeDefined()
const injectEntry = entry!.inject as unknown as (
sessionId: SessionId,
) => TrajectoryViewInjected
const first = injectEntry(SID)
const second = injectEntry('s2' as SessionId)
expect(second.hooks.duration).toBe(first.hooks.duration)
first.setActualDuration(true)
expect(second.hooks.duration.getSnapshot()).toBe(true)
expect(localStorage.getItem('dsh.trajectory.duration')).toBe('true')
expect(localStorage.getItem(`dsh.trajectory.duration.${SID}`)).toBeNull()
})
}) })
describe('tab switching in ConversationRoot', () => { describe('tab switching in ConversationRoot', () => {
@@ -653,6 +685,7 @@ describe('timeline projection', () => {
{ {
...standaloneProps([]), ...standaloneProps([]),
...standaloneHistory(historySnapshot([])), ...standaloneHistory(historySnapshot([])),
...standaloneDuration(),
}, },
)) ))
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy() expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
@@ -661,12 +694,19 @@ describe('timeline projection', () => {
}) })
describe('TrajectoryView branches', () => { describe('TrajectoryView branches', () => {
it('persists the duration preference across trajectory view mounts', () => { it('persists the duration preference through the runtime snapshot-store seam', () => {
const props = { const firstDuration = createTrajectoryDurationStore()
const commonProps = {
...standaloneProps(NODES), ...standaloneProps(NODES),
...standaloneHistory(historySnapshot(NODES)), ...standaloneHistory(historySnapshot(NODES)),
} }
const first = render(<TrajectoryView {...props} />) const first = render(
<TrajectoryView
{...commonProps}
useDuration={bindSnapshotSelector(firstDuration)}
setActualDuration={(value) => { firstDuration.set(value) }}
/>,
)
const duration = screen.getByRole('button', { name: 'Use actual duration' }) const duration = screen.getByRole('button', { name: 'Use actual duration' })
expect(duration.getAttribute('aria-pressed')).toBe('false') expect(duration.getAttribute('aria-pressed')).toBe('false')
@@ -674,7 +714,14 @@ describe('TrajectoryView branches', () => {
expect(localStorage.getItem('dsh.trajectory.duration')).toBe('true') expect(localStorage.getItem('dsh.trajectory.duration')).toBe('true')
first.unmount() first.unmount()
render(<TrajectoryView {...props} />) const restoredDuration = createTrajectoryDurationStore()
render(
<TrajectoryView
{...commonProps}
useDuration={bindSnapshotSelector(restoredDuration)}
setActualDuration={(value) => { restoredDuration.set(value) }}
/>,
)
expect(screen.getByRole('button', { name: 'Use actual duration' }).getAttribute('aria-pressed')) expect(screen.getByRole('button', { name: 'Use actual duration' }).getAttribute('aria-pressed'))
.toBe('true') .toBe('true')
}) })
@@ -734,6 +781,7 @@ describe('TrajectoryView branches', () => {
const view = render( const view = render(
<TrajectoryView <TrajectoryView
{...standaloneProps([])} {...standaloneProps([])}
{...standaloneDuration()}
useHistory={bindSnapshotSelector(store)} useHistory={bindSnapshotSelector(store)}
loadAllHistory={vi.fn(() => Promise.resolve())} loadAllHistory={vi.fn(() => Promise.resolve())}
/>, />,
@@ -776,6 +824,7 @@ describe('TrajectoryView branches', () => {
render( render(
<TrajectoryView <TrajectoryView
{...standaloneProps([])} {...standaloneProps([])}
{...standaloneDuration()}
useHistory={bindSnapshotSelector(store)} useHistory={bindSnapshotSelector(store)}
loadAllHistory={vi.fn(() => Promise.resolve())} loadAllHistory={vi.fn(() => Promise.resolve())}
/>, />,