fix(trajectory): use runtime preference persistence
This commit is contained in:
@@ -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())}
|
||||||
/>,
|
/>,
|
||||||
|
|||||||
Reference in New Issue
Block a user