feat(ui): add trajectory context generations

This commit is contained in:
_Kerman
2026-07-27 15:58:06 +08:00
parent 628c1bffe0
commit 714090bb4d
17 changed files with 542 additions and 43 deletions
@@ -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;
}
}
@@ -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;