fix(client): keep trajectory history on the session seam

This commit is contained in:
_Kerman
2026-07-28 13:56:00 +08:00
parent 748140da13
commit 1ac0eb9611
9 changed files with 100 additions and 159 deletions

View File

@@ -1,12 +1,9 @@
/** Trajectory view: compact summary over a turn-aware event ledger. */
import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {
AssistantMessageNode, ConversationContext, SessionHistory,
} from '@deepseek-ai/dsh-client-runtime/client'
import {
inspectRequests, projectConversationHistory,
AssistantMessageNode, ConversationContext, RequestView,
} from '@deepseek-ai/dsh-client-runtime/client'
import { deriveTrajectoryContextBranches } from './context-branches.ts'
import {
@@ -19,10 +16,11 @@ import { deriveTrajectoryLayout } from './layout.ts'
import css from './views.module.css'
const EMPTY_IDS: ReadonlySet<number> = new Set()
const EMPTY_REQUESTS: readonly RequestView[] = []
/** Raw session-history source needed by the event-complete trajectory view. */
/** Session-history paging needed by the event-complete trajectory view. */
export interface TrajectoryViewInjected {
history: SessionHistory
loadAllHistory: () => Promise<void>
}
interface UsageLike {
@@ -69,47 +67,29 @@ function addUsage(
}
}
export function TrajectoryView({ useSession, history }: ConvViewProps & TrajectoryViewInjected) {
export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & TrajectoryViewInjected) {
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_IDS)
const [collapsedAssistants, setCollapsedAssistants] =
useState<ReadonlySet<number>>(EMPTY_IDS)
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 subscribeHistory = useMemo(
() => (listener: () => void) => history.subscribe(listener),
[history],
)
const getHistorySnapshot = useMemo(
() => () => history.getSnapshot(),
[history],
)
const historySnapshot = useSyncExternalStore(
subscribeHistory,
getHistorySnapshot,
getHistorySnapshot,
)
const loadAllHistoryRef = useRef(loadAllHistory)
loadAllHistoryRef.current = loadAllHistory
useEffect(() => {
if (historySnapshot.openState === 'open' && historySnapshot.hasMore) {
void history.loadAll()
}
}, [history, historySnapshot.hasMore, historySnapshot.openState])
const projectedHistory = useMemo(
() => projectConversationHistory(historySnapshot.entries),
[historySnapshot.entries],
)
const requestInspection = useMemo(
() => inspectRequests(historySnapshot.entries),
[historySnapshot.entries],
)
const requests = requestInspection.requests
const callSchemas = requestInspection.callSchemas
if (openState === 'open' && hasMore) void loadAllHistoryRef.current()
}, [hasMore, openState])
const requests = inspection?.requests ?? EMPTY_REQUESTS
const callSchemas = inspection?.callSchemas
const contexts = useMemo<readonly ConversationContext[]>(
() => projectedHistory.contexts.length === 0
() => inspection === undefined || inspection.contexts.length === 0
? [{ id: 0, nodes }]
: projectedHistory.contexts,
[nodes, projectedHistory.contexts],
: inspection.contexts,
[inspection, nodes],
)
const branches = useMemo(
() => deriveTrajectoryContextBranches(contexts),
@@ -117,9 +97,9 @@ export function TrajectoryView({ useSession, history }: ConvViewProps & Trajecto
)
const currentBranch = branches.at(-1)
if (currentBranch === undefined) throw new Error('trajectory branch projection must not be empty')
const selectedNodes = projectedHistory.eventNodes.length === 0
const selectedNodes = inspection === undefined || inspection.eventNodes.length === 0
? nodes
: projectedHistory.eventNodes
: inspection.eventNodes
const globalRequestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
const assistantsByStep = new Map<string, AssistantMessageNode>()
for (const context of contexts) {
@@ -256,7 +236,7 @@ export function TrajectoryView({ useSession, history }: ConvViewProps & Trajecto
partial,
runningCalls,
requests,
callSchemas,
...(callSchemas === undefined ? {} : { callSchemas }),
codeDispatches,
}),
[

View File

@@ -36,7 +36,7 @@ export function apply(ctx: Context): void {
if (session === undefined) {
throw new Error(`ui-trajectory: session "${sessionId}" resolved no binding`)
}
return { history: session.history }
return { loadAllHistory: () => session.loadAllHistory() }
},
}, TrajectoryView)
ctx.slots.register(

View File

@@ -60,7 +60,7 @@ describe('tsdown client artifact', () => {
const { handoff, surface } = await loadArtifact()
expect(handoff.id).toBe(PLUGIN_ID)
expect(surface.apply).toBeTypeOf('function')
expect(surface.inject).toEqual(['slots', 'conversation'])
expect(surface.inject).toEqual(['slots', 'conversation', 'sessions'])
})
it.skipIf(code === undefined)('mounted as an object plugin, apply registers both view tabs on the real ring', async () => {
@@ -72,10 +72,10 @@ describe('tsdown client artifact', () => {
name: 'root',
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
}, (_p: { renderSlot?: unknown }) => null)
// The plugin injects 'conversation' as an ordering edge (the declaring
// plugin provides it after declaring the ring); the bench declares the
// ring itself, so a stub satisfies the wait.
// The plugin injects 'conversation' as an ordering edge and 'sessions'
// for its per-session history callback; this bench supplies both.
ctx.provide('conversation', {})
ctx.provide('sessions', {})
const fiber = ctx.plugin(surface as { apply: (ctx: Context) => void })
await fiber.await()
expect(slots.entries('conversation.view').map(e => e.options.id)).toEqual(['trajectory', 'waterfall'])

View File

@@ -16,8 +16,7 @@ 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, SessionHistory, SessionHistorySnapshot, SessionId,
SessionListState, WorkspaceListState,
ConversationSnapshot, SessionId, SessionListState, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
// Export discipline: packages/client/AGENTS.md.
@@ -83,42 +82,11 @@ function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps {
} as unknown as ConvViewProps
}
function emptyHistory(): SessionHistory {
const store = createSnapshotStore<SessionHistorySnapshot>({
sessionId: SID,
entries: [],
baseSeq: 0,
openState: 'open',
openError: null,
hasMore: false,
loadingOlder: false,
})
return {
getSnapshot: () => store.getSnapshot(),
subscribe: listener => store.subscribe(listener),
loadAll: () => Promise.resolve(),
}
}
/** Real-stack bench: root Context + real SlotsService ring + the plugin fiber. */
async function bench() {
const ctx = new Context()
const slots = new SlotsService(ctx)
const loadAllHistory = vi.fn(() => Promise.resolve())
const historyStore = createSnapshotStore<SessionHistorySnapshot>({
sessionId: SID,
entries: [],
baseSeq: 0,
openState: 'open',
openError: null,
hasMore: true,
loadingOlder: false,
})
const history: SessionHistory = {
getSnapshot: () => historyStore.getSnapshot(),
subscribe: listener => historyStore.subscribe(listener),
loadAll: loadAllHistory,
}
// The conversation entry's role: declare the ring, then seed the chat entry.
slots.register({
name: 'root',
@@ -132,7 +100,7 @@ async function bench() {
ctx.provide('conversation', {})
ctx.provide('sessions', {
binding: (sessionId: SessionId) => sessionId === SID
? { session: { history } }
? { session: { loadAllHistory } }
: undefined,
})
const fiber = ctx.plugin({ inject: [...inject], apply })
@@ -295,7 +263,7 @@ describe('span derivation', () => {
expect(container.firstChild).toBeNull()
render(createElement(
TrajectoryView,
{ ...standaloneProps([]), history: emptyHistory() },
{ ...standaloneProps([]), loadAllHistory: () => Promise.resolve() },
))
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
expect(screen.queryByRole('row')).toBeNull()