feat(ui): add trajectory context generations
This commit is contained in:
@@ -28,8 +28,8 @@ export type {
|
|||||||
EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore,
|
EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore,
|
||||||
} from './contract/store.ts'
|
} from './contract/store.ts'
|
||||||
export type {
|
export type {
|
||||||
AssistantBlock, AssistantMessageNode, AssistantTiming, CodeSubCall, ComposerPhase, ContextMessageNode, ConversationNode,
|
AssistantBlock, AssistantMessageNode, AssistantTiming, CodeSubCall, ComposerPhase, ContextMessageNode,
|
||||||
ConversationSnapshot, QueuedMessage, RunningToolCall,
|
ConversationContext, ConversationContextOriginKind, ConversationNode, ConversationSnapshot, QueuedMessage, RunningToolCall,
|
||||||
SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
|
SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
|
||||||
} from './sessions/conversation.ts'
|
} from './sessions/conversation.ts'
|
||||||
export { PendingWait } from './sessions/pending.ts'
|
export { PendingWait } from './sessions/pending.ts'
|
||||||
|
|||||||
@@ -206,6 +206,25 @@ export type OpenState = 'cold' | 'loading' | 'open' | 'error'
|
|||||||
*/
|
*/
|
||||||
export type ComposerPhase = 'blank' | 'engaging' | 'active'
|
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 停止失败). */
|
/** Send/stop failure surfaced in the input error strip; op picks the user-facing copy (发送失败 vs 停止失败). */
|
||||||
export interface PromptError {
|
export interface PromptError {
|
||||||
op: 'send' | 'stop'
|
op: 'send' | 'stop'
|
||||||
@@ -217,6 +236,8 @@ export interface ConversationSnapshot {
|
|||||||
sessionId: SessionId
|
sessionId: SessionId
|
||||||
/** Surface fold product (finalized conversation nodes in surface order). */
|
/** Surface fold product (finalized conversation nodes in surface order). */
|
||||||
nodes: readonly ConversationNode[]
|
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. */
|
/** Fold degradation flag (cross-window replace defense): when true, nodes come from the lenient linear scan. */
|
||||||
foldDegraded: boolean
|
foldDegraded: boolean
|
||||||
partial: PartialAssistant | null
|
partial: PartialAssistant | null
|
||||||
|
|||||||
@@ -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
|
// 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
|
// 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.
|
// 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 { 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'
|
import { toAssistantBlocks } from './conversation.ts'
|
||||||
|
|
||||||
/** In-window tool/call index entry (result-card backfill + runningCalls material). */
|
/** 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. */
|
* reference-stability contract (§A.9.4) starts here. */
|
||||||
private rev = 0
|
private rev = 0
|
||||||
private nodesResult: { rev: number; value: { nodes: ConversationNode[]; degraded: boolean } } | null = null
|
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). */
|
/** In-window tool/call index (Session uses it for runningCalls and result-card backfill). */
|
||||||
get callIndex(): ReadonlyMap<string, CallIndexEntry> {
|
get callIndex(): ReadonlyMap<string, CallIndexEntry> {
|
||||||
@@ -122,6 +129,7 @@ export class FoldAdapter {
|
|||||||
*/
|
*/
|
||||||
reset(events: readonly SessionEvent[], baseSeq: number, views?: readonly (ToolEventView | undefined)[]): void {
|
reset(events: readonly SessionEvent[], baseSeq: number, views?: readonly (ToolEventView | undefined)[]): void {
|
||||||
this.rev++
|
this.rev++
|
||||||
|
this.surfaceRev++
|
||||||
this.baseSeq = baseSeq
|
this.baseSeq = baseSeq
|
||||||
this.padded = []
|
this.padded = []
|
||||||
for (let i = 0; i < baseSeq; i++) this.padded.push(paddingEvent(i))
|
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 {
|
append(event: SessionEvent, view?: ToolEventView): void {
|
||||||
this.rev++
|
this.rev++
|
||||||
|
if (isSurfaceEvent(event)) this.surfaceRev++
|
||||||
this.padded.push(event)
|
this.padded.push(event)
|
||||||
this.indexCall(event, view)
|
this.indexCall(event, view)
|
||||||
}
|
}
|
||||||
@@ -193,6 +202,41 @@ export class FoldAdapter {
|
|||||||
return value
|
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). */
|
/** Degradation branch: lenient linear scan ignoring surfaceOp/replace (all surface-eligible events in append order). */
|
||||||
private degradedSeqs(): number[] {
|
private degradedSeqs(): number[] {
|
||||||
const seqs: number[] = []
|
const seqs: number[] = []
|
||||||
@@ -203,6 +247,21 @@ export class FoldAdapter {
|
|||||||
return seqs
|
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 {
|
private assistantTiming(event: SessionEvent<'assistant/message'>): AssistantTiming {
|
||||||
let stepStartTime: number | null = null
|
let stepStartTime: number | null = null
|
||||||
let firstTokenTime: 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 {
|
function isTokenDelta(chunk: SessionEvent<'assistant/chunk'>['data']['chunk']): boolean {
|
||||||
switch (chunk.type) {
|
switch (chunk.type) {
|
||||||
case 'text-delta':
|
case 'text-delta':
|
||||||
|
|||||||
@@ -772,6 +772,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
|||||||
|
|
||||||
private buildSnapshot(): ConversationSnapshot {
|
private buildSnapshot(): ConversationSnapshot {
|
||||||
const { nodes: folded, degraded } = this.foldAdapter.nodes()
|
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.
|
// 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
|
// The merged array is cached on (folded reference, frozenRev) so an unchanged flow keeps its
|
||||||
// reference across snapshot swaps (§A.9.4).
|
// reference across snapshot swaps (§A.9.4).
|
||||||
@@ -803,6 +804,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
|||||||
return {
|
return {
|
||||||
sessionId: this.sessionId,
|
sessionId: this.sessionId,
|
||||||
nodes,
|
nodes,
|
||||||
|
contexts,
|
||||||
foldDegraded: degraded,
|
foldDegraded: degraded,
|
||||||
partial,
|
partial,
|
||||||
runningCalls: this.callsCache.value,
|
runningCalls: this.callsCache.value,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
84
packages/client/ui-trajectory/src/client/ContextsPanel.tsx
Normal file
84
packages/client/ui-trajectory/src/client/ContextsPanel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -51,6 +51,11 @@
|
|||||||
background: var(--dsw-alias-state-success-tertiary);
|
background: var(--dsw-alias-state-success-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tagContext {
|
||||||
|
color: var(--dsw-alias-label-secondary);
|
||||||
|
background: var(--dsw-alias-bg-layer-3);
|
||||||
|
}
|
||||||
|
|
||||||
.tagMessage {
|
.tagMessage {
|
||||||
color: var(--dsw-alias-brand-primary-new-colorprimary-new-color);
|
color: var(--dsw-alias-brand-primary-new-colorprimary-new-color);
|
||||||
background: var(--dsw-specific-bubble);
|
background: var(--dsw-specific-bubble);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export type {
|
|||||||
/** Display label per kind (matches the design tags). */
|
/** Display label per kind (matches the design tags). */
|
||||||
const KIND_LABEL: Record<TrajectoryCellKind, string> = {
|
const KIND_LABEL: Record<TrajectoryCellKind, string> = {
|
||||||
user: 'User',
|
user: 'User',
|
||||||
|
context: 'Context',
|
||||||
message: 'Message',
|
message: 'Message',
|
||||||
tool: 'Tool',
|
tool: 'Tool',
|
||||||
subtool: 'Sub',
|
subtool: 'Sub',
|
||||||
@@ -24,6 +25,7 @@ const KIND_LABEL: Record<TrajectoryCellKind, string> = {
|
|||||||
|
|
||||||
const TAG_CLASS: Record<TrajectoryCellKind, string> = {
|
const TAG_CLASS: Record<TrajectoryCellKind, string> = {
|
||||||
user: css.tagUser!,
|
user: css.tagUser!,
|
||||||
|
context: css.tagContext!,
|
||||||
message: css.tagMessage!,
|
message: css.tagMessage!,
|
||||||
tool: css.tagTool!,
|
tool: css.tagTool!,
|
||||||
subtool: css.tagSubtool!,
|
subtool: css.tagSubtool!,
|
||||||
|
|||||||
@@ -230,6 +230,12 @@
|
|||||||
background: var(--dsw-alias-state-business-tertiary);
|
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 {
|
.message {
|
||||||
border-color: var(--dsw-alias-border-l2);
|
border-color: var(--dsw-alias-border-l2);
|
||||||
color: var(--dsw-alias-label-secondary);
|
color: var(--dsw-alias-label-secondary);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import css from './TrajectoryTable.module.css'
|
|||||||
|
|
||||||
const KIND_LABEL: Record<TrajectoryCellKind, string> = {
|
const KIND_LABEL: Record<TrajectoryCellKind, string> = {
|
||||||
user: 'USER',
|
user: 'USER',
|
||||||
|
context: 'CONTEXT',
|
||||||
message: 'ASSISTANT',
|
message: 'ASSISTANT',
|
||||||
tool: 'TOOL',
|
tool: 'TOOL',
|
||||||
subtool: 'SUBTOOL',
|
subtool: 'SUBTOOL',
|
||||||
@@ -331,7 +332,9 @@ function tokenSummary(cell: TrajectoryCellProps): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isMarkdownRecord(record: TableRecord): boolean {
|
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(
|
function parentRecords(
|
||||||
@@ -369,7 +372,9 @@ function parentRecords(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function markdownSource(record: TableRecord): string | undefined {
|
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
|
if (record.cell.kind === 'message') return record.cell.outputDetail
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
@@ -394,7 +399,7 @@ function detailTabs(record: TableRecord): readonly DetailTabItem[] {
|
|||||||
|
|
||||||
function recordDisplayText(cell: TrajectoryCellProps): string {
|
function recordDisplayText(cell: TrajectoryCellProps): string {
|
||||||
if (isToolCallOnly(cell)) return ''
|
if (isToolCallOnly(cell)) return ''
|
||||||
const markdown = cell.kind === 'user'
|
const markdown = cell.kind === 'user' || cell.kind === 'context'
|
||||||
? cell.inputDetail
|
? cell.inputDetail
|
||||||
: cell.kind === 'message'
|
: cell.kind === 'message'
|
||||||
? cell.outputDetail ?? cell.thinkingDetail
|
? cell.outputDetail ?? cell.thinkingDetail
|
||||||
@@ -735,7 +740,8 @@ function RecordPayload({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const markdown = (
|
const markdown = (
|
||||||
direction === 'input' && record.cell.kind === 'user'
|
direction === 'input'
|
||||||
|
&& (record.cell.kind === 'user' || record.cell.kind === 'context')
|
||||||
) || (
|
) || (
|
||||||
direction === 'output' && record.cell.kind === 'message'
|
direction === 'output' && record.cell.kind === 'message'
|
||||||
)
|
)
|
||||||
@@ -1015,7 +1021,9 @@ export function TrajectoryTable({
|
|||||||
{!isCollapsedSummary && (
|
{!isCollapsedSummary && (
|
||||||
<span
|
<span
|
||||||
className={
|
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.kindSlotLeft}`
|
||||||
: `${css.kindSlot} ${css.kindSlotRight}`
|
: `${css.kindSlot} ${css.kindSlotRight}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,36 @@
|
|||||||
font: var(--dsw-font-xs-strong-13);
|
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 {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: none;
|
flex: none;
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
import css from './TrajectoryToolbar.module.css'
|
import css from './TrajectoryToolbar.module.css'
|
||||||
|
|
||||||
export interface TrajectoryToolbarProps {
|
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. */
|
/** Number of turns containing more than one row. */
|
||||||
collapsibleTurns: number
|
collapsibleTurns: number
|
||||||
/** Whether every collapsible turn is currently folded. */
|
/** Whether every collapsible turn is currently folded. */
|
||||||
@@ -23,6 +27,8 @@ export interface TrajectoryToolbarProps {
|
|||||||
* @returns the toolbar element.
|
* @returns the toolbar element.
|
||||||
*/
|
*/
|
||||||
export function TrajectoryToolbar({
|
export function TrajectoryToolbar({
|
||||||
|
contextLabel,
|
||||||
|
contextCurrent,
|
||||||
collapsibleTurns,
|
collapsibleTurns,
|
||||||
allTurnsCollapsed,
|
allTurnsCollapsed,
|
||||||
onToggleAllTurns,
|
onToggleAllTurns,
|
||||||
@@ -35,6 +41,15 @@ export function TrajectoryToolbar({
|
|||||||
<div className={css.inner}>
|
<div className={css.inner}>
|
||||||
<div className={css.summary}>
|
<div className={css.summary}>
|
||||||
<span className={css.title}>Trajectory</span>
|
<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>
|
||||||
<div className={css.actions}>
|
<div className={css.actions}>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -2,22 +2,60 @@
|
|||||||
|
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
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 { TrajectoryTable } from './TrajectoryTable.tsx'
|
||||||
import { TrajectoryToolbar } from './TrajectoryToolbar.tsx'
|
import { TrajectoryToolbar } from './TrajectoryToolbar.tsx'
|
||||||
import { deriveTrajectoryLayout } from './layout.ts'
|
import { deriveTrajectoryLayout } from './layout.ts'
|
||||||
import css from './views.module.css'
|
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) {
|
export function TrajectoryView({ useSession }: ConvViewProps) {
|
||||||
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(() => new Set())
|
const [selectedContextId, setSelectedContextId] = useState<number | null>(null)
|
||||||
const [collapsedAssistants, setCollapsedAssistants] = useState<ReadonlySet<number>>(() => new Set())
|
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 nodes = useSession((s) => s.nodes)
|
||||||
|
const projectedContexts = useSession((s) => s.contexts)
|
||||||
const partial = useSession((s) => s.partial)
|
const partial = useSession((s) => s.partial)
|
||||||
const runningCalls = useSession((s) => s.runningCalls)
|
const runningCalls = useSession((s) => s.runningCalls)
|
||||||
const callSchemas = useSession((s) => s.callSchemas)
|
const callSchemas = useSession((s) => s.callSchemas)
|
||||||
const codeDispatches = useSession((s) => s.codeDispatches)
|
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(
|
const turns = useMemo(
|
||||||
() => deriveTrajectoryLayout({ nodes, partial, runningCalls, callSchemas, codeDispatches }),
|
() => deriveTrajectoryLayout({
|
||||||
[nodes, partial, runningCalls, callSchemas, codeDispatches],
|
nodes: selectedNodes,
|
||||||
|
partial: viewingCurrent ? partial : null,
|
||||||
|
runningCalls: viewingCurrent ? runningCalls : [],
|
||||||
|
callSchemas,
|
||||||
|
codeDispatches,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
selectedNodes, viewingCurrent, partial, runningCalls, callSchemas, codeDispatches,
|
||||||
|
],
|
||||||
)
|
)
|
||||||
const collapsibleTurnIds = useMemo(
|
const collapsibleTurnIds = useMemo(
|
||||||
() => turns
|
() => turns
|
||||||
@@ -44,50 +82,68 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
|
|||||||
&& collapsibleAssistantIds.every(index => collapsedAssistants.has(index))
|
&& collapsibleAssistantIds.every(index => collapsedAssistants.has(index))
|
||||||
|
|
||||||
const toggleTurn = (turn: number) => {
|
const toggleTurn = (turn: number) => {
|
||||||
setCollapsedTurns((current) => {
|
setCollapsedTurnsByContext((current) => {
|
||||||
const next = new Set(current)
|
const next = new Map(current)
|
||||||
if (next.has(turn)) next.delete(turn)
|
const collapsed = new Set(current.get(selectedContext.id) ?? EMPTY_IDS)
|
||||||
else next.add(turn)
|
if (collapsed.has(turn)) collapsed.delete(turn)
|
||||||
|
else collapsed.add(turn)
|
||||||
|
next.set(selectedContext.id, collapsed)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleAllTurns = () => {
|
const toggleAllTurns = () => {
|
||||||
setCollapsedTurns((current) => {
|
setCollapsedTurnsByContext((current) => {
|
||||||
const next = new Set(current)
|
const next = new Map(current)
|
||||||
|
const collapsed = new Set(current.get(selectedContext.id) ?? EMPTY_IDS)
|
||||||
if (allTurnsCollapsed) {
|
if (allTurnsCollapsed) {
|
||||||
for (const turn of collapsibleTurnIds) next.delete(turn)
|
for (const turn of collapsibleTurnIds) collapsed.delete(turn)
|
||||||
} else {
|
} else {
|
||||||
for (const turn of collapsibleTurnIds) next.add(turn)
|
for (const turn of collapsibleTurnIds) collapsed.add(turn)
|
||||||
}
|
}
|
||||||
|
next.set(selectedContext.id, collapsed)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleAssistant = (index: number) => {
|
const toggleAssistant = (index: number) => {
|
||||||
setCollapsedAssistants((current) => {
|
setCollapsedAssistantsByContext((current) => {
|
||||||
const next = new Set(current)
|
const next = new Map(current)
|
||||||
if (next.has(index)) next.delete(index)
|
const collapsed = new Set(current.get(selectedContext.id) ?? EMPTY_IDS)
|
||||||
else next.add(index)
|
if (collapsed.has(index)) collapsed.delete(index)
|
||||||
|
else collapsed.add(index)
|
||||||
|
next.set(selectedContext.id, collapsed)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleAllAssistants = () => {
|
const toggleAllAssistants = () => {
|
||||||
setCollapsedAssistants((current) => {
|
setCollapsedAssistantsByContext((current) => {
|
||||||
const next = new Set(current)
|
const next = new Map(current)
|
||||||
|
const collapsed = new Set(current.get(selectedContext.id) ?? EMPTY_IDS)
|
||||||
if (allAssistantsCollapsed) {
|
if (allAssistantsCollapsed) {
|
||||||
for (const index of collapsibleAssistantIds) next.delete(index)
|
for (const index of collapsibleAssistantIds) collapsed.delete(index)
|
||||||
} else {
|
} else {
|
||||||
for (const index of collapsibleAssistantIds) next.add(index)
|
for (const index of collapsibleAssistantIds) collapsed.add(index)
|
||||||
}
|
}
|
||||||
|
next.set(selectedContext.id, collapsed)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectContext = (id: number) => {
|
||||||
|
setSelectedContextId(id === currentContext.id ? null : id)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={css.root}>
|
<div className={css.root}>
|
||||||
<TrajectoryToolbar
|
<TrajectoryToolbar
|
||||||
|
{...contexts.length > 1
|
||||||
|
? {
|
||||||
|
contextLabel: contextLabel(selectedContext),
|
||||||
|
contextCurrent: viewingCurrent,
|
||||||
|
}
|
||||||
|
: {}}
|
||||||
collapsibleTurns={collapsibleTurnIds.length}
|
collapsibleTurns={collapsibleTurnIds.length}
|
||||||
allTurnsCollapsed={allTurnsCollapsed}
|
allTurnsCollapsed={allTurnsCollapsed}
|
||||||
onToggleAllTurns={toggleAllTurns}
|
onToggleAllTurns={toggleAllTurns}
|
||||||
@@ -95,16 +151,29 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
|
|||||||
allAssistantsCollapsed={allAssistantsCollapsed}
|
allAssistantsCollapsed={allAssistantsCollapsed}
|
||||||
onToggleAllAssistants={toggleAllAssistants}
|
onToggleAllAssistants={toggleAllAssistants}
|
||||||
/>
|
/>
|
||||||
{turns.length === 0 && <p className={css.empty}>No trajectory events</p>}
|
<div className={css.contextLayout}>
|
||||||
{turns.length > 0 && (
|
{contexts.length > 1 && (
|
||||||
<TrajectoryTable
|
<ContextsPanel
|
||||||
turns={turns}
|
contexts={contexts}
|
||||||
collapsedTurns={collapsedTurns}
|
selectedId={selectedContext.id}
|
||||||
onToggleTurn={toggleTurn}
|
currentId={currentContext.id}
|
||||||
collapsedAssistants={collapsedAssistants}
|
onSelect={selectContext}
|
||||||
onToggleAssistant={toggleAssistant}
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,7 +132,19 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (node.kind === 'context') {
|
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
|
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import type { HTMLAttributes } from 'react'
|
import type { HTMLAttributes } from 'react'
|
||||||
|
|
||||||
/** Closed set of trajectory record kinds. */
|
/** 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. */
|
/** Recorded inputs needed to derive assistant TTFT and decode throughput. */
|
||||||
export interface AssistantMetricDetail {
|
export interface AssistantMetricDetail {
|
||||||
|
|||||||
@@ -23,6 +23,22 @@
|
|||||||
font: var(--dsw-font-xs-13);
|
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). */
|
/* Waterfall placeholder rows (shared module). */
|
||||||
.row {
|
.row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -57,6 +57,16 @@ export interface SurfaceFoldResult {
|
|||||||
replacements: SurfaceFoldReplacement[]
|
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. */
|
/** Readonly live projection of the message-producing session events. */
|
||||||
export interface SessionSurface {
|
export interface SessionSurface {
|
||||||
/** Current surface event sequences in model-visible order. */
|
/** 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. */
|
/** Incremental ordered surface view and append-boundary validator. */
|
||||||
export class SurfaceManager implements SessionSurface {
|
export class SurfaceManager implements SessionSurface {
|
||||||
/** Shared transition state; replacement history is not retained. */
|
/** Shared transition state for the live surface. */
|
||||||
private _state = createFoldState()
|
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. */
|
/** Last processed seq; -1 folds a seeded log on first access. */
|
||||||
private _lastProcessedSeq = -1
|
private _lastProcessedSeq = -1
|
||||||
|
|
||||||
@@ -329,11 +343,35 @@ export class SurfaceManager implements SessionSurface {
|
|||||||
return this._state.nodes
|
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. */
|
/** Fold events appended since the previous access. */
|
||||||
private _processDelta(): void {
|
private _processDelta(): void {
|
||||||
for (let i = this._lastProcessedSeq + 1; i < this.log.length; i++) {
|
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
|
// 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
|
this._lastProcessedSeq = i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user