From 714090bb4d7ba380502f01bc61984fb997a2802b Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 27 Jul 2026 15:58:06 +0800 Subject: [PATCH] feat(ui): add trajectory context generations --- packages/client/runtime/src/client/index.ts | 4 +- .../src/client/sessions/conversation.ts | 21 +++ .../src/client/sessions/fold-adapter.ts | 79 ++++++++++- .../runtime/src/client/sessions/session.ts | 2 + .../src/client/ContextsPanel.module.css | 116 ++++++++++++++++ .../src/client/ContextsPanel.tsx | 84 ++++++++++++ .../src/client/TrajectoryCell.module.css | 5 + .../src/client/TrajectoryCell.tsx | 2 + .../src/client/TrajectoryTable.module.css | 6 + .../src/client/TrajectoryTable.tsx | 18 ++- .../src/client/TrajectoryToolbar.module.css | 30 ++++ .../src/client/TrajectoryToolbar.tsx | 15 ++ .../src/client/TrajectoryView.tsx | 129 ++++++++++++++---- .../client/ui-trajectory/src/client/layout.ts | 14 +- .../src/client/trajectory-record.ts | 2 +- .../ui-trajectory/src/client/views.module.css | 16 +++ packages/core/session/src/surface.ts | 42 +++++- 17 files changed, 542 insertions(+), 43 deletions(-) create mode 100644 packages/client/ui-trajectory/src/client/ContextsPanel.module.css create mode 100644 packages/client/ui-trajectory/src/client/ContextsPanel.tsx diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 15b3beebb8..5ee2b3e492 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -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' diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 609c2830bc..b888e8668f 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -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 diff --git a/packages/client/runtime/src/client/sessions/fold-adapter.ts b/packages/client/runtime/src/client/sessions/fold-adapter.ts index 9fc302398a..ef715854f3 100644 --- a/packages/client/runtime/src/client/sessions/fold-adapter.ts +++ b/packages/client/runtime/src/client/sessions/fold-adapter.ts @@ -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 { @@ -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': diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 493ca6baa7..3e1eed36bd 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -772,6 +772,7 @@ export class Session implements ObservableSnapshot { 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 { return { sessionId: this.sessionId, nodes, + contexts, foldDegraded: degraded, partial, runningCalls: this.callsCache.value, diff --git a/packages/client/ui-trajectory/src/client/ContextsPanel.module.css b/packages/client/ui-trajectory/src/client/ContextsPanel.module.css new file mode 100644 index 0000000000..e358fe4055 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/ContextsPanel.module.css @@ -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; + } +} diff --git a/packages/client/ui-trajectory/src/client/ContextsPanel.tsx b/packages/client/ui-trajectory/src/client/ContextsPanel.tsx new file mode 100644 index 0000000000..aa58789beb --- /dev/null +++ b/packages/client/ui-trajectory/src/client/ContextsPanel.tsx @@ -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 ( + + ) +} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryCell.module.css b/packages/client/ui-trajectory/src/client/TrajectoryCell.module.css index c5efc232d1..496a75c566 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryCell.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryCell.module.css @@ -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); diff --git a/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx b/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx index 384d0e8b30..211b1dc3d4 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx @@ -17,6 +17,7 @@ export type { /** Display label per kind (matches the design tags). */ const KIND_LABEL: Record = { user: 'User', + context: 'Context', message: 'Message', tool: 'Tool', subtool: 'Sub', @@ -24,6 +25,7 @@ const KIND_LABEL: Record = { const TAG_CLASS: Record = { user: css.tagUser!, + context: css.tagContext!, message: css.tagMessage!, tool: css.tagTool!, subtool: css.tagSubtool!, diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index e936d2eb48..f7098796a2 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -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); diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index f50ec809a5..db153276cd 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -14,6 +14,7 @@ import css from './TrajectoryTable.module.css' const KIND_LABEL: Record = { 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 && (
Trajectory + {contextLabel !== undefined && ( + <> + / + {contextLabel} + + {contextCurrent ? 'Current' : 'Frozen'} + + + )}