fix(ui-trajectory): place steering before request boundary

This commit is contained in:
imccyu
2026-08-11 01:06:11 +08:00
parent 98a4cc6569
commit d66e693c63
11 changed files with 303 additions and 47 deletions
@@ -492,6 +492,21 @@ function requestKey(turn: number | null, group: string): string {
return `${turn}\u0000${group}`
}
function indexRequestBoundaries(records: readonly TableRecord[]): ReadonlyMap<string, number> {
const boundaries = new Map<string, number>()
for (const record of records) {
const key = requestKey(record.turn, record.group)
if (boundaries.has(key)) continue
if (requestStep(record.group) === undefined) {
if (record.groupStart) boundaries.set(key, record.cell.index)
continue
}
if (record.cell.kind === 'user' || record.cell.kind === 'context') continue
boundaries.set(key, record.cell.index)
}
return boundaries
}
function sectionLabel(turn: number | null): string {
return turn === null ? 'Between turns' : `Turn ${turn}`
}
@@ -499,16 +514,18 @@ function sectionLabel(turn: number | null): string {
function indexRequestNumbers(
records: readonly TableRecord[],
sessionNumbers: readonly TrajectoryRequestNumber[] | undefined,
boundaries: ReadonlyMap<string, number>,
): ReadonlyMap<string, number> {
const numbers = new Map<string, number>()
for (const request of sessionNumbers ?? []) {
numbers.set(requestKey(request.turn, request.group), request.number)
}
let next = Math.max(0, ...numbers.values()) + 1
const boundaries = records
.filter(record => record.groupStart && requestStep(record.group) !== undefined)
const boundaryRecords = records
.filter(record => boundaries.get(requestKey(record.turn, record.group)) === record.cell.index
&& requestStep(record.group) !== undefined)
.sort((left, right) => left.cell.index - right.cell.index)
for (const record of boundaries) {
for (const record of boundaryRecords) {
const key = requestKey(record.turn, record.group)
if (!numbers.has(key)) numbers.set(key, next++)
}
@@ -1731,9 +1748,10 @@ export function TrajectoryTable({
useEffect(() => {
onSelectedIndexChange?.(selectedIndex)
}, [onSelectedIndexChange, selectedIndex])
const requestBoundaries = useMemo(() => indexRequestBoundaries(allRecords), [allRecords])
const requestNumbers = useMemo(
() => indexRequestNumbers(allRecords, sessionRequestNumbers),
[allRecords, sessionRequestNumbers],
() => indexRequestNumbers(allRecords, sessionRequestNumbers, requestBoundaries),
[allRecords, requestBoundaries, sessionRequestNumbers],
)
const records = useMemo(() => {
if (searchMatchIndexes !== null) return filterRecords(allRecords, searchMatchIndexes)
@@ -2218,10 +2236,11 @@ export function TrajectoryTable({
const isRequestOnly = record.cell.requestOnly === true
const isInitialSystem = record.cell.kind === 'system'
&& record.cell.index === allRecords[0]?.cell.index
const request = record.groupStart
const key = requestKey(record.turn, record.group)
const request = requestBoundaries.get(key) === record.cell.index
&& !isCollapsedSummary
&& (record.turn === null || !collapsedTurns.has(record.turn))
? requestNumbers.get(requestKey(record.turn, record.group))
? requestNumbers.get(key)
: undefined
const requestInfo = request === undefined
? undefined
@@ -145,6 +145,7 @@ export function TrajectoryView({
snapshot.openState === 'loading' || snapshot.loadingOlder)
const hasOlderHistory = useSession(snapshot => snapshot.hasMore)
const nodes = inspection.eventNodes
const eventLocations = inspection.eventLocations
const historyBaseSeq = nodes[0]?.seq ?? 0
const partial = inspection.partial
const runningCalls = inspection.runningCalls
@@ -254,6 +255,7 @@ export function TrajectoryView({
const finalized = useMemo(() => {
const turns = deriveTrajectoryLayout({
nodes,
eventLocations,
partial: partialTurn === null || partialStep === null
? null
: { turn: partialTurn, step: partialStep, blocks: [] },
@@ -263,7 +265,7 @@ export function TrajectoryView({
})
return { turns, lastIndex: lastCellIndex(turns) }
}, [
nodes, partialTurn, partialStep,
nodes, eventLocations, partialTurn, partialStep,
runningCalls, requests, callSchemas,
])
const timelinePartialSignature = partialStructureSignature(partial)
@@ -5,6 +5,7 @@
import type {
AssistantBlock,
AssistantMessageNode,
ConversationLocation,
ConversationSnapshot,
RequestInspectionSnapshot,
RequestPromptChange,
@@ -34,6 +35,7 @@ export interface TrajectoryTurnModel {
/** Snapshot slice the trajectory view folds. */
export interface TrajectoryLayoutInput {
nodes: ConversationSnapshot['nodes']
eventLocations?: ReadonlyMap<number, ConversationLocation>
partial: ConversationSnapshot['partial']
runningCalls: ConversationSnapshot['runningCalls']
requests?: readonly RequestView[]
@@ -71,7 +73,7 @@ type CompactionRequestView = Extract<RequestView, { purpose: 'compaction' }>
type InputNode = Extract<
ConversationSnapshot['nodes'][number],
{ kind: 'user' | 'context' }
{ kind: 'user' | 'steering' | 'context' }
>
type OrderedLayoutEntry =
@@ -135,12 +137,13 @@ function inputCellDetail(node: InputNode): Pick<
*/
export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly TrajectoryTurnModel[] {
const {
nodes, partial, runningCalls, requests = [], callSchemas,
nodes, eventLocations, partial, runningCalls, requests = [], callSchemas,
} = input
const resultByCall = indexResults(nodes)
const callById = new Map<string, ToolCallBlock>(resultByCall)
for (const call of runningCalls) callById.set(call.callId, call)
const emittedCallIds = indexAssistantCallIds(nodes)
const followingAssistants = indexFollowingAssistants(nodes)
const callStartById = new Map<string, number>()
for (const result of resultByCall.values()) {
const startedAt = finiteTime(result.callTime)
@@ -185,6 +188,19 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
}
groups.push({ title, laid: [...laid] })
}
const pushStepInput = (turn: number, step: number, laid: readonly LaidCell[]) => {
if (laid.length === 0) return
const groups = bucket(turn).groups
const title = `Step ${step}`
const existing = groups.find(group => group.title === title)
if (existing === undefined) {
groups.push({ title, laid: [...laid] })
return
}
const request = existing.laid.findIndex(entry => entry.cell.requestOnly === true)
if (request === -1) existing.laid.push(...laid)
else existing.laid.splice(request, 0, ...laid)
}
const representedRequests = new Set<string>()
for (const node of nodes) {
@@ -338,7 +354,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
if (node.kind === 'user') {
// user/message has no turn on the wire; enclose it in the next assistant
// (or partial) turn, else open the turn after the last assistant.
const turn = enclosingUserTurn(nodes, i, partial, lastAssistantTurn)
const turn = enclosingUserTurn(followingAssistants[i], partial, lastAssistantTurn)
pushMessage(turn, {
absTime: finiteTime(node.time),
cell: {
@@ -351,6 +367,26 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
continue
}
if (node.kind === 'steering') {
const placement = steeringPlacement(
followingAssistants[i],
partial,
lastAssistantTurn,
eventLocations?.get(node.seq),
)
const laid = {
absTime: finiteTime(node.time),
cell: {
index: ++index,
kind: 'user' as const,
...inputCellDetail(node),
},
}
if (placement.step === undefined) pushMessage(placement.turn, laid)
else pushStepInput(placement.turn, placement.step, [laid])
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
continue
}
if (node.kind === 'assistant') {
const laidList = withSubCalls(
expandAssistant(node, index + 1, prevAbsTime, resultByCall, callStartById, callById),
@@ -364,7 +400,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
continue
}
if (node.kind === 'context') {
const turn = enclosingUserTurn(nodes, i, partial, lastAssistantTurn)
const turn = enclosingUserTurn(followingAssistants[i], partial, lastAssistantTurn)
pushMessage(turn, {
absTime: finiteTime(node.time),
cell: {
@@ -821,22 +857,53 @@ function stringifySourceValue(value: unknown): string {
* in-flight partial, else the turn after the last finalized assistant (or 1).
*/
function enclosingUserTurn(
nodes: ConversationSnapshot['nodes'],
userIndex: number,
followingAssistant: AssistantMessageNode | undefined,
partial: ConversationSnapshot['partial'],
lastAssistantTurn: number | null,
): number {
for (let i = userIndex + 1; i < nodes.length; i++) {
const n = nodes[i]
/* v8 ignore next -- dense-array guard: i stays within nodes.length, so the undefined arm needs a sparse array no caller builds. */
if (n === undefined) continue
if (n.kind === 'assistant') return n.turn
}
if (followingAssistant !== undefined) return followingAssistant.turn
if (partial !== null) return partial.turn
if (lastAssistantTurn !== null) return lastAssistantTurn + 1
return 1
}
function steeringPlacement(
followingAssistant: AssistantMessageNode | undefined,
partial: ConversationSnapshot['partial'],
lastAssistantTurn: number | null,
location: ConversationLocation | undefined,
): { turn: number; step?: number } {
if (location?.kind === 'step') {
return { turn: location.turn.turn, step: location.step.step }
}
const locatedTurn = location?.kind === 'turn' ? location.turn.turn : undefined
if (followingAssistant !== undefined
&& (locatedTurn === undefined || followingAssistant.turn === locatedTurn)) {
return {
turn: followingAssistant.turn,
...(followingAssistant.step > 0 ? { step: followingAssistant.step } : {}),
}
}
if (partial !== null && (locatedTurn === undefined || partial.turn === locatedTurn)) {
return { turn: partial.turn, ...(partial.step > 0 ? { step: partial.step } : {}) }
}
if (locatedTurn !== undefined) return { turn: locatedTurn }
return { turn: lastAssistantTurn ?? 1 }
}
function indexFollowingAssistants(
nodes: ConversationSnapshot['nodes'],
): readonly (AssistantMessageNode | undefined)[] {
const following = new Array<AssistantMessageNode | undefined>(nodes.length)
let assistant: AssistantMessageNode | undefined
for (let index = nodes.length - 1; index >= 0; index--) {
following[index] = assistant
const node = nodes[index]
if (node?.kind === 'assistant') assistant = node
}
return following
}
function enclosingPromptTurn(
nodes: ConversationSnapshot['nodes'],
seq: number,
@@ -53,12 +53,14 @@ export type TrajectoryContribution =
export interface TrajectoryConversationViewNode extends ConversationViewNode {
readonly target: 'trajectory'
readonly anchorSeq: number
readonly location: ConversationLocation
readonly data: TrajectoryContribution
}
/** Stage-oriented Trajectory data assembled from registered business Contexts. */
export interface TrajectorySnapshot {
readonly eventNodes: readonly ConversationNode[]
readonly eventLocations: ReadonlyMap<number, ConversationLocation>
readonly requests: readonly RequestView[]
readonly callSchemas: ReadonlyMap<string, ConversationPromptSnapshot['tools'][number]>
readonly partial: PartialAssistant | null
@@ -22,6 +22,7 @@ export function trajectoryNode(
id: context.id,
target: 'trajectory',
anchorSeq,
location: context.start?.location ?? { kind: 'unresolved' },
data,
}
}
@@ -16,6 +16,7 @@ type ToolSchema = ConversationPromptSnapshot['tools'][number]
/** Stable empty target used until a Session has assembled Trajectory records. */
export const EMPTY_TRAJECTORY_SNAPSHOT: TrajectorySnapshot = {
eventNodes: EMPTY_LIST,
eventLocations: new Map(),
requests: EMPTY_LIST,
callSchemas: new Map(),
partial: null,
@@ -179,6 +180,7 @@ export class TrajectorySnapshotBuilder implements ConversationViewBuilder<
if (key !== undefined) headersByStep.set(key, contribution.data.header)
}
const finalized: ConversationNode[] = []
const eventLocations = new Map<number, TrajectoryConversationViewNode['location']>()
const requests: RequestView[] = []
const boundaries: { seq: number; time: number }[] = []
const turnEndings: { turn: number; time: number; error?: string }[] = []
@@ -198,6 +200,7 @@ export class TrajectorySnapshotBuilder implements ConversationViewBuilder<
}
if (data.kind === 'node') {
finalized.push(data.node)
eventLocations.set(data.node.seq, contribution.location)
continue
}
if (data.kind === 'assistant') {
@@ -244,6 +247,7 @@ export class TrajectorySnapshotBuilder implements ConversationViewBuilder<
const eventNodes = finalized
return {
eventNodes,
eventLocations,
requests,
callSchemas,
partial,