refactor(client): isolate trajectory history reads

This commit is contained in:
_Kerman
2026-07-28 13:50:46 +08:00
parent 5fc9a041ba
commit 028ac5c2f6
18 changed files with 909 additions and 670 deletions
@@ -1,14 +1,14 @@
/** Trajectory view: compact summary over a turn-aware event ledger. */
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {
AssistantMessageNode, CompactionRequestView, ConversationContext,
ConversationPromptChange, ModelRequestView,
AssistantMessageNode, ConversationContext, SessionHistory,
} from '@deepseek-ai/dsh-client-runtime/client'
import {
deriveTrajectoryContextBranches, trajectoryBranchContainsSeq,
} from './context-branches.ts'
inspectRequests, projectConversationHistory,
} from '@deepseek-ai/dsh-client-runtime/client'
import { deriveTrajectoryContextBranches } from './context-branches.ts'
import {
TrajectoryTable,
type TrajectoryRequestNumber,
@@ -19,9 +19,11 @@ import { deriveTrajectoryLayout } from './layout.ts'
import css from './views.module.css'
const EMPTY_IDS: ReadonlySet<number> = new Set()
const EMPTY_COMPACTION_REQUESTS: readonly CompactionRequestView[] = []
const EMPTY_MODEL_REQUESTS: readonly ModelRequestView[] = []
const EMPTY_PROMPT_CHANGES: readonly ConversationPromptChange[] = []
/** Raw session-history source needed by the event-complete trajectory view. */
export interface TrajectoryViewInjected {
history: SessionHistory
}
interface UsageLike {
inputTokens?: number
@@ -67,30 +69,47 @@ function addUsage(
}
}
export function TrajectoryView({ useSession }: ConvViewProps) {
export function TrajectoryView({ useSession, history }: 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 projectedContexts = useSession(s => s.contexts)
const compactionRequests = useSession(
s => s.compactionRequests ?? EMPTY_COMPACTION_REQUESTS,
)
const requestAttempts = useSession(
s => s.requestAttempts ?? EMPTY_MODEL_REQUESTS,
)
const promptChanges = useSession(
s => s.promptChanges ?? EMPTY_PROMPT_CHANGES,
)
const partial = useSession(s => s.partial)
const runningCalls = useSession(s => s.runningCalls)
const callSchemas = useSession(s => s.callSchemas)
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,
)
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
const contexts = useMemo<readonly ConversationContext[]>(
() => projectedContexts === undefined || projectedContexts.length === 0
() => projectedHistory.contexts.length === 0
? [{ id: 0, nodes }]
: projectedContexts,
[nodes, projectedContexts],
: projectedHistory.contexts,
[nodes, projectedHistory.contexts],
)
const branches = useMemo(
() => deriveTrajectoryContextBranches(contexts),
@@ -98,11 +117,9 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
)
const currentBranch = branches.at(-1)
if (currentBranch === undefined) throw new Error('trajectory branch projection must not be empty')
const selectedNodes = useMemo(() => {
const bySeq = new Map(currentBranch.nodes.map(node => [node.seq, node]))
for (const node of nodes) bySeq.set(node.seq, node)
return [...bySeq.values()].sort((left, right) => left.seq - right.seq)
}, [currentBranch, nodes])
const selectedNodes = projectedHistory.eventNodes.length === 0
? nodes
: projectedHistory.eventNodes
const globalRequestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
const assistantsByStep = new Map<string, AssistantMessageNode>()
for (const context of contexts) {
@@ -115,46 +132,38 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
if (node.kind !== 'assistant' || node.step <= 0) continue
assistantsByStep.set(`${node.turn}\u0000${node.step}`, node)
}
const attemptsByStep = new Map(
requestAttempts.map(request => [
`${request.turn}\u0000${request.step}`,
request,
]),
const requestsByStep = new Map(
requests
.filter(request => request.purpose === 'assistant')
.map(request => [
`${request.turn}\u0000${request.step}`,
request,
]),
)
const orderedRequests = [
...requestAttempts.map(request => ({
...requests.map(request => ({
seq: request.startSeq,
kind: 'ordinary' as const,
request,
node: assistantsByStep.get(`${request.turn}\u0000${request.step}`),
node: request.purpose === 'assistant'
? assistantsByStep.get(`${request.turn}\u0000${request.step}`)
: undefined,
})),
...[...assistantsByStep.entries()].flatMap(([key, node]) =>
attemptsByStep.has(key)
requestsByStep.has(key)
? []
: [{
seq: node.seq,
kind: 'ordinary' as const,
request: undefined,
node,
}],
),
...compactionRequests.map(request => ({
seq: request.startSeq,
kind: 'compaction' as const,
request,
node: undefined,
})),
].sort((left, right) => left.seq - right.seq)
const numbered: TrajectoryRequestNumber[] = []
let cumulativeUsage: TrajectoryUsage | undefined
for (const [index, entry] of orderedRequests.entries()) {
const usage = requestUsage(
entry.kind === 'compaction'
? entry.request.usage
: entry.request?.usage ?? entry.node?.usage,
)
const usage = requestUsage(entry.request?.usage ?? entry.node?.usage)
cumulativeUsage = addUsage(cumulativeUsage, usage)
if (entry.kind === 'ordinary') {
if (entry.request?.purpose !== 'compaction') {
const request = entry.request
const node = entry.node
const turn = request?.turn ?? node?.turn
@@ -223,10 +232,10 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
step: partial.step,
group: `Step ${partial.step}`,
number: orderedRequests.length + 1,
...(currentBranch.latest.prompt?.config?.provider === undefined
...(currentBranch.latest.prompt?.config.provider === undefined
? {}
: { provider: currentBranch.latest.prompt.config.provider }),
...(currentBranch.latest.prompt?.config?.model === undefined
...(currentBranch.latest.prompt?.config.model === undefined
? {}
: { model: currentBranch.latest.prompt.config.model }),
...(currentBranch.latest.prompt?.config === undefined
@@ -238,48 +247,20 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
}
return numbered
}, [
compactionRequests, contexts, currentBranch.latest.prompt, nodes, partial,
requestAttempts,
contexts, currentBranch.latest.prompt, nodes, partial, requests,
])
const requestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
return globalRequestNumbers.filter(request =>
request.seq === undefined
|| trajectoryBranchContainsSeq(currentBranch, request.seq),
)
}, [currentBranch, globalRequestNumbers])
const visibleRequestAttempts = useMemo(
() => requestAttempts.filter(request =>
requestNumbers.some(number =>
number.purpose !== 'compaction' && number.seq === request.startSeq,
)),
[requestAttempts, requestNumbers],
)
const visibleCompactionRequests = useMemo(
() => compactionRequests.filter(request =>
requestNumbers.some(number =>
number.purpose === 'compaction' && number.seq === request.startSeq,
)),
[compactionRequests, requestNumbers],
)
const visiblePromptChanges = useMemo(
() => promptChanges.filter(change =>
trajectoryBranchContainsSeq(currentBranch, change.seq)),
[currentBranch, promptChanges],
)
const requestNumbers = globalRequestNumbers
const turns = useMemo(
() => deriveTrajectoryLayout({
nodes: selectedNodes,
partial,
runningCalls,
compactionRequests: visibleCompactionRequests,
requestAttempts: visibleRequestAttempts,
promptChanges: visiblePromptChanges,
requests,
callSchemas,
codeDispatches,
}),
[
selectedNodes, partial, runningCalls, visibleCompactionRequests,
visibleRequestAttempts, visiblePromptChanges, callSchemas, codeDispatches,
selectedNodes, partial, runningCalls, requests, callSchemas, codeDispatches,
],
)
const collapsibleTurnIds = useMemo(
@@ -3,10 +3,11 @@
* view slot without defining a service.
*/
import type { Context } from 'cordis'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
// 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.
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { TrajectoryView } from './TrajectoryView.tsx'
import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx'
import { WaterfallView } from './WaterfallView.tsx'
/**
@@ -16,7 +17,7 @@ import { WaterfallView } from './WaterfallView.tsx'
* into an undeclared slot throws — service waiting is what orders this
* apply after the declaring one.
*/
export const inject = ['slots', 'conversation']
export const inject = ['slots', 'conversation', 'sessions']
/**
* Client plugin body: register the trajectory and waterfall view tabs. The
@@ -25,8 +26,19 @@ export const inject = ['slots', 'conversation']
* @param ctx - client root context.
*/
export function apply(ctx: Context): void {
ctx.slots.register(
{ name: 'conversation.view', id: 'trajectory', order: 10, label: 'Trajectory' }, TrajectoryView)
ctx.slots.register({
name: 'conversation.view',
id: 'trajectory',
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`)
}
return { history: session.history }
},
}, TrajectoryView)
ctx.slots.register(
{ name: 'conversation.view', id: 'waterfall', order: 20, label: 'Waterfall' }, WaterfallView)
}
@@ -6,10 +6,10 @@ import type {
AssistantBlock,
AssistantMessageNode,
CodeSubCall,
CompactionRequestView,
ConversationPromptChange,
ConversationSnapshot,
ModelRequestView,
RequestInspectionSnapshot,
RequestPromptChange,
RequestView,
ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import type {
@@ -35,10 +35,8 @@ export interface TrajectoryLayoutInput {
nodes: ConversationSnapshot['nodes']
partial: ConversationSnapshot['partial']
runningCalls: ConversationSnapshot['runningCalls']
compactionRequests?: readonly CompactionRequestView[]
requestAttempts?: readonly ModelRequestView[]
promptChanges?: readonly ConversationPromptChange[]
callSchemas?: ConversationSnapshot['callSchemas']
requests?: readonly RequestView[]
callSchemas?: RequestInspectionSnapshot['callSchemas']
/** run_code sub-dispatches by parent callId (sub-cells nest under the parent Tool cell). */
codeDispatches: ConversationSnapshot['codeDispatches']
}
@@ -83,17 +81,18 @@ type OrderedLayoutEntry =
| {
kind: 'compaction'
seq: number
request: CompactionRequestView
request: RequestView
}
| {
kind: 'system'
seq: number
change: ConversationPromptChange
request: RequestView
change: RequestPromptChange
}
| {
kind: 'request'
seq: number
request: ModelRequestView
request: RequestView
}
function layoutEntryOrder(entry: OrderedLayoutEntry): number {
@@ -124,9 +123,7 @@ function inputCellDetail(node: InputNode): Pick<
*/
export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly TrajectoryTurnModel[] {
const {
nodes, partial, runningCalls, compactionRequests = [], requestAttempts = [],
promptChanges = [],
callSchemas, codeDispatches,
nodes, partial, runningCalls, requests = [], callSchemas, codeDispatches,
} = input
const resultByCall = indexResults(nodes)
const callStartById = new Map<string, number>()
@@ -193,17 +190,23 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
node,
nodeIndex,
})),
...compactionRequests.map(request => ({
kind: 'compaction' as const,
seq: request.startSeq,
request,
})),
...promptChanges.map(change => ({
kind: 'system' as const,
seq: change.seq,
change,
})),
...requestAttempts
...requests
.filter(request => request.purpose === 'compaction')
.map(request => ({
kind: 'compaction' as const,
seq: request.startSeq,
request,
})),
...requests.flatMap(request => request.promptChange === undefined || request.prompt === undefined
? []
: [{
kind: 'system' as const,
seq: request.promptChange.seq,
request,
change: request.promptChange,
}]),
...requests
.filter(request => request.purpose === 'assistant')
.filter(request =>
!representedRequests.has(`${request.turn}\u0000${request.step}`),
)
@@ -238,7 +241,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
continue
}
if (entry.kind === 'system') {
const { change } = entry
const { change, request } = entry
const turn = change.kind === 'initial'
? firstVisibleTurn(nodes, partial)
: enclosingPromptTurn(nodes, change.seq, partial)
@@ -249,7 +252,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
kind: 'system',
text: promptChangeLabel(change),
sourceSeq: change.seq,
promptDetail: change.prompt,
...(request.prompt === undefined ? {} : { promptDetail: request.prompt }),
...(change.previous === undefined
? {}
: { previousPromptDetail: change.previous }),
@@ -450,7 +453,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
function attachToolSchema(
laid: LaidCell,
callSchemas: ConversationSnapshot['callSchemas'],
callSchemas: RequestInspectionSnapshot['callSchemas'] | undefined,
): void {
if (laid.callId === undefined || callSchemas === undefined) return
const schema = callSchemas.get(laid.callId)
@@ -616,7 +619,7 @@ function summarizeAssistantActivity(blocks: readonly AssistantBlock[]): string {
return ''
}
function promptChangeLabel(change: ConversationPromptChange): string {
function promptChangeLabel(change: RequestPromptChange): string {
if (change.kind === 'initial') return 'Initial System Prompt'
if (change.kind === 'system') return 'System Prompt Updated'
if (change.kind === 'tools') return 'Tools Updated'