refactor(client): isolate trajectory history
This commit is contained in:
@@ -2,8 +2,10 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {
|
||||
AssistantMessageNode, ConversationContext, ConversationNode, RequestView,
|
||||
AssistantMessageNode, ConversationContext, RequestView,
|
||||
SessionHistoryFace,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
deriveTrajectoryContextBranches, trajectoryBranchContainsRequest,
|
||||
@@ -28,6 +30,7 @@ const EMPTY_REQUESTS: readonly RequestView[] = []
|
||||
|
||||
/** Session-history paging needed by the event-complete trajectory view. */
|
||||
export interface TrajectoryViewInjected {
|
||||
hooks: { history: SessionHistoryFace }
|
||||
loadAllHistory: (signal: AbortSignal) => Promise<void>
|
||||
}
|
||||
|
||||
@@ -84,12 +87,6 @@ function searchableJson(value: unknown): string {
|
||||
}
|
||||
}
|
||||
|
||||
function isInterruptedNode(node: ConversationNode): boolean {
|
||||
return node.kind === 'assistant'
|
||||
? node.interrupted === true
|
||||
: node.kind === 'tool-result' && node.error?.code === 'interrupted'
|
||||
}
|
||||
|
||||
function searchMatches(
|
||||
turns: ReturnType<typeof deriveTrajectoryLayout>,
|
||||
query: string,
|
||||
@@ -137,7 +134,9 @@ function searchMatches(
|
||||
return matches
|
||||
}
|
||||
|
||||
export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & TrajectoryViewInjected) {
|
||||
export function TrajectoryView({
|
||||
useHistory, loadAllHistory,
|
||||
}: ConvViewProps & InjectFace<TrajectoryViewInjected>) {
|
||||
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_IDS)
|
||||
const [collapsedAssistants, setCollapsedAssistants] =
|
||||
useState<ReadonlySet<number>>(EMPTY_IDS)
|
||||
@@ -150,26 +149,22 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedTimelineIndex, setSelectedTimelineIndex] = useState<number | null>(null)
|
||||
const ledgerRef = useRef<HTMLDivElement>(null)
|
||||
const nodes = useSession(s => s.nodes)
|
||||
const inspection = useSession(s => s.inspection)
|
||||
const hasMore = useSession(s => s.hasMore)
|
||||
const openState = useSession(s => s.openState)
|
||||
const partial = useSession(s => s.partial)
|
||||
const runningCalls = useSession(s => s.runningCalls)
|
||||
const codeDispatches = useSession(s => s.codeDispatches)
|
||||
const inspection = useHistory(snapshot => snapshot.inspection)
|
||||
const nodes = inspection.eventNodes
|
||||
const partial = inspection.partial
|
||||
const runningCalls = inspection.runningCalls
|
||||
const codeDispatches = inspection.codeDispatches
|
||||
const loadAllHistoryRef = useRef(loadAllHistory)
|
||||
loadAllHistoryRef.current = loadAllHistory
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
if (openState === 'open' && hasMore) {
|
||||
void loadAllHistoryRef.current(controller.signal)
|
||||
}
|
||||
void loadAllHistoryRef.current(controller.signal)
|
||||
return () => { controller.abort() }
|
||||
}, [hasMore, openState])
|
||||
const requests = inspection?.requests ?? EMPTY_REQUESTS
|
||||
const callSchemas = inspection?.callSchemas
|
||||
}, [])
|
||||
const requests = inspection.requests ?? EMPTY_REQUESTS
|
||||
const callSchemas = inspection.callSchemas
|
||||
const contexts = useMemo<readonly ConversationContext[]>(
|
||||
() => inspection === undefined || inspection.contexts.length === 0
|
||||
() => inspection.contexts.length === 0
|
||||
? [{ id: 0, nodes }]
|
||||
: inspection.contexts,
|
||||
[inspection, nodes],
|
||||
@@ -182,11 +177,11 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
|
||||
if (currentBranch === undefined) throw new Error('trajectory branch projection must not be empty')
|
||||
const selectedNodes = useMemo(() => {
|
||||
const selected = new Map(currentBranch.nodes.map(node => [node.seq, node]))
|
||||
for (const node of nodes) {
|
||||
if (isInterruptedNode(node)) selected.set(node.seq, node)
|
||||
for (const node of inspection.interruptedNodes) {
|
||||
selected.set(node.seq, node)
|
||||
}
|
||||
return [...selected.values()].sort((left, right) => left.seq - right.seq)
|
||||
}, [currentBranch, nodes])
|
||||
}, [currentBranch, inspection])
|
||||
const selectedRequests = useMemo(
|
||||
() => requests.filter(request =>
|
||||
trajectoryBranchContainsRequest(currentBranch, request),
|
||||
|
||||
@@ -16,7 +16,7 @@ import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.ts
|
||||
* into an undeclared slot throws — service waiting is what orders this
|
||||
* apply after the declaring one.
|
||||
*/
|
||||
export const inject = ['slots', 'conversation', 'sessions']
|
||||
export const inject = ['slots', 'conversation', 'sessionHistory']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the trajectory view tab. The registration
|
||||
@@ -30,12 +30,10 @@ export function apply(ctx: Context): void {
|
||||
order: 10,
|
||||
label: 'Trajectory',
|
||||
inject: (sessionId: SessionId): TrajectoryViewInjected => {
|
||||
const session = ctx.sessions.binding(sessionId)?.session
|
||||
if (session === undefined) {
|
||||
throw new Error(`ui-trajectory: session "${sessionId}" resolved no binding`)
|
||||
}
|
||||
const history = ctx.sessionHistory.source(sessionId)
|
||||
return {
|
||||
loadAllHistory: signal => session.loadAllHistory(signal),
|
||||
hooks: { history },
|
||||
loadAllHistory: signal => history.loadAll(signal),
|
||||
}
|
||||
},
|
||||
}, TrajectoryView)
|
||||
|
||||
@@ -10,13 +10,14 @@
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { createElement, type FC, type ReactNode } from 'react'
|
||||
import { createElement, type ComponentProps, type FC, type ReactNode } from 'react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ConversationSnapshot, RequestView, SessionId, SessionListState, WorkspaceListState,
|
||||
ConversationSnapshot, RequestView, SessionHistoryFace, SessionHistoryInspection,
|
||||
SessionHistorySnapshot, SessionId, SessionListState, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { ConversationSession, type ConversationSessionProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx'
|
||||
@@ -24,7 +25,9 @@ import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/cli
|
||||
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'
|
||||
import { TrajectoryView } from '../src/client/TrajectoryView.tsx'
|
||||
import {
|
||||
TrajectoryView, type TrajectoryViewInjected,
|
||||
} from '../src/client/TrajectoryView.tsx'
|
||||
import { deriveTrajectoryTimeline } from '../src/client/timeline.ts'
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
@@ -54,6 +57,38 @@ const NODES = [
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
|
||||
function historySnapshot(
|
||||
nodes: ConversationSnapshot['nodes'],
|
||||
inspection: Partial<SessionHistoryInspection> = {},
|
||||
): SessionHistorySnapshot {
|
||||
return {
|
||||
state: 'ready',
|
||||
error: null,
|
||||
hasMore: false,
|
||||
inspection: {
|
||||
eventNodes: nodes,
|
||||
contexts: [{ id: 0, nodes }],
|
||||
requests: [],
|
||||
callSchemas: new Map(),
|
||||
interruptedNodes: [],
|
||||
partial: null,
|
||||
runningCalls: [],
|
||||
codeDispatches: new Map(),
|
||||
...inspection,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function standaloneHistory(
|
||||
snapshot: SessionHistorySnapshot,
|
||||
): Pick<ComponentProps<typeof TrajectoryView>, 'useHistory' | 'loadAllHistory'> {
|
||||
const store = createSnapshotStore(snapshot)
|
||||
return {
|
||||
useHistory: bindSnapshotSelector(store),
|
||||
loadAllHistory: () => Promise.resolve(),
|
||||
}
|
||||
}
|
||||
|
||||
function fakeSession(nodes: ConversationSnapshot['nodes']) {
|
||||
const store = createSnapshotStore({
|
||||
nodes, pending: [], partial: null,
|
||||
@@ -93,6 +128,13 @@ async function bench() {
|
||||
const ctx = new Context()
|
||||
const slots = new SlotsService(ctx)
|
||||
const loadAllHistory = vi.fn((_signal: AbortSignal) => Promise.resolve())
|
||||
const historyStore = createSnapshotStore(historySnapshot(NODES))
|
||||
const history: SessionHistoryFace = {
|
||||
sessionId: SID,
|
||||
getSnapshot: historyStore.getSnapshot,
|
||||
subscribe: historyStore.subscribe,
|
||||
loadAll: loadAllHistory,
|
||||
}
|
||||
// The conversation entry's role: declare the ring, then seed the chat entry.
|
||||
slots.register({
|
||||
name: 'root',
|
||||
@@ -104,11 +146,7 @@ async function bench() {
|
||||
// 'conversation' inject is an ordering edge; the bench declares the ring
|
||||
// itself, so a stub satisfies the wait.
|
||||
ctx.provide('conversation', {})
|
||||
ctx.provide('sessions', {
|
||||
binding: (sessionId: SessionId) => sessionId === SID
|
||||
? { session: { loadAllHistory } }
|
||||
: undefined,
|
||||
})
|
||||
ctx.provide('sessionHistory', { source: () => history })
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
return { ctx, slots, fiber, loadAllHistory }
|
||||
@@ -141,9 +179,17 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
|
||||
const injected = injectEntry === undefined
|
||||
? {}
|
||||
: injectEntry(SID)
|
||||
const injectedProps = 'hooks' in injected
|
||||
? {
|
||||
loadAllHistory: (injected as TrajectoryViewInjected).loadAllHistory,
|
||||
useHistory: bindSnapshotSelector(
|
||||
(injected as TrajectoryViewInjected).hooks.history,
|
||||
),
|
||||
}
|
||||
: injected
|
||||
return (
|
||||
<View
|
||||
{...injected}
|
||||
{...injectedProps}
|
||||
{...({ sessionId: SID, useSession, useSessions: emptySessions(), useWorkspaces: emptyWorkspaces() } as unknown as ConvViewProps)}
|
||||
key={key}
|
||||
/>
|
||||
@@ -151,7 +197,6 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
|
||||
}) as unknown as ConversationSessionProps['renderSlot']
|
||||
return render(
|
||||
<ConversationSession
|
||||
composer={null}
|
||||
sessionId={SID}
|
||||
SessionProvider={({ children }) => children(SID)}
|
||||
useSession={useSession}
|
||||
@@ -345,7 +390,10 @@ describe('timeline projection', () => {
|
||||
expect(deriveTrajectoryTimeline([])).toBeNull()
|
||||
render(createElement(
|
||||
TrajectoryView,
|
||||
{ ...standaloneProps([]), loadAllHistory: () => Promise.resolve() },
|
||||
{
|
||||
...standaloneProps([]),
|
||||
...standaloneHistory(historySnapshot([])),
|
||||
},
|
||||
))
|
||||
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
|
||||
expect(screen.queryByRole('row')).toBeNull()
|
||||
@@ -386,9 +434,9 @@ describe('TrajectoryView branches', () => {
|
||||
completedAt: startSeq * 1_000 + 100,
|
||||
status: 'complete',
|
||||
})
|
||||
const store = createSnapshotStore({
|
||||
nodes: [retained, current],
|
||||
inspection: {
|
||||
const store = createSnapshotStore(historySnapshot(
|
||||
[retained, abandoned, current],
|
||||
{
|
||||
eventNodes: [retained, abandoned, current],
|
||||
contexts: [
|
||||
{ id: 0, nodes: [retained, abandoned] },
|
||||
@@ -403,17 +451,12 @@ describe('TrajectoryView branches', () => {
|
||||
requests: [request(2, 1), request(4, 2)],
|
||||
callSchemas: new Map(),
|
||||
},
|
||||
openState: 'open' as const,
|
||||
hasMore: false,
|
||||
partial: null,
|
||||
runningCalls: [] as ConversationSnapshot['runningCalls'],
|
||||
codeDispatches: new Map(),
|
||||
})
|
||||
))
|
||||
|
||||
const view = render(
|
||||
<TrajectoryView
|
||||
{...standaloneProps([])}
|
||||
useSession={bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot>}
|
||||
useHistory={bindSnapshotSelector(store)}
|
||||
loadAllHistory={vi.fn(() => Promise.resolve())}
|
||||
/>,
|
||||
)
|
||||
@@ -441,25 +484,21 @@ describe('TrajectoryView branches', () => {
|
||||
error: { name: 'Interrupted', code: 'interrupted' },
|
||||
callView: null, resultView: null,
|
||||
} as unknown as ConversationSnapshot['nodes'][number]
|
||||
const store = createSnapshotStore({
|
||||
nodes: [retained, interruptedAssistant, interruptedTool],
|
||||
inspection: {
|
||||
const store = createSnapshotStore(historySnapshot(
|
||||
[retained],
|
||||
{
|
||||
eventNodes: [retained],
|
||||
contexts: [{ id: 0, nodes: [retained] }],
|
||||
requests: [],
|
||||
callSchemas: new Map(),
|
||||
interruptedNodes: [interruptedAssistant, interruptedTool],
|
||||
},
|
||||
openState: 'open' as const,
|
||||
hasMore: false,
|
||||
partial: null,
|
||||
runningCalls: [] as ConversationSnapshot['runningCalls'],
|
||||
codeDispatches: new Map(),
|
||||
})
|
||||
))
|
||||
|
||||
render(
|
||||
<TrajectoryView
|
||||
{...standaloneProps([])}
|
||||
useSession={bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot>}
|
||||
useHistory={bindSnapshotSelector(store)}
|
||||
loadAllHistory={vi.fn(() => Promise.resolve())}
|
||||
/>,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user