mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
feat(ui): add trajectory context generations
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
.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);
|
||||
|
||||
@@ -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!,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user