feat(ui): add trajectory context generations

This commit is contained in:
_Kerman
2026-07-27 15:58:06 +08:00
parent 628c1bffe0
commit 714090bb4d
17 changed files with 542 additions and 43 deletions

View File

@@ -28,8 +28,8 @@ export type {
EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore,
} from './contract/store.ts'
export type {
AssistantBlock, AssistantMessageNode, AssistantTiming, CodeSubCall, ComposerPhase, ContextMessageNode, ConversationNode,
ConversationSnapshot, QueuedMessage, RunningToolCall,
AssistantBlock, AssistantMessageNode, AssistantTiming, CodeSubCall, ComposerPhase, ContextMessageNode,
ConversationContext, ConversationContextOriginKind, ConversationNode, ConversationSnapshot, QueuedMessage, RunningToolCall,
SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
export { PendingWait } from './sessions/pending.ts'

View File

@@ -206,6 +206,25 @@ export type OpenState = 'cold' | 'loading' | 'open' | 'error'
*/
export type ComposerPhase = 'blank' | 'engaging' | 'active'
/** Operation that started a new append-only model context. */
export type ConversationContextOriginKind = 'compaction' | 'rewind' | 'rewrite'
/** One immutable model-context generation reconstructed from surface replacements. */
export interface ConversationContext {
/** Zero-based generation within the session; stable across later appends. */
id: number
/** Previous generation in this session; absent for the initial context. */
parentId?: number
/** Why this generation exists; absent for the initial context. */
origin?: ConversationContextOriginKind
/** Event seq of the replacement that created this generation. */
originSeq?: number
/** Unix epoch ms of the replacement that created this generation. */
createdAt?: number
/** Final frozen nodes for historical generations, or current folded nodes for the tail. */
nodes: readonly ConversationNode[]
}
/** Send/stop failure surfaced in the input error strip; op picks the user-facing copy (发送失败 vs 停止失败). */
export interface PromptError {
op: 'send' | 'stop'
@@ -217,6 +236,8 @@ export interface ConversationSnapshot {
sessionId: SessionId
/** Surface fold product (finalized conversation nodes in surface order). */
nodes: readonly ConversationNode[]
/** Append-only context generations split at every model-surface replacement. */
contexts?: readonly ConversationContext[]
/** Fold degradation flag (cross-window replace defense): when true, nodes come from the lenient linear scan. */
foldDegraded: boolean
partial: PartialAssistant | null

View File

@@ -7,9 +7,13 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
// Subpath export (package.json exports "./surface", alias added for this): all value imports
// go through it — the package root points at lib/index.js (needs a build) which the vite
// browser bundle cannot resolve; surface.ts has no Node dependencies.
import { SurfaceManager, isSurfaceEligibleType } from '@deepseek-ai/dsh-session/surface'
import {
SurfaceManager, isSurfaceEligibleType, isSurfaceEvent,
} from '@deepseek-ai/dsh-session/surface'
import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type { AssistantTiming, ConversationNode } from './conversation.ts'
import type {
AssistantTiming, ConversationContext, ConversationContextOriginKind, ConversationNode,
} from './conversation.ts'
import { toAssistantBlocks } from './conversation.ts'
/** In-window tool/call index entry (result-card backfill + runningCalls material). */
@@ -107,6 +111,9 @@ export class FoldAdapter {
* reference-stability contract (§A.9.4) starts here. */
private rev = 0
private nodesResult: { rev: number; value: { nodes: ConversationNode[]; degraded: boolean } } | null = null
/** Revision of the model-visible surface only; log-only chunks do not rebuild context generations. */
private surfaceRev = 0
private contextsResult: { rev: number; value: readonly ConversationContext[] } | null = null
/** In-window tool/call index (Session uses it for runningCalls and result-card backfill). */
get callIndex(): ReadonlyMap<string, CallIndexEntry> {
@@ -122,6 +129,7 @@ export class FoldAdapter {
*/
reset(events: readonly SessionEvent[], baseSeq: number, views?: readonly (ToolEventView | undefined)[]): void {
this.rev++
this.surfaceRev++
this.baseSeq = baseSeq
this.padded = []
for (let i = 0; i < baseSeq; i++) this.padded.push(paddingEvent(i))
@@ -146,6 +154,7 @@ export class FoldAdapter {
*/
append(event: SessionEvent, view?: ToolEventView): void {
this.rev++
if (isSurfaceEvent(event)) this.surfaceRev++
this.padded.push(event)
this.indexCall(event, view)
}
@@ -193,6 +202,41 @@ export class FoldAdapter {
return value
}
/**
* Append-only context generations reconstructed from canonical surface replacements.
* @returns Frozen historical contexts followed by the current context.
*/
contexts(): readonly ConversationContext[] {
if (this.contextsResult !== null && this.contextsResult.rev === this.surfaceRev) {
return this.contextsResult.value
}
const current = this.nodes()
if (current.degraded) {
const value: readonly ConversationContext[] = [{ id: 0, nodes: current.nodes }]
this.contextsResult = { rev: this.surfaceRev, value }
return value
}
const value = this.surface.contexts.map((context): ConversationContext => {
const nodes: ConversationNode[] = []
for (const seq of context.nodes) {
const node = this.materialize(seq)
if (node !== undefined) nodes.push(node)
}
if (context.origin === undefined) return { id: context.generation, nodes }
const originEvent = this.padded[context.origin.seq]
return {
id: context.generation,
parentId: context.generation - 1,
origin: contextOriginKind(originEvent),
originSeq: context.origin.seq,
...(originEvent === undefined ? {} : { createdAt: originEvent.time }),
nodes,
}
})
this.contextsResult = { rev: this.surfaceRev, value }
return value
}
/** Degradation branch: lenient linear scan ignoring surfaceOp/replace (all surface-eligible events in append order). */
private degradedSeqs(): number[] {
const seqs: number[] = []
@@ -203,6 +247,21 @@ export class FoldAdapter {
return seqs
}
private materialize(seq: number): ConversationNode | undefined {
const cached = this.nodeCache.get(seq)
if (cached !== undefined) return cached
const event = this.padded[seq]
if (event === undefined) return
const node = materializeNode(
event,
this.callIdx,
this.resultViews.get(seq) ?? null,
event.type === 'assistant/message' ? this.assistantTiming(event) : undefined,
)
this.nodeCache.set(seq, node)
return node
}
private assistantTiming(event: SessionEvent<'assistant/message'>): AssistantTiming {
let stepStartTime: number | null = null
let firstTokenTime: number | null = null
@@ -246,6 +305,22 @@ export class FoldAdapter {
}
}
function contextOriginKind(event: SessionEvent | undefined): ConversationContextOriginKind {
if (event?.type !== 'user/message') return 'rewrite'
const source = event.data.source
if (
typeof source === 'object'
&& source !== null
&& 'kind' in source
&& 'plugin' in source
&& source.kind === 'plugin'
) {
if (source.plugin === 'compact') return 'compaction'
if (source.plugin === 'rewind') return 'rewind'
}
return 'rewrite'
}
function isTokenDelta(chunk: SessionEvent<'assistant/chunk'>['data']['chunk']): boolean {
switch (chunk.type) {
case 'text-delta':

View File

@@ -772,6 +772,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
private buildSnapshot(): ConversationSnapshot {
const { nodes: folded, degraded } = this.foldAdapter.nodes()
const contexts = this.foldAdapter.contexts()
// Frozen interrupted nodes ride fractional seqs: a stable merge keeps them in flow order.
// The merged array is cached on (folded reference, frozenRev) so an unchanged flow keeps its
// reference across snapshot swaps (§A.9.4).
@@ -803,6 +804,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
return {
sessionId: this.sessionId,
nodes,
contexts,
foldDegraded: degraded,
partial,
runningCalls: this.callsCache.value,

View File

@@ -0,0 +1,116 @@
.root {
display: flex;
flex: none;
width: 218px;
min-width: 176px;
flex-direction: column;
box-sizing: border-box;
overflow: hidden;
border-right: 1px solid var(--dsw-alias-border-l2);
background: var(--dsw-alias-bg-layer-1);
}
.header {
display: flex;
flex: none;
height: 34px;
align-items: center;
box-sizing: border-box;
padding: 0 12px;
border-bottom: 1px solid var(--dsw-alias-border-l1);
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xs-strong-13);
user-select: none;
}
.list {
min-height: 0;
padding: 4px;
overflow: auto;
}
.item {
display: flex;
width: 100%;
min-width: 0;
min-height: 42px;
align-items: center;
box-sizing: border-box;
padding: 4px 7px;
gap: 7px;
border: 0;
border-radius: 4px;
color: var(--dsw-alias-label-primary);
background: transparent;
cursor: pointer;
text-align: left;
}
.item:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.item:focus-visible {
outline: 1px solid var(--dsw-alias-state-business-primary);
outline-offset: -1px;
}
.itemSelected {
background: var(--dsw-alias-interactive-bg-active);
box-shadow: inset 2px 0 var(--dsw-alias-state-business-primary);
}
.icon {
flex: none;
color: var(--dsw-alias-label-caption);
}
.itemSelected .icon {
color: var(--dsw-alias-state-business-primary);
}
.itemBody {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
}
.itemTitle,
.itemMeta {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.itemTitle {
font: var(--dsw-font-xs-13);
}
.itemMeta {
color: var(--dsw-alias-label-tertiary);
font: 11px/16px var(--ds-font-family-code);
}
.current,
.frozen {
flex: none;
align-self: flex-start;
padding-top: 1px;
font: 10px/16px var(--ds-font-family-code);
user-select: none;
}
.current {
color: var(--dsw-alias-state-business-primary);
}
.frozen {
color: var(--dsw-alias-label-caption);
}
@media (max-width: 820px) {
.root {
width: 184px;
}
}

View File

@@ -0,0 +1,84 @@
/** Context-generation selector for a trajectory session. */
import { IconBranchOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type {
ConversationContext, ConversationContextOriginKind,
} from '@deepseek-ai/dsh-client-runtime/client'
import css from './ContextsPanel.module.css'
export interface ContextsPanelProps {
contexts: readonly ConversationContext[]
selectedId: number
currentId: number
onSelect(id: number): void
}
function formatTime(timestamp: number | undefined): string | undefined {
if (timestamp === undefined || !Number.isFinite(timestamp)) return
const date = new Date(timestamp)
const two = (value: number) => String(value).padStart(2, '0')
return `${two(date.getHours())}:${two(date.getMinutes())}:${two(date.getSeconds())}`
}
function originLabel(origin: ConversationContextOriginKind | undefined): string {
if (origin === 'compaction') return 'Compaction'
if (origin === 'rewind') return 'Rewind'
if (origin === 'rewrite') return 'Context rewrite'
return 'Initial context'
}
/** Human-facing context title without exposing internal generation ids. */
export function contextLabel(context: ConversationContext): string {
const label = originLabel(context.origin)
const time = formatTime(context.createdAt)
return time === undefined ? label : `${label} · ${time}`
}
/**
* Render every append-only context generation in creation order.
* @param props - Contexts and the selected/current identities.
* @returns The context navigation panel.
*/
export function ContextsPanel({
contexts,
selectedId,
currentId,
onSelect,
}: ContextsPanelProps) {
return (
<aside className={css.root} aria-label="Contexts">
<div className={css.header}>Contexts</div>
<div className={css.list}>
{contexts.map((context) => {
const selected = context.id === selectedId
const current = context.id === currentId
const parent = context.parentId === undefined
? undefined
: contexts.find(candidate => candidate.id === context.parentId)
return (
<button
key={context.id}
type="button"
className={selected ? `${css.item} ${css.itemSelected}` : css.item}
aria-current={selected ? 'true' : undefined}
onClick={() => { onSelect(context.id) }}
>
<IconBranchOutline16 className={css.icon} size={14} />
<span className={css.itemBody}>
<span className={css.itemTitle}>{contextLabel(context)}</span>
<span className={css.itemMeta}>
{context.origin === undefined
? 'Session origin'
: `from ${parent === undefined ? 'previous context' : originLabel(parent.origin)}`}
</span>
</span>
<span className={current ? css.current : css.frozen}>
{current ? 'Current' : 'Frozen'}
</span>
</button>
)
})}
</div>
</aside>
)
}

View File

@@ -51,6 +51,11 @@
background: var(--dsw-alias-state-success-tertiary);
}
.tagContext {
color: var(--dsw-alias-label-secondary);
background: var(--dsw-alias-bg-layer-3);
}
.tagMessage {
color: var(--dsw-alias-brand-primary-new-colorprimary-new-color);
background: var(--dsw-specific-bubble);

View File

@@ -17,6 +17,7 @@ export type {
/** Display label per kind (matches the design tags). */
const KIND_LABEL: Record<TrajectoryCellKind, string> = {
user: 'User',
context: 'Context',
message: 'Message',
tool: 'Tool',
subtool: 'Sub',
@@ -24,6 +25,7 @@ const KIND_LABEL: Record<TrajectoryCellKind, string> = {
const TAG_CLASS: Record<TrajectoryCellKind, string> = {
user: css.tagUser!,
context: css.tagContext!,
message: css.tagMessage!,
tool: css.tagTool!,
subtool: css.tagSubtool!,

View File

@@ -230,6 +230,12 @@
background: var(--dsw-alias-state-business-tertiary);
}
.context {
border-color: var(--dsw-alias-border-l2);
color: var(--dsw-alias-label-secondary);
background: var(--dsw-alias-bg-layer-1);
}
.message {
border-color: var(--dsw-alias-border-l2);
color: var(--dsw-alias-label-secondary);

View File

@@ -14,6 +14,7 @@ import css from './TrajectoryTable.module.css'
const KIND_LABEL: Record<TrajectoryCellKind, string> = {
user: 'USER',
context: 'CONTEXT',
message: 'ASSISTANT',
tool: 'TOOL',
subtool: 'SUBTOOL',
@@ -331,7 +332,9 @@ function tokenSummary(cell: TrajectoryCellProps): string {
}
function isMarkdownRecord(record: TableRecord): boolean {
return record.cell.kind === 'user' || record.cell.kind === 'message'
return record.cell.kind === 'user'
|| record.cell.kind === 'context'
|| record.cell.kind === 'message'
}
function parentRecords(
@@ -369,7 +372,9 @@ function parentRecords(
}
function markdownSource(record: TableRecord): string | undefined {
if (record.cell.kind === 'user') return record.cell.inputDetail
if (record.cell.kind === 'user' || record.cell.kind === 'context') {
return record.cell.inputDetail
}
if (record.cell.kind === 'message') return record.cell.outputDetail
return undefined
}
@@ -394,7 +399,7 @@ function detailTabs(record: TableRecord): readonly DetailTabItem[] {
function recordDisplayText(cell: TrajectoryCellProps): string {
if (isToolCallOnly(cell)) return ''
const markdown = cell.kind === 'user'
const markdown = cell.kind === 'user' || cell.kind === 'context'
? cell.inputDetail
: cell.kind === 'message'
? cell.outputDetail ?? cell.thinkingDetail
@@ -735,7 +740,8 @@ function RecordPayload({
}
const markdown = (
direction === 'input' && record.cell.kind === 'user'
direction === 'input'
&& (record.cell.kind === 'user' || record.cell.kind === 'context')
) || (
direction === 'output' && record.cell.kind === 'message'
)
@@ -1015,7 +1021,9 @@ export function TrajectoryTable({
{!isCollapsedSummary && (
<span
className={
record.cell.kind === 'user' || record.cell.kind === 'message'
record.cell.kind === 'user'
|| record.cell.kind === 'context'
|| record.cell.kind === 'message'
? `${css.kindSlot} ${css.kindSlotLeft}`
: `${css.kindSlot} ${css.kindSlotRight}`
}

View File

@@ -32,6 +32,36 @@
font: var(--dsw-font-xs-strong-13);
}
.separator {
flex: none;
color: var(--dsw-alias-label-caption);
user-select: none;
}
.context {
min-width: 0;
overflow: hidden;
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xs-13);
text-overflow: ellipsis;
white-space: nowrap;
}
.contextCurrent,
.contextFrozen {
flex: none;
font: 10px/16px var(--ds-font-family-code);
user-select: none;
}
.contextCurrent {
color: var(--dsw-alias-state-business-primary);
}
.contextFrozen {
color: var(--dsw-alias-label-caption);
}
.actions {
display: flex;
flex: none;

View File

@@ -3,6 +3,10 @@
import css from './TrajectoryToolbar.module.css'
export interface TrajectoryToolbarProps {
/** Selected context title when the session contains discontinuities. */
contextLabel?: string
/** Whether the selected context is the live tail. */
contextCurrent?: boolean
/** Number of turns containing more than one row. */
collapsibleTurns: number
/** Whether every collapsible turn is currently folded. */
@@ -23,6 +27,8 @@ export interface TrajectoryToolbarProps {
* @returns the toolbar element.
*/
export function TrajectoryToolbar({
contextLabel,
contextCurrent,
collapsibleTurns,
allTurnsCollapsed,
onToggleAllTurns,
@@ -35,6 +41,15 @@ export function TrajectoryToolbar({
<div className={css.inner}>
<div className={css.summary}>
<span className={css.title}>Trajectory</span>
{contextLabel !== undefined && (
<>
<span className={css.separator}>/</span>
<span className={css.context}>{contextLabel}</span>
<span className={contextCurrent ? css.contextCurrent : css.contextFrozen}>
{contextCurrent ? 'Current' : 'Frozen'}
</span>
</>
)}
</div>
<div className={css.actions}>
<button

View File

@@ -2,22 +2,60 @@
import { useMemo, useState } from 'react'
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ConversationContext } from '@deepseek-ai/dsh-client-runtime/client'
import { ContextsPanel, contextLabel } from './ContextsPanel.tsx'
import { TrajectoryTable } from './TrajectoryTable.tsx'
import { TrajectoryToolbar } from './TrajectoryToolbar.tsx'
import { deriveTrajectoryLayout } from './layout.ts'
import css from './views.module.css'
const EMPTY_IDS: ReadonlySet<number> = new Set()
function currentContextOf(contexts: readonly ConversationContext[]): ConversationContext {
const context = contexts.at(-1)
if (context === undefined) throw new Error('trajectory context projection must not be empty')
return context
}
export function TrajectoryView({ useSession }: ConvViewProps) {
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(() => new Set())
const [collapsedAssistants, setCollapsedAssistants] = useState<ReadonlySet<number>>(() => new Set())
const [selectedContextId, setSelectedContextId] = useState<number | null>(null)
const [collapsedTurnsByContext, setCollapsedTurnsByContext] = useState<
ReadonlyMap<number, ReadonlySet<number>>
>(() => new Map())
const [collapsedAssistantsByContext, setCollapsedAssistantsByContext] = useState<
ReadonlyMap<number, ReadonlySet<number>>
>(() => new Map())
const nodes = useSession((s) => s.nodes)
const projectedContexts = useSession((s) => s.contexts)
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 contexts = useMemo<readonly ConversationContext[]>(
() => projectedContexts === undefined || projectedContexts.length === 0
? [{ id: 0, nodes }]
: projectedContexts,
[nodes, projectedContexts],
)
const currentContext = currentContextOf(contexts)
const selectedContext = selectedContextId === null
? currentContext
: contexts.find(context => context.id === selectedContextId) ?? currentContext
const viewingCurrent = selectedContext.id === currentContext.id
const selectedNodes = viewingCurrent ? nodes : selectedContext.nodes
const collapsedTurns = collapsedTurnsByContext.get(selectedContext.id) ?? EMPTY_IDS
const collapsedAssistants = collapsedAssistantsByContext.get(selectedContext.id) ?? EMPTY_IDS
const turns = useMemo(
() => deriveTrajectoryLayout({ nodes, partial, runningCalls, callSchemas, codeDispatches }),
[nodes, partial, runningCalls, callSchemas, codeDispatches],
() => deriveTrajectoryLayout({
nodes: selectedNodes,
partial: viewingCurrent ? partial : null,
runningCalls: viewingCurrent ? runningCalls : [],
callSchemas,
codeDispatches,
}),
[
selectedNodes, viewingCurrent, partial, runningCalls, callSchemas, codeDispatches,
],
)
const collapsibleTurnIds = useMemo(
() => turns
@@ -44,50 +82,68 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
&& collapsibleAssistantIds.every(index => collapsedAssistants.has(index))
const toggleTurn = (turn: number) => {
setCollapsedTurns((current) => {
const next = new Set(current)
if (next.has(turn)) next.delete(turn)
else next.add(turn)
setCollapsedTurnsByContext((current) => {
const next = new Map(current)
const collapsed = new Set(current.get(selectedContext.id) ?? EMPTY_IDS)
if (collapsed.has(turn)) collapsed.delete(turn)
else collapsed.add(turn)
next.set(selectedContext.id, collapsed)
return next
})
}
const toggleAllTurns = () => {
setCollapsedTurns((current) => {
const next = new Set(current)
setCollapsedTurnsByContext((current) => {
const next = new Map(current)
const collapsed = new Set(current.get(selectedContext.id) ?? EMPTY_IDS)
if (allTurnsCollapsed) {
for (const turn of collapsibleTurnIds) next.delete(turn)
for (const turn of collapsibleTurnIds) collapsed.delete(turn)
} else {
for (const turn of collapsibleTurnIds) next.add(turn)
for (const turn of collapsibleTurnIds) collapsed.add(turn)
}
next.set(selectedContext.id, collapsed)
return next
})
}
const toggleAssistant = (index: number) => {
setCollapsedAssistants((current) => {
const next = new Set(current)
if (next.has(index)) next.delete(index)
else next.add(index)
setCollapsedAssistantsByContext((current) => {
const next = new Map(current)
const collapsed = new Set(current.get(selectedContext.id) ?? EMPTY_IDS)
if (collapsed.has(index)) collapsed.delete(index)
else collapsed.add(index)
next.set(selectedContext.id, collapsed)
return next
})
}
const toggleAllAssistants = () => {
setCollapsedAssistants((current) => {
const next = new Set(current)
setCollapsedAssistantsByContext((current) => {
const next = new Map(current)
const collapsed = new Set(current.get(selectedContext.id) ?? EMPTY_IDS)
if (allAssistantsCollapsed) {
for (const index of collapsibleAssistantIds) next.delete(index)
for (const index of collapsibleAssistantIds) collapsed.delete(index)
} else {
for (const index of collapsibleAssistantIds) next.add(index)
for (const index of collapsibleAssistantIds) collapsed.add(index)
}
next.set(selectedContext.id, collapsed)
return next
})
}
const selectContext = (id: number) => {
setSelectedContextId(id === currentContext.id ? null : id)
}
return (
<div className={css.root}>
<TrajectoryToolbar
{...contexts.length > 1
? {
contextLabel: contextLabel(selectedContext),
contextCurrent: viewingCurrent,
}
: {}}
collapsibleTurns={collapsibleTurnIds.length}
allTurnsCollapsed={allTurnsCollapsed}
onToggleAllTurns={toggleAllTurns}
@@ -95,16 +151,29 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
allAssistantsCollapsed={allAssistantsCollapsed}
onToggleAllAssistants={toggleAllAssistants}
/>
{turns.length === 0 && <p className={css.empty}>No trajectory events</p>}
{turns.length > 0 && (
<TrajectoryTable
turns={turns}
collapsedTurns={collapsedTurns}
onToggleTurn={toggleTurn}
collapsedAssistants={collapsedAssistants}
onToggleAssistant={toggleAssistant}
/>
)}
<div className={css.contextLayout}>
{contexts.length > 1 && (
<ContextsPanel
contexts={contexts}
selectedId={selectedContext.id}
currentId={currentContext.id}
onSelect={selectContext}
/>
)}
<div className={css.ledger}>
{turns.length === 0 && <p className={css.empty}>No trajectory events</p>}
{turns.length > 0 && (
<TrajectoryTable
key={selectedContext.id}
turns={turns}
collapsedTurns={collapsedTurns}
onToggleTurn={toggleTurn}
collapsedAssistants={collapsedAssistants}
onToggleAssistant={toggleAssistant}
/>
)}
</div>
</div>
</div>
)
}

View File

@@ -132,7 +132,19 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
continue
}
if (node.kind === 'context') {
// No trajectory cell, but the surface still advances the duration cursor.
const turn = enclosingUserTurn(nodes, i, partial, lastAssistantTurn)
pushMessage(turn, {
absTime: finiteTime(node.time),
cell: {
index: ++index,
kind: 'context',
text: summarizeContent(node.content),
inputDetail: detailContent(node.content),
sourceBlocks: node.content.map(block => sourceBlock(block)),
timeSeconds: 0,
startedAt: finiteTime(node.time),
},
})
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
continue
}

View File

@@ -3,7 +3,7 @@
import type { HTMLAttributes } from 'react'
/** Closed set of trajectory record kinds. */
export type TrajectoryCellKind = 'user' | 'message' | 'tool' | 'subtool'
export type TrajectoryCellKind = 'user' | 'context' | 'message' | 'tool' | 'subtool'
/** Recorded inputs needed to derive assistant TTFT and decode throughput. */
export interface AssistantMetricDetail {

View File

@@ -23,6 +23,22 @@
font: var(--dsw-font-xs-13);
}
.contextLayout {
display: flex;
flex: 1;
min-height: 0;
min-width: 0;
overflow: hidden;
}
.ledger {
display: flex;
flex: 1;
min-height: 0;
min-width: 0;
overflow: hidden;
}
/* Waterfall placeholder rows (shared module). */
.row {
display: flex;

View File

@@ -57,6 +57,16 @@ export interface SurfaceFoldResult {
replacements: SurfaceFoldReplacement[]
}
/** One append-only surface generation separated from its successor by a replacement. */
export interface SurfaceFoldContext {
/** Zero-based generation within this session log. */
generation: number
/** Surface sequences present when this generation froze, or at the current tail. */
nodes: number[]
/** Replacement operation that created this generation; absent for the initial context. */
origin?: SurfaceFoldReplacement
}
/** Readonly live projection of the message-producing session events. */
export interface SessionSurface {
/** Current surface event sequences in model-visible order. */
@@ -301,8 +311,12 @@ export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult
/** Incremental ordered surface view and append-boundary validator. */
export class SurfaceManager implements SessionSurface {
/** Shared transition state; replacement history is not retained. */
/** Shared transition state for the live surface. */
private _state = createFoldState()
/** Frozen generations completed by replacements. */
private _contexts: SurfaceFoldContext[] = []
/** Replacement that created the live generation. */
private _contextOrigin: SurfaceFoldReplacement | undefined
/** Last processed seq; -1 folds a seeded log on first access. */
private _lastProcessedSeq = -1
@@ -329,11 +343,35 @@ export class SurfaceManager implements SessionSurface {
return this._state.nodes
}
/** Frozen generations followed by a detached snapshot of the live generation. */
get contexts(): readonly SurfaceFoldContext[] {
if (this._lastProcessedSeq < this.log.length - 1) this._processDelta()
return [
...this._contexts,
{
generation: this._state.replaceGeneration,
nodes: [...this._state.nodes],
...(this._contextOrigin === undefined ? {} : { origin: this._contextOrigin }),
},
]
}
/** Fold events appended since the previous access. */
private _processDelta(): void {
for (let i = this._lastProcessedSeq + 1; i < this.log.length; i++) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition
applySurfaceEvent(this._state, this.log[i]!, i, this.log)
const event = this.log[i]!
const op = surfaceOpOf(event)
const priorNodes = typeof op === 'object' ? [...this._state.nodes] : undefined
const replacement = applySurfaceEvent(this._state, event, i, this.log)
if (replacement !== undefined && priorNodes !== undefined) {
this._contexts.push({
generation: this._state.replaceGeneration - 1,
nodes: priorNodes,
...(this._contextOrigin === undefined ? {} : { origin: this._contextOrigin }),
})
this._contextOrigin = replacement
}
this._lastProcessedSeq = i
}
}