feat(ui): complete trajectory request inspection
This commit is contained in:
@@ -30,7 +30,8 @@ export type {
|
||||
export type {
|
||||
AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig,
|
||||
AssistantTiming, CodeSubCall, ComposerPhase, ContextMessageNode,
|
||||
ConversationContext, ConversationContextOriginKind, ConversationNode, ConversationPromptSnapshot,
|
||||
CompactionRequestView, ConversationContext, ConversationContextOriginKind,
|
||||
ConversationNode, ConversationPromptChange, ConversationPromptSnapshot, ModelRequestView,
|
||||
ConversationSnapshot, QueuedMessage, RunningToolCall,
|
||||
SteeringMessageNode, TodoItem, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
|
||||
} from './sessions/conversation.ts'
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface UserMessageNode {
|
||||
time: number
|
||||
content: readonly ContentBlock[]
|
||||
source: unknown
|
||||
meta?: unknown
|
||||
}
|
||||
|
||||
/** Recorded boundaries used to derive assistant latency and throughput. */
|
||||
@@ -67,6 +68,7 @@ export interface AssistantTiming {
|
||||
export interface AssistantRequestConfig {
|
||||
provider: string
|
||||
model: string
|
||||
purpose?: string
|
||||
thinking?: string
|
||||
reasoningEffort?: string
|
||||
temperature?: number
|
||||
@@ -108,6 +110,7 @@ export interface SteeringMessageNode {
|
||||
turn: number
|
||||
content: readonly ContentBlock[]
|
||||
source: unknown
|
||||
meta?: unknown
|
||||
}
|
||||
|
||||
/** A context/system injection surfaced in the flow. */
|
||||
@@ -241,6 +244,20 @@ export interface ConversationPromptSnapshot {
|
||||
tools: readonly ToolSchema[]
|
||||
}
|
||||
|
||||
/** One system-prompt/tool-catalog state that became effective in the request timeline. */
|
||||
export interface ConversationPromptChange {
|
||||
/** Sequence of the request/header event that introduced this state. */
|
||||
seq: number
|
||||
/** Unix epoch ms from the request/header event. */
|
||||
time: number
|
||||
/** How the model-visible system configuration differs from the prior recorded state. */
|
||||
kind: 'initial' | 'system' | 'tools' | 'system-and-tools'
|
||||
/** Complete state effective from this event onward. */
|
||||
prompt: ConversationPromptSnapshot
|
||||
/** State immediately before this change; absent for the initial header. */
|
||||
previous?: ConversationPromptSnapshot
|
||||
}
|
||||
|
||||
/** One immutable model-context generation reconstructed from surface replacements. */
|
||||
export interface ConversationContext {
|
||||
/** Zero-based generation within the session; stable across later appends. */
|
||||
@@ -259,6 +276,46 @@ export interface ConversationContext {
|
||||
nodes: readonly ConversationNode[]
|
||||
}
|
||||
|
||||
/** One auxiliary compaction model request reconstructed from its durable lifecycle events. */
|
||||
export interface CompactionRequestView {
|
||||
startSeq: number
|
||||
turn: number
|
||||
startedAt: number
|
||||
completedAt: number | null
|
||||
status: 'running' | 'complete' | 'error'
|
||||
error?: string
|
||||
summarySeq?: number
|
||||
replacementSeq?: number
|
||||
summary?: readonly ContentBlock[]
|
||||
rawOutput?: readonly ContentBlock[]
|
||||
provenance?: AssistantProvenanceView
|
||||
requestConfig?: AssistantRequestConfig
|
||||
usage?: unknown
|
||||
}
|
||||
|
||||
/** One ordinary provider-request attempt reconstructed from a durable step boundary. */
|
||||
export interface ModelRequestView {
|
||||
/** Sequence of the step/start event that opened this attempt. */
|
||||
startSeq: number
|
||||
turn: number
|
||||
step: number
|
||||
/** Unix epoch ms from step/start. */
|
||||
startedAt: number
|
||||
/** Assistant completion time, or the failed step/end time when no response completed. */
|
||||
completedAt: number | null
|
||||
status: 'running' | 'complete' | 'error'
|
||||
error?: string
|
||||
/** Assistant/message sequence when this attempt completed successfully. */
|
||||
resultSeq?: number
|
||||
provenance?: AssistantProvenanceView
|
||||
requestConfig?: AssistantRequestConfig
|
||||
usage?: unknown
|
||||
/** Retry ordinal scheduled after this failed attempt. */
|
||||
retry?: number
|
||||
maxRetries?: number
|
||||
retryDelayMs?: number
|
||||
}
|
||||
|
||||
/** Send/stop failure surfaced in the input error strip; op picks the user-facing copy (发送失败 vs 停止失败). */
|
||||
export interface PromptError {
|
||||
op: 'send' | 'stop'
|
||||
@@ -272,6 +329,12 @@ export interface ConversationSnapshot {
|
||||
nodes: readonly ConversationNode[]
|
||||
/** Append-only context generations split at every model-surface replacement. */
|
||||
contexts?: readonly ConversationContext[]
|
||||
/** Auxiliary compaction requests, including those without an assistant/message surface node. */
|
||||
compactionRequests?: readonly CompactionRequestView[]
|
||||
/** Ordinary provider requests, including failed attempts that produced no assistant message. */
|
||||
requestAttempts?: readonly ModelRequestView[]
|
||||
/** System-prompt/tool-catalog changes in request order. */
|
||||
promptChanges?: readonly ConversationPromptChange[]
|
||||
/** Fold degradation flag (cross-window replace defense): when true, nodes come from the lenient linear scan. */
|
||||
foldDegraded: boolean
|
||||
partial: PartialAssistant | null
|
||||
|
||||
@@ -59,6 +59,7 @@ function materializeNode(
|
||||
return {
|
||||
kind: 'user', seq: event.seq, time: event.time,
|
||||
content: event.data.content, source: event.data.source,
|
||||
meta: event.data.meta,
|
||||
}
|
||||
case 'assistant/message':
|
||||
return {
|
||||
@@ -76,6 +77,7 @@ function materializeNode(
|
||||
return {
|
||||
kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn,
|
||||
content: event.data.content, source: event.data.source,
|
||||
meta: event.data.meta,
|
||||
}
|
||||
case 'tool/result': {
|
||||
const call = callIndex.get(String(event.data.callId))
|
||||
|
||||
@@ -12,8 +12,9 @@ import type {
|
||||
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { ObservableSnapshot } from '../contract/store.ts'
|
||||
import type {
|
||||
CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, OpenState,
|
||||
PromptError, QueuedMessage, RunningToolCall,
|
||||
CodeSubCall, CompactionRequestView, ComposerPhase, ConversationNode,
|
||||
ConversationPromptChange, ConversationPromptSnapshot, ConversationSnapshot,
|
||||
ModelRequestView, OpenState, PromptError, QueuedMessage, RunningToolCall,
|
||||
} from './conversation.ts'
|
||||
import type { PendingInteraction } from './pending.ts'
|
||||
import { PendingWait } from './pending.ts'
|
||||
@@ -113,6 +114,11 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
private callSchemas = new Map<string, ToolSchema>()
|
||||
private callSchemasRev = 0
|
||||
private callSchemasCache: { rev: number; value: ReadonlyMap<string, ToolSchema> } | null = null
|
||||
private promptChangesRev = 0
|
||||
private promptChangesCache: {
|
||||
rev: number
|
||||
value: readonly ConversationPromptChange[]
|
||||
} | null = null
|
||||
private running = false
|
||||
/**
|
||||
* Sticky send marker, private input of the composerPhase derivation: set
|
||||
@@ -671,6 +677,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
}
|
||||
switch (event.type) {
|
||||
case 'request/header': {
|
||||
this.promptChangesRev++
|
||||
this.activeToolSchemas = new Map(
|
||||
(event.data.header.tools ?? []).map(schema => [schema.name, schema]),
|
||||
)
|
||||
@@ -776,6 +783,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
this.activeToolSchemas = new Map()
|
||||
this.callSchemas = new Map()
|
||||
this.callSchemasRev++
|
||||
this.promptChangesRev++
|
||||
for (let i = 0; i < this.events.length; i++) {
|
||||
const event = this.events[i]
|
||||
/* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */
|
||||
@@ -818,11 +826,23 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
if (this.queueCache === null || this.queueCache.rev !== this.queueRev) {
|
||||
this.queueCache = { rev: this.queueRev, value: this.queued.map(entry => entry.row) }
|
||||
}
|
||||
if (
|
||||
this.promptChangesCache === null
|
||||
|| this.promptChangesCache.rev !== this.promptChangesRev
|
||||
) {
|
||||
this.promptChangesCache = {
|
||||
rev: this.promptChangesRev,
|
||||
value: derivePromptChanges(this.events),
|
||||
}
|
||||
}
|
||||
const partial = this.partial?.toPartial() ?? null
|
||||
return {
|
||||
sessionId: this.sessionId,
|
||||
nodes,
|
||||
contexts,
|
||||
compactionRequests: deriveCompactionRequests(this.events),
|
||||
requestAttempts: deriveModelRequests(this.events),
|
||||
promptChanges: this.promptChangesCache.value,
|
||||
foldDegraded: degraded,
|
||||
partial,
|
||||
runningCalls: this.callsCache.value,
|
||||
@@ -862,3 +882,238 @@ function derivePhase(hasContent: boolean, promptAttempted: boolean): ComposerPha
|
||||
if (hasContent) return 'active'
|
||||
return promptAttempted ? 'engaging' : 'blank'
|
||||
}
|
||||
|
||||
interface RetryEvent {
|
||||
type: 'llm/retry'
|
||||
seq: number
|
||||
time: number
|
||||
data: {
|
||||
turn: number
|
||||
step: number
|
||||
retry: number
|
||||
maxRetries: number
|
||||
delayMs: number
|
||||
failure: { message: string }
|
||||
}
|
||||
}
|
||||
|
||||
function modelRequestKey(turn: number, step: number): string {
|
||||
return `${turn}\u0000${step}`
|
||||
}
|
||||
|
||||
/** Project every durable step into one provider request, retaining failed retry attempts. */
|
||||
function deriveModelRequests(events: readonly SessionEvent[]): readonly ModelRequestView[] {
|
||||
const requests: ModelRequestView[] = []
|
||||
const byStep = new Map<string, number>()
|
||||
let activeStep: string | undefined
|
||||
let activeConfig: ConversationPromptSnapshot['config']
|
||||
|
||||
const update = (key: string, change: Partial<ModelRequestView>): void => {
|
||||
const index = byStep.get(key)
|
||||
if (index === undefined) return
|
||||
const request = requests[index]
|
||||
if (request !== undefined) requests[index] = { ...request, ...change }
|
||||
}
|
||||
|
||||
for (const sourceEvent of events) {
|
||||
if (sourceEvent.type === 'request/header') {
|
||||
activeConfig = sourceEvent.data.header.config
|
||||
if (activeStep !== undefined) update(activeStep, { requestConfig: activeConfig })
|
||||
continue
|
||||
}
|
||||
if (sourceEvent.type === 'step/start') {
|
||||
const { turn, step } = sourceEvent.data
|
||||
const key = modelRequestKey(turn, step)
|
||||
byStep.set(key, requests.length)
|
||||
requests.push({
|
||||
startSeq: sourceEvent.seq,
|
||||
turn,
|
||||
step,
|
||||
startedAt: sourceEvent.time,
|
||||
completedAt: null,
|
||||
status: 'running',
|
||||
...(activeConfig === undefined ? {} : { requestConfig: activeConfig }),
|
||||
})
|
||||
activeStep = key
|
||||
continue
|
||||
}
|
||||
if (sourceEvent.type === 'assistant/message') {
|
||||
const key = modelRequestKey(sourceEvent.data.turn, sourceEvent.data.step)
|
||||
update(key, {
|
||||
completedAt: sourceEvent.time,
|
||||
status: 'complete',
|
||||
resultSeq: sourceEvent.seq,
|
||||
provenance: {
|
||||
provider: sourceEvent.data.provenance.provider,
|
||||
model: sourceEvent.data.provenance.model,
|
||||
},
|
||||
...(sourceEvent.data.usage === undefined ? {} : { usage: sourceEvent.data.usage }),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (sourceEvent.type === 'step/end') {
|
||||
const key = modelRequestKey(sourceEvent.data.turn, sourceEvent.data.step)
|
||||
const index = byStep.get(key)
|
||||
const request = index === undefined ? undefined : requests[index]
|
||||
if (request !== undefined && request.status === 'running') {
|
||||
requests[index] = {
|
||||
...request,
|
||||
completedAt: sourceEvent.time,
|
||||
status: 'error',
|
||||
}
|
||||
}
|
||||
if (activeStep === key) activeStep = undefined
|
||||
continue
|
||||
}
|
||||
if ((sourceEvent.type as string) === 'llm/retry') {
|
||||
const event = sourceEvent as unknown as RetryEvent
|
||||
update(modelRequestKey(event.data.turn, event.data.step), {
|
||||
status: 'error',
|
||||
error: event.data.failure.message,
|
||||
retry: event.data.retry,
|
||||
maxRetries: event.data.maxRetries,
|
||||
retryDelayMs: event.data.delayMs,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (sourceEvent.type !== 'turn/end' || sourceEvent.data.reason.kind !== 'error') continue
|
||||
const reason = sourceEvent.data.reason
|
||||
update(modelRequestKey(sourceEvent.data.turn, reason.step), {
|
||||
status: 'error',
|
||||
error: 'failure' in reason ? reason.failure.message : reason.message,
|
||||
})
|
||||
}
|
||||
return requests
|
||||
}
|
||||
|
||||
interface CompactionStartEvent {
|
||||
type: 'compact/start'
|
||||
seq: number
|
||||
time: number
|
||||
data: { turn: number }
|
||||
}
|
||||
|
||||
interface CompactionSummaryEvent {
|
||||
type: 'compact/summary'
|
||||
seq: number
|
||||
time: number
|
||||
data: {
|
||||
summary: readonly ContentBlock[]
|
||||
rawOutput?: readonly ContentBlock[]
|
||||
provider: string
|
||||
model: string
|
||||
maxTokens?: number
|
||||
usage?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
interface CompactionEndEvent {
|
||||
type: 'compact/end'
|
||||
seq: number
|
||||
time: number
|
||||
data: { turn: number; error?: string }
|
||||
}
|
||||
|
||||
/** Project log-only compaction request brackets without coupling the client runtime to one backend package. */
|
||||
function deriveCompactionRequests(events: readonly SessionEvent[]): readonly CompactionRequestView[] {
|
||||
const requests: CompactionRequestView[] = []
|
||||
let active: CompactionRequestView | undefined
|
||||
for (const sourceEvent of events) {
|
||||
const type = sourceEvent.type as string
|
||||
if (type === 'compact/start') {
|
||||
const event = sourceEvent as unknown as CompactionStartEvent
|
||||
active = {
|
||||
startSeq: event.seq,
|
||||
turn: event.data.turn,
|
||||
startedAt: event.time,
|
||||
completedAt: null,
|
||||
status: 'running',
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (type === 'compact/summary' && active !== undefined) {
|
||||
const event = sourceEvent as unknown as CompactionSummaryEvent
|
||||
active = {
|
||||
...active,
|
||||
summarySeq: event.seq,
|
||||
summary: event.data.summary,
|
||||
...(event.data.rawOutput === undefined ? {} : { rawOutput: event.data.rawOutput }),
|
||||
provenance: {
|
||||
provider: event.data.provider,
|
||||
model: event.data.model,
|
||||
},
|
||||
requestConfig: {
|
||||
provider: event.data.provider,
|
||||
model: event.data.model,
|
||||
purpose: 'compaction',
|
||||
...(event.data.maxTokens === undefined ? {} : { maxTokens: event.data.maxTokens }),
|
||||
},
|
||||
...(event.data.usage === undefined ? {} : { usage: event.data.usage }),
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (
|
||||
sourceEvent.type === 'user/message'
|
||||
&& active?.summarySeq !== undefined
|
||||
&& isCompactionSource(sourceEvent.data.source)
|
||||
) {
|
||||
active = { ...active, replacementSeq: sourceEvent.seq }
|
||||
continue
|
||||
}
|
||||
if (type !== 'compact/end' || active === undefined) continue
|
||||
const event = sourceEvent as unknown as CompactionEndEvent
|
||||
active = {
|
||||
...active,
|
||||
completedAt: event.time,
|
||||
status: event.data.error === undefined ? 'complete' : 'error',
|
||||
...(event.data.error === undefined ? {} : { error: event.data.error }),
|
||||
}
|
||||
requests.push(active)
|
||||
active = undefined
|
||||
}
|
||||
if (active !== undefined) requests.push(active)
|
||||
return requests
|
||||
}
|
||||
|
||||
/** Project request headers into model-visible system/tool changes only. */
|
||||
function derivePromptChanges(events: readonly SessionEvent[]): readonly ConversationPromptChange[] {
|
||||
const changes: ConversationPromptChange[] = []
|
||||
let previous: ConversationPromptSnapshot | undefined
|
||||
for (const event of events) {
|
||||
if (event.type !== 'request/header') continue
|
||||
const prompt: ConversationPromptSnapshot = {
|
||||
config: event.data.header.config,
|
||||
system: event.data.header.system ?? '',
|
||||
tools: event.data.header.tools ?? [],
|
||||
}
|
||||
const systemChanged = previous !== undefined && previous.system !== prompt.system
|
||||
const toolsChanged = previous !== undefined
|
||||
&& JSON.stringify(previous.tools) !== JSON.stringify(prompt.tools)
|
||||
if (previous === undefined || systemChanged || toolsChanged) {
|
||||
changes.push({
|
||||
seq: event.seq,
|
||||
time: event.time,
|
||||
kind: previous === undefined
|
||||
? 'initial'
|
||||
: systemChanged && toolsChanged
|
||||
? 'system-and-tools'
|
||||
: systemChanged
|
||||
? 'system'
|
||||
: 'tools',
|
||||
prompt,
|
||||
...(previous === undefined ? {} : { previous }),
|
||||
})
|
||||
}
|
||||
previous = prompt
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
function isCompactionSource(source: unknown): boolean {
|
||||
return typeof source === 'object'
|
||||
&& source !== null
|
||||
&& 'kind' in source
|
||||
&& source.kind === 'plugin'
|
||||
&& 'plugin' in source
|
||||
&& source.plugin === 'compact'
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* lightningcss inside the bundle: importing `x.module.css` yields the
|
||||
* hashed class map, and the css text auto-injects a <style data-plugin="<id>">
|
||||
* tag at factory execution (the loader removes plugin-owned tags on unload).
|
||||
* The virtual loader registers each real stylesheet as a watch dependency.
|
||||
*/
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { basename, dirname, resolve as resolvePath } from 'node:path'
|
||||
@@ -127,6 +128,7 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi
|
||||
async load(virtualId: string) {
|
||||
if (!virtualId.startsWith(CSS_VIRTUAL_PREFIX)) return null
|
||||
const fileId = virtualId.slice(CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
|
||||
this.addWatchFile(fileId)
|
||||
const source = await readFile(fileId)
|
||||
const { code, exports: cssExports } = transform({
|
||||
filename: fileId,
|
||||
|
||||
@@ -49,9 +49,7 @@
|
||||
}
|
||||
|
||||
/* Drag handles are frame children (columns clip overflow): an 8px hit strip
|
||||
centered on the column border via inline left, above column content. The
|
||||
visible pill (12x32 r10, riding the border at vertical center) is the figma
|
||||
Handle component; the hit strip stays wider than the pill. */
|
||||
centered on the column border via inline left, above column content. */
|
||||
.handle {
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
@@ -75,36 +73,3 @@
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.handle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 12px;
|
||||
height: 32px;
|
||||
border-radius: 10px;
|
||||
box-sizing: border-box;
|
||||
background: var(--dsw-alias-button-floating-fill);
|
||||
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
||||
/* Hover affordance: the pill hides until the pointer is over the owning
|
||||
column (data-side pairs handle and column), the strip itself, or a drag. */
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
background var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.sidebarCol:hover ~ .handle[data-side='sidebar']::after,
|
||||
.detailsCol:hover ~ .handle[data-side='details']::after,
|
||||
.handle:hover::after,
|
||||
.handle[data-dragging='true']::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.handle:hover::after,
|
||||
.handle[data-dragging='true']::after {
|
||||
background: var(--dsw-alias-button-floating-hover);
|
||||
border-color: var(--dsw-alias-border-l3);
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
}
|
||||
|
||||
.expandedTopLevelContainer {
|
||||
padding: 0 0 0 calc(2ch - 12px);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.row.topLevelBracket {
|
||||
@@ -59,7 +59,7 @@
|
||||
|
||||
.children {
|
||||
margin: 0;
|
||||
padding: 0 0 0 4px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
min-width: 100%;
|
||||
min-height: 16px;
|
||||
margin: 0;
|
||||
padding: 0 0 0 12px;
|
||||
padding: 0 0 0 10px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@
|
||||
left: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
box-sizing: border-box;
|
||||
width: 8px;
|
||||
height: 16px;
|
||||
@@ -196,7 +196,7 @@
|
||||
border-left: 6px solid currentColor;
|
||||
content: '';
|
||||
transform: scale(0.75);
|
||||
transform-origin: center;
|
||||
transform-origin: 33.333% center;
|
||||
}
|
||||
|
||||
.collapseIcon::before {
|
||||
|
||||
@@ -66,6 +66,17 @@
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.noLanguage .bannerWrap {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.noLanguage .banner {
|
||||
padding-left: 8px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.block :where(pre) {
|
||||
font: var(--dsl-code-block-content-font);
|
||||
padding: 16px;
|
||||
@@ -76,6 +87,10 @@
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
}
|
||||
|
||||
.noLanguage :where(pre) {
|
||||
padding-right: 72px;
|
||||
}
|
||||
|
||||
/* Shiki inlines its theme background var; route it to the repo token. */
|
||||
.block :where(pre.shiki) {
|
||||
background: var(--dsw-alias-markdown-code-block) !important;
|
||||
|
||||
@@ -52,6 +52,7 @@ async function writeClipboard(text: string): Promise<boolean> {
|
||||
|
||||
export function CodeBlock({ code, lang, className }: CodeBlockProps) {
|
||||
const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
|
||||
const hasLanguage = lang !== undefined && lang !== ''
|
||||
const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang])
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
@@ -80,7 +81,10 @@ export function CodeBlock({ code, lang, className }: CodeBlockProps) {
|
||||
)
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={clsx(css.block, 'md-code-block', className)}>
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={clsx(css.block, !hasLanguage && css.noLanguage, 'md-code-block', className)}
|
||||
>
|
||||
<div className={css.bannerWrap}>
|
||||
<div className={css.banner}>
|
||||
<div className={css.infostring}>{lang ?? ''}</div>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/**
|
||||
* Markdown-to-plain-text projection for compact summaries and labels.
|
||||
* Parsing shares the renderer's GFM grammar; raw HTML is intentionally
|
||||
* omitted, links keep their labels, images keep alt text, and code keeps its
|
||||
* source text.
|
||||
* Parsing shares the renderer's GFM grammar; raw HTML remains literal, links
|
||||
* keep their labels, images keep alt text, and code keeps its source text.
|
||||
*/
|
||||
|
||||
import { fromMarkdown } from 'mdast-util-from-markdown'
|
||||
@@ -37,6 +36,7 @@ function inlineText(node: MarkdownNode): string {
|
||||
case 'break':
|
||||
return '\n'
|
||||
case 'html':
|
||||
return node.value ?? ''
|
||||
case 'thematicBreak':
|
||||
case 'definition':
|
||||
return ''
|
||||
@@ -70,6 +70,7 @@ function blockText(node: MarkdownNode): string {
|
||||
case 'tableCell':
|
||||
return compactInline(inlineText(node))
|
||||
case 'html':
|
||||
return node.value?.trim() ?? ''
|
||||
case 'thematicBreak':
|
||||
case 'definition':
|
||||
return ''
|
||||
|
||||
@@ -33,6 +33,9 @@
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"diff": "^9.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
/** 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>
|
||||
)
|
||||
}
|
||||
@@ -46,6 +46,11 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tagSystem {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
}
|
||||
|
||||
.tagUser {
|
||||
color: var(--dsw-alias-state-success-primary);
|
||||
background: var(--dsw-alias-state-success-tertiary);
|
||||
|
||||
@@ -16,6 +16,7 @@ export type {
|
||||
|
||||
/** Display label per kind (matches the design tags). */
|
||||
const KIND_LABEL: Record<TrajectoryCellKind, string> = {
|
||||
system: 'System',
|
||||
user: 'User',
|
||||
context: 'Context',
|
||||
message: 'Message',
|
||||
@@ -24,6 +25,7 @@ const KIND_LABEL: Record<TrajectoryCellKind, string> = {
|
||||
}
|
||||
|
||||
const TAG_CLASS: Record<TrajectoryCellKind, string> = {
|
||||
system: css.tagSystem!,
|
||||
user: css.tagUser!,
|
||||
context: css.tagContext!,
|
||||
message: css.tagMessage!,
|
||||
@@ -41,6 +43,8 @@ export function TrajectoryCell({
|
||||
kind,
|
||||
text,
|
||||
inputDetail: _inputDetail,
|
||||
promptDetail: _promptDetail,
|
||||
previousPromptDetail: _previousPromptDetail,
|
||||
outputDetail: _outputDetail,
|
||||
thinkingDetail: _thinkingDetail,
|
||||
sourceBlocks: _sourceBlocks,
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
.table {
|
||||
--trajectory-turn-accent: color-mix(
|
||||
in srgb,
|
||||
var(--dsw-alias-brand-primary-new-colorprimary-new-color) 22%,
|
||||
var(--dsw-static-blue-500) 22%,
|
||||
var(--dsw-alias-bg-layer-1)
|
||||
);
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
table-layout: fixed;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
font: var(--dsw-font-xs-13);
|
||||
font: var(--dsw-font-xxs-12);
|
||||
}
|
||||
|
||||
.eventColumn {
|
||||
@@ -50,7 +50,7 @@
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
background: var(--dsw-specific-sidebar-fill);
|
||||
font: var(--dsw-font-xs-13);
|
||||
font: var(--dsw-font-xxs-12);
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
@@ -83,6 +83,22 @@
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.table tbody tr[data-request-only='true']:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.table tbody tr[data-request-only='true'] td {
|
||||
height: 1px;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.table tbody tr[data-request-only='true'] .turnRail {
|
||||
top: -15px;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.table tbody tr:not([data-collapsed-summary]):focus-visible {
|
||||
box-shadow: inset 0 0 0 1px var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
@@ -95,7 +111,7 @@
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
top: -8px;
|
||||
left: 6px;
|
||||
left: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
@@ -106,10 +122,10 @@
|
||||
|
||||
.requestBoundaryControl::before {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
top: 5.5px;
|
||||
left: 5.5px;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--dsw-alias-label-caption);
|
||||
box-shadow:
|
||||
@@ -118,7 +134,7 @@
|
||||
content: '';
|
||||
transition:
|
||||
background 120ms var(--ds-ease-in-out),
|
||||
transform 120ms var(--ds-ease-in-out);
|
||||
box-shadow 120ms var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.requestBoundaryControl::after {
|
||||
@@ -127,9 +143,11 @@
|
||||
left: 17px;
|
||||
width: max-content;
|
||||
padding: 0 4px;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
border-radius: 2px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
|
||||
content: attr(data-label);
|
||||
font: 9px/12px var(--ds-font-family-code);
|
||||
opacity: 0;
|
||||
@@ -143,10 +161,19 @@
|
||||
}
|
||||
|
||||
.requestBoundaryControl:hover::before,
|
||||
.requestBoundaryControl:focus-visible::before,
|
||||
.requestBoundaryControl[aria-pressed='true']::before {
|
||||
background: var(--dsw-alias-label-primary);
|
||||
transform: scale(1.25);
|
||||
.requestBoundaryControl:focus-visible::before {
|
||||
background: var(--dsw-alias-brand-primary-new-colorprimary-new-color);
|
||||
}
|
||||
|
||||
.requestBoundaryControlActive::before,
|
||||
.requestBoundaryControlActive:hover::before,
|
||||
.requestBoundaryControlActive:focus-visible::before {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--dsw-alias-brand-primary-new-colorprimary-new-color) 18%,
|
||||
var(--dsw-alias-bg-layer-1)
|
||||
);
|
||||
box-shadow: 0 0 0 1.5px var(--dsw-alias-brand-primary-new-colorprimary-new-color);
|
||||
}
|
||||
|
||||
.requestBoundaryControl:hover::after,
|
||||
@@ -219,14 +246,17 @@
|
||||
}
|
||||
|
||||
.turnLabel {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
width: max-content;
|
||||
margin-left: 4px;
|
||||
padding: 1px 2px;
|
||||
border-radius: 2px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 0 0 2px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
font: 8px/10px var(--ds-font-family-code);
|
||||
@@ -241,11 +271,7 @@
|
||||
var(--dsw-static-blue-500) 55%,
|
||||
var(--dsw-alias-label-tertiary)
|
||||
);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--dsw-static-blue-500) 10%,
|
||||
var(--dsw-alias-bg-layer-1)
|
||||
);
|
||||
background: var(--trajectory-turn-accent);
|
||||
}
|
||||
|
||||
.eventInner {
|
||||
@@ -260,15 +286,8 @@
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: flex-end;
|
||||
width: 76px;
|
||||
}
|
||||
|
||||
.kindSlotLeft {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.kindSlotRight {
|
||||
justify-content: flex-end;
|
||||
width: 76px;
|
||||
}
|
||||
|
||||
.content {
|
||||
@@ -316,6 +335,80 @@
|
||||
background: var(--dsw-alias-state-success-tertiary);
|
||||
}
|
||||
|
||||
.compacted {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
}
|
||||
|
||||
.compactedSummary {
|
||||
margin-top: 12px;
|
||||
padding: 0 0 14px;
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
.promptDiffSections {
|
||||
display: flex;
|
||||
max-height: 100%;
|
||||
flex-direction: column;
|
||||
padding: 10px 14px 14px;
|
||||
gap: 14px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.promptDiffSection {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.promptDiffTitle {
|
||||
margin: 0 0 6px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: var(--dsw-font-xs-strong-13);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.promptDiff {
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
font: 11px/17px var(--ds-font-family-code);
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.promptDiff span {
|
||||
display: block;
|
||||
min-width: max-content;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.promptDiffLinemeta {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.promptDiffLinecontext {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.promptDiffLineadded {
|
||||
color: color-mix(
|
||||
in srgb,
|
||||
var(--dsw-alias-state-success-primary) 72%,
|
||||
var(--dsw-alias-label-primary)
|
||||
);
|
||||
background: var(--dsw-alias-state-success-tertiary);
|
||||
}
|
||||
|
||||
.promptDiffLineremoved {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--dsw-alias-state-error-primary) 12%,
|
||||
var(--dsw-alias-bg-layer-1)
|
||||
);
|
||||
}
|
||||
|
||||
.assistantVioletBright {
|
||||
color: color-mix(
|
||||
in srgb,
|
||||
@@ -359,8 +452,9 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table tbody tr[data-collapsed-summary] td {
|
||||
height: 24px;
|
||||
.table tbody tr[data-collapsed-summary='turn'] td,
|
||||
.table tbody tr[data-collapsed-summary='assistant'] td {
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.table tbody tr[data-collapsed-summary] {
|
||||
@@ -383,7 +477,7 @@
|
||||
min-width: 0;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.collapsedTurnEllipsis {
|
||||
@@ -425,7 +519,7 @@
|
||||
|
||||
.toolCallNameTypeface {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font: 400 13px/18px Menlo, Consolas, 'Liberation Mono', 'PingFang SC',
|
||||
font: 400 12px/18px Menlo, Consolas, 'Liberation Mono', 'PingFang SC',
|
||||
'Microsoft YaHei';
|
||||
}
|
||||
|
||||
@@ -454,6 +548,10 @@
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.noOutputText {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.arrow {
|
||||
flex: none;
|
||||
margin-right: 8px;
|
||||
@@ -492,38 +590,6 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.detailsResizeHandle::after {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
box-sizing: border-box;
|
||||
width: 12px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 10px;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
content: '';
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%);
|
||||
transition:
|
||||
opacity var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
background var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.details:hover .detailsResizeHandle::after,
|
||||
.detailsResizeHandle:hover::after,
|
||||
.detailsResizeHandle:active::after,
|
||||
.detailsResizeHandle:focus-visible::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.detailsResizeHandle:hover::after,
|
||||
.detailsResizeHandle:active::after,
|
||||
.detailsResizeHandle:focus-visible::after {
|
||||
border-color: var(--dsw-alias-border-l3);
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
}
|
||||
|
||||
.detailsResizeHandle:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
@@ -645,6 +711,28 @@
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.detailBodySummary {
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
flex-direction: column;
|
||||
padding-bottom: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.detailBodySummary > .overview {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.detailBodySummary > .compactedSummary {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.compactedSummary .markdownPayload {
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
.overview {
|
||||
margin: 0;
|
||||
padding: 8px 0;
|
||||
@@ -733,6 +821,20 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.timestampToggle {
|
||||
all: unset;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.timestampToggle:focus-visible {
|
||||
outline: 1px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.tokenEquation {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
@@ -744,15 +846,34 @@
|
||||
}
|
||||
|
||||
.overviewSections {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.overviewSection {
|
||||
display: flex;
|
||||
flex: 0 1 auto;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.overviewSection:has(> .overviewPreview > .overview),
|
||||
.overviewSection:has(> .overviewPreview > .noPayload) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.overviewSection + .overviewSection {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.overviewHeading {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: flex-end;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
@@ -801,7 +922,8 @@
|
||||
}
|
||||
|
||||
.overviewPreview {
|
||||
max-height: 180px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
@@ -865,6 +987,42 @@
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
.markdownPreview > div > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.markdownPreview > div > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.markdownPreview > div :where(h1, h2, h3) {
|
||||
margin: 12px 0 6px;
|
||||
}
|
||||
|
||||
.markdownPreview > div :where(h4, h5, h6) {
|
||||
margin: 10px 0 5px;
|
||||
}
|
||||
|
||||
.markdownPreview > div :where(p, ul, ol) {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.markdownPreview > div li:not(:first-child) {
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.markdownPreview > div blockquote {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.markdownPreview > div hr {
|
||||
margin: 14px 0;
|
||||
}
|
||||
|
||||
.markdownPreview > div > :global(.md-code-block) {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.assistantContent .markdownPayload,
|
||||
.assistantContent .payload {
|
||||
min-height: 0;
|
||||
@@ -1130,11 +1288,12 @@
|
||||
all: unset;
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
width: calc(100% + 4px);
|
||||
min-width: 0;
|
||||
height: 19px;
|
||||
align-items: center;
|
||||
padding: 0 4px 0 0;
|
||||
margin-left: -4px;
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { CSSProperties, ReactNode } from 'react'
|
||||
import {
|
||||
extractMarkdownPlainText, IconChevronRightOutline14, JsonTree, MarkdownText,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { structuredPatch } from 'diff'
|
||||
import type {
|
||||
AssistantRequestConfig, ConversationPromptSnapshot,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -16,8 +17,10 @@ import type { TrajectoryTurnModel } from './layout.ts'
|
||||
import css from './TrajectoryTable.module.css'
|
||||
|
||||
const KIND_LABEL: Record<TrajectoryCellKind, string> = {
|
||||
system: 'SYSTEM',
|
||||
user: 'USER',
|
||||
context: 'CONTEXT',
|
||||
compacted: 'COMPACTED',
|
||||
message: 'ASSISTANT',
|
||||
tool: 'TOOL',
|
||||
subtool: 'SUBTOOL',
|
||||
@@ -40,12 +43,14 @@ type DetailTab =
|
||||
| 'overview'
|
||||
| 'rendered'
|
||||
| 'source'
|
||||
| 'origin'
|
||||
| 'input'
|
||||
| 'output'
|
||||
| 'schema'
|
||||
| 'options'
|
||||
| 'usage'
|
||||
| 'timing'
|
||||
| 'diff'
|
||||
type RecordState = 'complete' | 'running' | 'error'
|
||||
|
||||
interface DetailTabItem {
|
||||
@@ -86,11 +91,14 @@ const TOOL_REQUEST_MIN_WIDTH = 180
|
||||
const TOOL_REQUEST_MAX_WIDTH = 480
|
||||
const DEFAULT_TOOL_REQUEST_SHARE = 0.36
|
||||
const DEFAULT_TOOL_REQUEST_OFFSET = 56
|
||||
const SYSTEM_PROMPT_INDEX = 0
|
||||
const SYSTEM_PROMPT_TABS: readonly DetailTabItem[] = [
|
||||
{ id: 'system-prompt', label: 'System Prompt' },
|
||||
{ id: 'tools', label: 'Tools' },
|
||||
]
|
||||
const SYSTEM_UPDATE_TABS: readonly DetailTabItem[] = [
|
||||
{ id: 'diff', label: 'Diff' },
|
||||
...SYSTEM_PROMPT_TABS,
|
||||
]
|
||||
const REQUEST_TABS: readonly DetailTabItem[] = [
|
||||
{ id: 'overview', label: 'Summary' },
|
||||
{ id: 'options', label: 'Options' },
|
||||
@@ -125,19 +133,40 @@ function formatDurationMs(milliseconds: number): string {
|
||||
return `${(milliseconds / 1_000).toFixed(milliseconds < 10_000 ? 2 : 1)} s`
|
||||
}
|
||||
|
||||
function formatStartedAt(timestamp: number | null): { label: string; title?: string } {
|
||||
if (timestamp === null || !Number.isFinite(timestamp)) return { label: 'Not available' }
|
||||
function formatStartedAt(timestamp: number | null): string {
|
||||
if (timestamp === null || !Number.isFinite(timestamp)) return 'Not available'
|
||||
const date = new Date(timestamp)
|
||||
const two = (value: number) => String(value).padStart(2, '0')
|
||||
const three = (value: number) => String(value).padStart(3, '0')
|
||||
const time = `${two(date.getHours())}:${two(date.getMinutes())}:${two(date.getSeconds())}.${three(date.getMilliseconds())}`
|
||||
const day = `${date.getFullYear()}-${two(date.getMonth() + 1)}-${two(date.getDate())}`
|
||||
return { label: time, title: `${day} ${time}` }
|
||||
return `${day} ${time}`
|
||||
}
|
||||
|
||||
function StartedAtValue({ timestamp }: { timestamp: number | null }) {
|
||||
const formatted = formatStartedAt(timestamp)
|
||||
return <dd title={formatted.title}>{formatted.label}</dd>
|
||||
const [showUnix, setShowUnix] = useState(false)
|
||||
if (timestamp === null || !Number.isFinite(timestamp)) return <dd>Not available</dd>
|
||||
return (
|
||||
<dd>
|
||||
<button
|
||||
type="button"
|
||||
className={css.timestampToggle}
|
||||
title={showUnix ? 'Show local time' : 'Show Unix timestamp'}
|
||||
onClick={(event) => {
|
||||
const selection = window.getSelection()
|
||||
if (
|
||||
selection !== null
|
||||
&& !selection.isCollapsed
|
||||
&& selection.rangeCount > 0
|
||||
&& selection.getRangeAt(0).intersectsNode(event.currentTarget)
|
||||
) return
|
||||
setShowUnix(current => !current)
|
||||
}}
|
||||
>
|
||||
{showUnix ? (timestamp / 1_000).toFixed(3) : formatStartedAt(timestamp)}
|
||||
</button>
|
||||
</dd>
|
||||
)
|
||||
}
|
||||
|
||||
function totalTime(metrics: AssistantMetricDetail): string {
|
||||
@@ -184,8 +213,6 @@ function AssistantTimingPanel({ metrics }: { metrics: AssistantMetricDetail }) {
|
||||
|
||||
/** Props for the trajectory ledger. */
|
||||
export interface TrajectoryTableProps {
|
||||
/** Latest model request header in force for the selected context. */
|
||||
prompt?: ConversationPromptSnapshot
|
||||
/** Session-global request numbers for the request groups visible in this context. */
|
||||
requestNumbers?: readonly TrajectoryRequestNumber[]
|
||||
/** Grouped records in display order. */
|
||||
@@ -200,11 +227,23 @@ export interface TrajectoryTableProps {
|
||||
onToggleAssistant(index: number): void
|
||||
}
|
||||
|
||||
/** One context-local request identity paired with its session-global number. */
|
||||
/** One request identity paired with its session-global number. */
|
||||
export interface TrajectoryRequestNumber {
|
||||
/** Request anchor event sequence; absent for the currently streaming ordinary request. */
|
||||
seq?: number
|
||||
turn: number
|
||||
step: number
|
||||
group: string
|
||||
number: number
|
||||
purpose?: 'compaction'
|
||||
status?: 'complete' | 'running' | 'error'
|
||||
startedAt?: number
|
||||
completedAt?: number | null
|
||||
error?: string
|
||||
retry?: number
|
||||
maxRetries?: number
|
||||
retryDelayMs?: number
|
||||
resultSeq?: number
|
||||
provider?: string
|
||||
model?: string
|
||||
requestConfig?: AssistantRequestConfig
|
||||
@@ -226,7 +265,10 @@ function flattenRecords(turns: readonly TrajectoryTurnModel[]): TableRecord[] {
|
||||
let firstInTurn = true
|
||||
const records = turn.groups.flatMap((group) => {
|
||||
return group.cells.map((cell, index) => {
|
||||
const turnStart = firstInTurn && index === 0
|
||||
const turnStart = firstInTurn
|
||||
&& cell.requestOnly !== true
|
||||
&& cell.kind !== 'system'
|
||||
&& cell.kind !== 'compacted'
|
||||
if (turnStart) firstInTurn = false
|
||||
return {
|
||||
turn: turn.turn,
|
||||
@@ -260,7 +302,7 @@ function indexRequestNumbers(
|
||||
): ReadonlyMap<string, number> {
|
||||
const numbers = new Map<string, number>()
|
||||
for (const request of sessionNumbers ?? []) {
|
||||
numbers.set(requestKey(request.turn, `Step ${request.step}`), request.number)
|
||||
numbers.set(requestKey(request.turn, request.group), request.number)
|
||||
}
|
||||
let next = Math.max(0, ...numbers.values()) + 1
|
||||
const boundaries = records
|
||||
@@ -274,14 +316,6 @@ function indexRequestNumbers(
|
||||
}
|
||||
|
||||
function summarizeTurn(records: readonly TableRecord[]): string {
|
||||
const userText = records
|
||||
.filter(record => record.cell.kind === 'user')
|
||||
.map(record => recordDisplayText(record.cell))
|
||||
.find(text => text !== '')
|
||||
const assistantText = records
|
||||
.filter(record => record.cell.kind === 'message')
|
||||
.map(record => recordDisplayText(record.cell))
|
||||
.find(text => text !== '')
|
||||
const steps = new Set(
|
||||
records
|
||||
.map(record => record.group)
|
||||
@@ -290,14 +324,10 @@ function summarizeTurn(records: readonly TableRecord[]): string {
|
||||
const toolCalls = records.filter(record =>
|
||||
record.cell.kind === 'tool' || record.cell.kind === 'subtool',
|
||||
).length
|
||||
const parts: string[] = []
|
||||
const message = userText ?? assistantText
|
||||
if (message !== undefined) parts.push(message)
|
||||
parts.push(
|
||||
return [
|
||||
`${steps} ${steps === 1 ? 'step' : 'steps'}`,
|
||||
`${toolCalls} tool ${toolCalls === 1 ? 'call' : 'calls'}`,
|
||||
)
|
||||
return parts.join(' · ')
|
||||
].join(' · ')
|
||||
}
|
||||
|
||||
function collapseTurnRecords(
|
||||
@@ -314,8 +344,11 @@ function collapseTurnRecords(
|
||||
return records.flatMap((record) => {
|
||||
if (!collapsedTurns.has(record.turn)) return [record]
|
||||
const turnRecords = recordsByTurn.get(record.turn) ?? [record]
|
||||
if (turnRecords.length <= 1) return [record]
|
||||
if (!record.turnStart) return []
|
||||
if (record.cell.requestOnly === true || record.cell.kind === 'system') return [record]
|
||||
const contentRecords = turnRecords.filter(candidate =>
|
||||
candidate.cell.requestOnly !== true && candidate.cell.kind !== 'system')
|
||||
if (contentRecords.length <= 1) return [record]
|
||||
if (record.cell.index !== contentRecords[0]?.cell.index) return []
|
||||
return [
|
||||
{ ...record, turnEnd: false },
|
||||
{
|
||||
@@ -323,7 +356,7 @@ function collapseTurnRecords(
|
||||
groupStart: false,
|
||||
turnStart: false,
|
||||
turnEnd: true,
|
||||
collapsedSummary: summarizeTurn(turnRecords.slice(1)),
|
||||
collapsedSummary: summarizeTurn(contentRecords.slice(1)),
|
||||
collapsedSummaryKind: 'turn',
|
||||
},
|
||||
]
|
||||
@@ -395,6 +428,7 @@ function collapseAssistantRecords(
|
||||
|
||||
function stateOf(record: TableRecord): RecordState {
|
||||
if (record.cell.isError) return 'error'
|
||||
if (record.cell.kind === 'compacted' && record.cell.timeSeconds === null) return 'running'
|
||||
if (
|
||||
(record.cell.kind === 'tool' || record.cell.kind === 'subtool')
|
||||
&& record.cell.outputDetail === undefined
|
||||
@@ -437,6 +471,9 @@ function inputTotal(usage: TrajectoryUsage): number | undefined {
|
||||
function UsageRows({ usage }: { usage: TrajectoryUsage | undefined }) {
|
||||
if (usage === undefined) return <p className={css.noPayload}>Usage not reported</p>
|
||||
const totalInput = inputTotal(usage)
|
||||
const otherOutput = usage.output !== undefined && usage.reasoning !== undefined
|
||||
? usage.output - usage.reasoning
|
||||
: undefined
|
||||
return (
|
||||
<dl className={css.overview}>
|
||||
{totalInput !== undefined && (
|
||||
@@ -469,6 +506,12 @@ function UsageRows({ usage }: { usage: TrajectoryUsage | undefined }) {
|
||||
<dd>{usage.reasoning} tok</dd>
|
||||
</div>
|
||||
)}
|
||||
{otherOutput !== undefined && (
|
||||
<div className={css.requestTokenDetail}>
|
||||
<dt>Content</dt>
|
||||
<dd>{otherOutput} tok</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
@@ -513,6 +556,46 @@ function RequestOptions({
|
||||
)
|
||||
}
|
||||
|
||||
function messageOriginLabel(source: unknown): string {
|
||||
if (typeof source !== 'object' || source === null || Array.isArray(source)) {
|
||||
return 'Unknown'
|
||||
}
|
||||
const kind = Reflect.get(source, 'kind')
|
||||
if (kind === 'user') return 'User'
|
||||
if (kind === 'plugin') {
|
||||
const plugin = Reflect.get(source, 'plugin')
|
||||
return typeof plugin === 'string' && plugin !== ''
|
||||
? `Plugin · ${plugin}`
|
||||
: 'Plugin'
|
||||
}
|
||||
if (kind === 'goal') {
|
||||
const round = Reflect.get(source, 'round')
|
||||
return typeof round === 'number' && round > 0
|
||||
? `Goal · Round ${round}`
|
||||
: 'Goal'
|
||||
}
|
||||
if (typeof kind !== 'string' || kind === '') return 'Unknown'
|
||||
return `${kind[0]?.toUpperCase() ?? ''}${kind.slice(1)}`
|
||||
}
|
||||
|
||||
function MessageOrigin({ record }: { record: TableRecord }) {
|
||||
const source = record.cell.messageSource
|
||||
if (source === undefined) return <p className={css.noPayload}>Origin not recorded</p>
|
||||
const sourceRoot = typeof source === 'object' && source !== null
|
||||
? source
|
||||
: { value: source }
|
||||
const data = record.cell.messageMeta === undefined
|
||||
? sourceRoot
|
||||
: { source, meta: record.cell.messageMeta }
|
||||
return (
|
||||
<JsonTree
|
||||
data={data}
|
||||
label="Message origin JSON"
|
||||
className={css.jsonPayload!}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function isMarkdownRecord(record: TableRecord): boolean {
|
||||
return record.cell.kind === 'user'
|
||||
|| record.cell.kind === 'context'
|
||||
@@ -557,16 +640,32 @@ function markdownSource(record: TableRecord): string | undefined {
|
||||
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' || record.cell.kind === 'compacted') {
|
||||
return record.cell.outputDetail
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function detailTabs(record: TableRecord): readonly DetailTabItem[] {
|
||||
if (record.cell.kind === 'system') {
|
||||
return record.cell.previousPromptDetail === undefined
|
||||
? SYSTEM_PROMPT_TABS
|
||||
: SYSTEM_UPDATE_TABS
|
||||
}
|
||||
if (record.cell.kind === 'compacted') {
|
||||
return [
|
||||
{ id: 'overview', label: 'Summary' },
|
||||
{ id: 'source', label: 'Raw Output' },
|
||||
]
|
||||
}
|
||||
if (isMarkdownRecord(record)) {
|
||||
return [
|
||||
{ id: 'overview', label: 'Summary' },
|
||||
{ id: 'rendered', label: 'Preview' },
|
||||
{ id: 'source', label: 'Source' },
|
||||
...(record.cell.messageSource === undefined
|
||||
? []
|
||||
: [{ id: 'origin', label: 'Origin' } as const]),
|
||||
]
|
||||
}
|
||||
return [
|
||||
@@ -586,8 +685,7 @@ function recordDisplayText(cell: TrajectoryCellProps): string {
|
||||
? cell.outputDetail ?? cell.thinkingDetail
|
||||
: undefined
|
||||
if (!markdown) return cell.text
|
||||
const plainText = extractMarkdownPlainText(markdown)
|
||||
return plainText.replace(/\s+/g, ' ').trim()
|
||||
return extractMarkdownPlainText(markdown).replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function toolCallTextParts(
|
||||
@@ -824,6 +922,83 @@ function ToolCatalog({ tools }: { tools: ConversationPromptSnapshot['tools'] })
|
||||
)
|
||||
}
|
||||
|
||||
interface PromptDiffLine {
|
||||
kind: 'meta' | 'context' | 'added' | 'removed'
|
||||
text: string
|
||||
}
|
||||
|
||||
function promptDiffLines(before: string, after: string): readonly PromptDiffLine[] {
|
||||
const patch = structuredPatch('', '', before, after, undefined, undefined, { context: 3 })
|
||||
return patch.hunks.flatMap((hunk, hunkIndex) => [
|
||||
...(hunkIndex === 0 ? [] : [{ kind: 'meta' as const, text: '' }]),
|
||||
{
|
||||
kind: 'meta' as const,
|
||||
text: `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`,
|
||||
},
|
||||
...hunk.lines.flatMap((line): PromptDiffLine[] => {
|
||||
if (line.startsWith('\\')) return []
|
||||
if (line.startsWith('+')) return [{ kind: 'added', text: line }]
|
||||
if (line.startsWith('-')) return [{ kind: 'removed', text: line }]
|
||||
return [{ kind: 'context', text: line }]
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
function PromptDiffSection({
|
||||
title,
|
||||
before,
|
||||
after,
|
||||
}: {
|
||||
title: string
|
||||
before: string
|
||||
after: string
|
||||
}) {
|
||||
const lines = promptDiffLines(before, after)
|
||||
if (lines.length === 0) return null
|
||||
return (
|
||||
<section className={css.promptDiffSection}>
|
||||
<h3 className={css.promptDiffTitle}>{title}</h3>
|
||||
<pre className={css.promptDiff}>
|
||||
{lines.map((line, index) => (
|
||||
<span className={css[`promptDiffLine${line.kind}`]} key={index}>
|
||||
{line.text || ' '}
|
||||
{'\n'}
|
||||
</span>
|
||||
))}
|
||||
</pre>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function SystemPromptDiff({
|
||||
before,
|
||||
after,
|
||||
}: {
|
||||
before: ConversationPromptSnapshot
|
||||
after: ConversationPromptSnapshot
|
||||
}) {
|
||||
const toolsBefore = JSON.stringify(before.tools, null, 2)
|
||||
const toolsAfter = JSON.stringify(after.tools, null, 2)
|
||||
return (
|
||||
<div className={css.promptDiffSections}>
|
||||
{before.system !== after.system && (
|
||||
<PromptDiffSection
|
||||
title="System Prompt"
|
||||
before={before.system}
|
||||
after={after.system}
|
||||
/>
|
||||
)}
|
||||
{toolsBefore !== toolsAfter && (
|
||||
<PromptDiffSection
|
||||
title="Tools"
|
||||
before={toolsBefore}
|
||||
after={toolsAfter}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolOutputBlocks({
|
||||
blocks,
|
||||
preview,
|
||||
@@ -862,7 +1037,7 @@ function MarkdownRecordContent({
|
||||
if (!rendered && record.cell.sourceBlocks && record.cell.sourceBlocks.length > 0) {
|
||||
return <SourceBlocks blocks={record.cell.sourceBlocks} onOpenCall={onOpenCall} />
|
||||
}
|
||||
if (record.cell.kind === 'message' && record.cell.thinkingDetail) {
|
||||
if (record.cell.thinkingDetail) {
|
||||
if (!rendered) {
|
||||
const source = [
|
||||
record.cell.thinkingDetail,
|
||||
@@ -961,11 +1136,28 @@ function RecordTiming({ record }: { record: TableRecord }) {
|
||||
function RequestTiming({
|
||||
assistant,
|
||||
anchor,
|
||||
request,
|
||||
}: {
|
||||
assistant: TableRecord | undefined
|
||||
anchor: TableRecord | undefined
|
||||
request?: TrajectoryRequestNumber
|
||||
}) {
|
||||
if (assistant !== undefined) return <RecordTiming record={assistant} />
|
||||
if (request?.startedAt !== undefined) {
|
||||
const duration = request.completedAt === null || request.completedAt === undefined
|
||||
? null
|
||||
: Math.max(0, (request.completedAt - request.startedAt) / 1000)
|
||||
return (
|
||||
<dl className={css.overview}>
|
||||
<div><dt>Started</dt><StartedAtValue timestamp={request.startedAt} /></div>
|
||||
<div><dt>Duration</dt><dd>{formatElapsedSeconds(duration)}</dd></div>
|
||||
<div>
|
||||
<dt>Timing source</dt>
|
||||
<dd>{duration === null ? 'Session timestamps (running)' : 'Session timestamps'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<dl className={css.overview}>
|
||||
<div>
|
||||
@@ -992,7 +1184,11 @@ function RecordPayload({
|
||||
: 'No result captured'
|
||||
if (!value) return <p className={css.noPayload}>{missing}</p>
|
||||
|
||||
if (direction === 'output' && record.cell.outputBlocks && record.cell.outputBlocks.length > 0) {
|
||||
if (
|
||||
direction === 'output'
|
||||
&& record.cell.outputBlocks?.some(block =>
|
||||
block.imageSrc !== undefined || block.content !== '') === true
|
||||
) {
|
||||
return (
|
||||
<ToolOutputBlocks
|
||||
blocks={record.cell.outputBlocks}
|
||||
@@ -1029,6 +1225,7 @@ function RecordPayload({
|
||||
css.payload,
|
||||
preview ? css.payloadPreview : undefined,
|
||||
record.cell.isError ? css.error : undefined,
|
||||
value === 'No output' ? css.noOutputText : undefined,
|
||||
].filter((value): value is string => value !== undefined).join(' ')}
|
||||
>
|
||||
{value}
|
||||
@@ -1141,7 +1338,6 @@ function OverviewSection({
|
||||
* @returns The ledger and an optional local record inspector.
|
||||
*/
|
||||
export function TrajectoryTable({
|
||||
prompt,
|
||||
requestNumbers: sessionRequestNumbers,
|
||||
turns,
|
||||
collapsedTurns,
|
||||
@@ -1161,13 +1357,14 @@ export function TrajectoryTable({
|
||||
const requestNumbers = indexRequestNumbers(allRecords, sessionRequestNumbers)
|
||||
const turnRecords = collapseTurnRecords(allRecords, collapsedTurns)
|
||||
const records = collapseAssistantRecords(turnRecords, collapsedAssistants)
|
||||
const systemPromptPreview = prompt === undefined
|
||||
? 'Request header not recorded'
|
||||
: prompt.system === ''
|
||||
? 'No system prompt'
|
||||
: extractMarkdownPlainText(prompt.system).replace(/\s+/g, ' ').trim()
|
||||
const promptSelected = selectedIndex === SYSTEM_PROMPT_INDEX
|
||||
const selected = allRecords.find(record => record.cell.index === selectedIndex)
|
||||
const selectedPrompt = selected?.cell.kind === 'system'
|
||||
? selected.cell.promptDetail
|
||||
: undefined
|
||||
const selectedPreviousPrompt = selected?.cell.kind === 'system'
|
||||
? selected.cell.previousPromptDetail
|
||||
: undefined
|
||||
const promptSelected = selectedPrompt !== undefined
|
||||
const selectedState = selected === undefined ? undefined : stateOf(selected)
|
||||
const selectedRequestRecords = selectedRequest === null
|
||||
? []
|
||||
@@ -1179,23 +1376,27 @@ export function TrajectoryTable({
|
||||
record => record.cell.kind === 'message',
|
||||
)
|
||||
const selectedRequestAnchor = selectedRequestAssistant ?? selectedRequestRecords[0]
|
||||
const selectedRequestInfo = selectedRequest === null
|
||||
? undefined
|
||||
: sessionRequestNumbers?.find(request => request.number === selectedRequest.number)
|
||||
const selectedRequestState: RecordState | undefined = selectedRequest === null
|
||||
? undefined
|
||||
: selectedRequestAssistant?.cell.assistantMetrics?.completedTime === null
|
||||
? 'running'
|
||||
: selectedRequestAssistant === undefined
|
||||
&& selectedRequestRecords.some(record => stateOf(record) === 'running')
|
||||
: selectedRequestInfo?.status
|
||||
?? (selectedRequestAssistant?.cell.assistantMetrics?.completedTime === null
|
||||
? 'running'
|
||||
: 'complete'
|
||||
: selectedRequestAssistant === undefined
|
||||
&& selectedRequestRecords.some(record => stateOf(record) === 'running')
|
||||
? 'running'
|
||||
: 'complete')
|
||||
const selectedRequestToolCalls = selectedRequestRecords.filter(
|
||||
record => record.cell.kind === 'tool',
|
||||
).length
|
||||
const selectedRequestSubtoolCalls = selectedRequestRecords.filter(
|
||||
record => record.cell.kind === 'subtool',
|
||||
).length
|
||||
const selectedRequestInfo = selectedRequest === null
|
||||
? undefined
|
||||
: sessionRequestNumbers?.find(request => request.number === selectedRequest.number)
|
||||
const selectedRequestResult = selectedRequestInfo?.resultSeq === undefined
|
||||
? selectedRequestAssistant
|
||||
: allRecords.find(record => record.cell.sourceSeq === selectedRequestInfo.resultSeq)
|
||||
const selectedRequestUsage = selectedRequestInfo?.usage ?? (
|
||||
selectedRequestAssistant === undefined
|
||||
? undefined
|
||||
@@ -1223,9 +1424,7 @@ export function TrajectoryTable({
|
||||
const activeTurn = selectedRequest?.turn ?? selected?.turn
|
||||
const selectedTabs = selectedRequest !== null
|
||||
? REQUEST_TABS.filter(tab => tab.id !== 'options' || selectedRequestOptions !== undefined)
|
||||
: promptSelected
|
||||
? SYSTEM_PROMPT_TABS
|
||||
: selected === undefined ? [] : detailTabs(selected)
|
||||
: selected === undefined ? [] : detailTabs(selected)
|
||||
const selectedParents: ParentRecords = selected === undefined
|
||||
? {}
|
||||
: parentRecords(allRecords, selected)
|
||||
@@ -1260,15 +1459,10 @@ export function TrajectoryTable({
|
||||
setSelectedRequest(null)
|
||||
setSelectedIndex(index)
|
||||
if (record === undefined) return
|
||||
const available = new Set(detailTabs(record).map(tab => tab.id))
|
||||
const tabs = detailTabs(record)
|
||||
const available = new Set(tabs.map(tab => tab.id))
|
||||
const recent = [...tabHistory.current].reverse().find(tab => available.has(tab))
|
||||
setActiveTab(recent ?? 'overview')
|
||||
}
|
||||
|
||||
const selectSystemPrompt = () => {
|
||||
setSelectedRequest(null)
|
||||
setSelectedIndex(SYSTEM_PROMPT_INDEX)
|
||||
activateTab('system-prompt')
|
||||
setActiveTab(recent ?? tabs[0]?.id ?? 'overview')
|
||||
}
|
||||
|
||||
const selectRequest = (
|
||||
@@ -1311,36 +1505,6 @@ export function TrajectoryTable({
|
||||
<col className={css.contentColumn} />
|
||||
</colgroup>
|
||||
<tbody>
|
||||
<tr
|
||||
tabIndex={0}
|
||||
aria-label="System prompt and tool catalog"
|
||||
aria-selected={promptSelected}
|
||||
data-kind="system"
|
||||
data-selected={promptSelected || undefined}
|
||||
onClick={selectSystemPrompt}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return
|
||||
event.preventDefault()
|
||||
selectSystemPrompt()
|
||||
}}
|
||||
>
|
||||
<td className={css.event}>
|
||||
{promptSelected && <span className={css.selectionRail} aria-hidden="true" />}
|
||||
<div className={css.eventInner}>
|
||||
<span className={`${css.kindSlot} ${css.kindSlotLeft}`}>
|
||||
<span className={`${css.kindTag} ${css.systemNeutral}`}>SYSTEM</span>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className={css.content}>
|
||||
<span
|
||||
className={css.contentText}
|
||||
title={prompt?.system || systemPromptPreview}
|
||||
>
|
||||
{systemPromptPreview}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{records.map((record) => {
|
||||
const displayText = recordDisplayText(record.cell)
|
||||
const toolCallText = toolCallTextParts(record.cell.kind, displayText)
|
||||
@@ -1348,20 +1512,38 @@ export function TrajectoryTable({
|
||||
? displayText
|
||||
: [toolCallText.name, toolCallText.args].filter(Boolean).join(' ')
|
||||
const isCollapsedSummary = record.collapsedSummary !== undefined
|
||||
const isRequestOnly = record.cell.requestOnly === true
|
||||
const isInitialSystem = record.cell.kind === 'system'
|
||||
&& record.cell.index === allRecords[0]?.cell.index
|
||||
const request = record.groupStart
|
||||
&& !isCollapsedSummary
|
||||
&& !collapsedTurns.has(record.turn)
|
||||
? requestNumbers.get(requestKey(record.turn, record.group))
|
||||
: undefined
|
||||
const requestInfo = request === undefined
|
||||
? undefined
|
||||
: sessionRequestNumbers?.find(candidate => candidate.number === request)
|
||||
const requestLabel = request === undefined
|
||||
? undefined
|
||||
: `Request #${request}${requestInfo?.purpose === 'compaction' ? ' · Compaction' : ''}`
|
||||
const requestSelected = request !== undefined
|
||||
&& selectedRequest?.turn === record.turn
|
||||
&& selectedRequest.number === request
|
||||
return (
|
||||
<tr
|
||||
key={`${record.cell.index}:${record.collapsedSummaryKind ?? 'record'}`}
|
||||
tabIndex={0}
|
||||
tabIndex={isRequestOnly ? -1 : 0}
|
||||
aria-label={isCollapsedSummary
|
||||
? `Collapsed ${record.collapsedSummaryKind} summary, ${record.collapsedSummary}`
|
||||
: `${request === undefined ? '' : `Request ${request}, `}${KIND_LABEL[record.cell.kind]}, ${listDisplayText || 'no content'}`}
|
||||
aria-selected={!isCollapsedSummary && selectedIndex === record.cell.index}
|
||||
: isRequestOnly
|
||||
? `Request ${request ?? ''}, compaction`
|
||||
: `${request === undefined ? '' : `Request ${request}, `}${KIND_LABEL[record.cell.kind]}, ${listDisplayText || 'no content'}`}
|
||||
aria-selected={!isCollapsedSummary && !isRequestOnly && selectedIndex === record.cell.index}
|
||||
data-kind={record.cell.kind}
|
||||
data-record-index={!isCollapsedSummary && !isRequestOnly
|
||||
? record.cell.index
|
||||
: undefined}
|
||||
data-request-only={isRequestOnly || undefined}
|
||||
data-group-start={record.groupStart || undefined}
|
||||
data-turn-start={record.turnStart || undefined}
|
||||
data-error={record.cell.isError || undefined}
|
||||
@@ -1369,14 +1551,16 @@ export function TrajectoryTable({
|
||||
data-turn-end={record.turnEnd || undefined}
|
||||
data-collapsed-summary={record.collapsedSummaryKind}
|
||||
data-selected={!isCollapsedSummary && selectedIndex === record.cell.index || undefined}
|
||||
onClick={isCollapsedSummary
|
||||
onClick={isRequestOnly
|
||||
? undefined
|
||||
: isCollapsedSummary
|
||||
? () => {
|
||||
if (record.collapsedSummaryKind === 'turn') onToggleTurn(record.turn)
|
||||
else onToggleAssistant(record.cell.index)
|
||||
}
|
||||
: () => { selectRecord(record.cell.index) }}
|
||||
onDoubleClick={(event) => {
|
||||
if (isCollapsedSummary) return
|
||||
if (isCollapsedSummary || isRequestOnly) return
|
||||
if (collapsedTurns.has(record.turn)) {
|
||||
event.preventDefault()
|
||||
onToggleTurn(record.turn)
|
||||
@@ -1391,11 +1575,15 @@ export function TrajectoryTable({
|
||||
return
|
||||
}
|
||||
if (!record.turnStart) return
|
||||
if (allRecords.filter(candidate => candidate.turn === record.turn).length <= 1) return
|
||||
if (allRecords.filter(candidate =>
|
||||
candidate.turn === record.turn
|
||||
&& candidate.cell.requestOnly !== true
|
||||
&& candidate.cell.kind !== 'system').length <= 1) return
|
||||
event.preventDefault()
|
||||
onToggleTurn(record.turn)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (isRequestOnly) return
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return
|
||||
event.preventDefault()
|
||||
if (isCollapsedSummary) {
|
||||
@@ -1410,13 +1598,12 @@ export function TrajectoryTable({
|
||||
{request !== undefined && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.requestBoundaryControl}
|
||||
aria-label={`Request #${request}`}
|
||||
aria-pressed={
|
||||
selectedRequest?.turn === record.turn
|
||||
&& selectedRequest.number === request
|
||||
}
|
||||
data-label={`Request #${request}`}
|
||||
className={requestSelected
|
||||
? `${css.requestBoundaryControl} ${css.requestBoundaryControlActive}`
|
||||
: css.requestBoundaryControl}
|
||||
aria-label={requestLabel}
|
||||
aria-pressed={requestSelected}
|
||||
data-label={requestLabel}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
selectRequest({
|
||||
@@ -1428,26 +1615,35 @@ export function TrajectoryTable({
|
||||
onDoubleClick={(event) => { event.stopPropagation() }}
|
||||
/>
|
||||
)}
|
||||
{activeTurn === record.turn && (
|
||||
{activeTurn === record.turn && !isInitialSystem && (
|
||||
<span className={css.turnRail} aria-hidden="true" />
|
||||
)}
|
||||
{!isCollapsedSummary && selectedIndex === record.cell.index && (
|
||||
<span className={css.selectionRail} aria-hidden="true" />
|
||||
)}
|
||||
{!isCollapsedSummary
|
||||
&& !isRequestOnly
|
||||
&& record.turnStart && (
|
||||
<span
|
||||
className={activeTurn === record.turn
|
||||
? `${css.turnLabel} ${css.turnLabelActive}`
|
||||
: css.turnLabel}
|
||||
>
|
||||
Turn {record.turn}
|
||||
</span>
|
||||
)}
|
||||
<div className={css.eventInner}>
|
||||
{!isCollapsedSummary && (
|
||||
{!isCollapsedSummary && !isRequestOnly && (
|
||||
<span
|
||||
className={
|
||||
record.cell.kind === 'user'
|
||||
|| record.cell.kind === 'context'
|
||||
|| record.cell.kind === 'message'
|
||||
? `${css.kindSlot} ${css.kindSlotLeft}`
|
||||
: `${css.kindSlot} ${css.kindSlotRight}`
|
||||
}
|
||||
className={css.kindSlot}
|
||||
>
|
||||
<span className={`${css.kindTag} ${
|
||||
record.cell.kind === 'context'
|
||||
record.cell.kind === 'system'
|
||||
? css.systemNeutral
|
||||
: record.cell.kind === 'context'
|
||||
? css.contextGreen
|
||||
: record.cell.kind === 'compacted'
|
||||
? css.compacted
|
||||
: record.cell.kind === 'tool'
|
||||
? css.toolAmber
|
||||
: record.cell.kind === 'message'
|
||||
@@ -1459,21 +1655,14 @@ export function TrajectoryTable({
|
||||
>
|
||||
{KIND_LABEL[record.cell.kind]}
|
||||
</span>
|
||||
{record.turnStart && record.cell.opensTurn && (
|
||||
<span
|
||||
className={activeTurn === record.turn
|
||||
? `${css.turnLabel} ${css.turnLabelActive}`
|
||||
: css.turnLabel}
|
||||
>
|
||||
Turn {record.turn}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className={css.content}>
|
||||
{record.collapsedSummary !== undefined
|
||||
{isRequestOnly
|
||||
? null
|
||||
: record.collapsedSummary !== undefined
|
||||
? (
|
||||
<span className={css.collapsedTurnContent} title={record.collapsedSummary}>
|
||||
<span className={css.collapsedTurnEllipsis}>…</span>
|
||||
@@ -1508,7 +1697,12 @@ export function TrajectoryTable({
|
||||
{record.cell.result !== undefined && (
|
||||
<span className={record.cell.isError ? `${css.inlineResult} ${css.error}` : css.inlineResult}>
|
||||
<span className={css.arrow}>→</span>
|
||||
<span className={css.inlineResultText}>{record.cell.result}</span>
|
||||
<span className={record.cell.result === 'No output'
|
||||
? `${css.inlineResultText} ${css.noOutputText}`
|
||||
: css.inlineResultText}
|
||||
>
|
||||
{record.cell.result}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
@@ -1611,18 +1805,27 @@ export function TrajectoryTable({
|
||||
<span className={css.requestDetailsName}>
|
||||
Request #{selectedRequest.number}
|
||||
</span>
|
||||
<span className={css.detailsLocation}>Turn {selectedRequest.turn}</span>
|
||||
<span className={css.detailsLocation}>
|
||||
{selectedRequestInfo?.purpose === 'compaction'
|
||||
? `Compaction · Turn ${selectedRequest.turn}`
|
||||
: `Turn ${selectedRequest.turn}`}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
: promptSelected
|
||||
? (
|
||||
<span className={`${css.kindTag} ${css.systemNeutral}`}>SYSTEM</span>
|
||||
<>
|
||||
<span className={`${css.kindTag} ${css.systemNeutral}`}>SYSTEM</span>
|
||||
<span className={css.detailsLocation}>{selected?.cell.text}</span>
|
||||
</>
|
||||
)
|
||||
: selected !== undefined && (
|
||||
<>
|
||||
<span className={`${css.kindTag} ${
|
||||
selected.cell.kind === 'context'
|
||||
? css.contextGreen
|
||||
: selected.cell.kind === 'compacted'
|
||||
? css.compacted
|
||||
: selected.cell.kind === 'tool'
|
||||
? css.toolAmber
|
||||
: selected.cell.kind === 'message'
|
||||
@@ -1634,7 +1837,11 @@ export function TrajectoryTable({
|
||||
>
|
||||
{KIND_LABEL[selected.cell.kind]}
|
||||
</span>
|
||||
<span className={css.detailsLocation}>{`Turn ${selected.turn} · ${selected.group}`}</span>
|
||||
<span className={css.detailsLocation}>
|
||||
{selected.cell.kind === 'compacted'
|
||||
? `Turn ${selected.turn}`
|
||||
: `Turn ${selected.turn} · ${selected.group}`}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -1668,7 +1875,9 @@ export function TrajectoryTable({
|
||||
</div>
|
||||
<div
|
||||
id="trajectory-detail-panel"
|
||||
className={css.detailBody}
|
||||
className={activeTab === 'overview'
|
||||
? `${css.detailBody} ${css.detailBodySummary}`
|
||||
: css.detailBody}
|
||||
role="tabpanel"
|
||||
aria-labelledby={`trajectory-detail-${activeTab}`}
|
||||
>
|
||||
@@ -1681,6 +1890,12 @@ export function TrajectoryTable({
|
||||
<dt>Status</dt>
|
||||
<dd>{statusLabel(selectedRequestState)}</dd>
|
||||
</div>
|
||||
{selectedRequestInfo?.purpose === 'compaction' && (
|
||||
<div>
|
||||
<dt>Purpose</dt>
|
||||
<dd>Compaction</dd>
|
||||
</div>
|
||||
)}
|
||||
{(selectedRequestInfo?.provider
|
||||
?? selectedRequestInfo?.requestConfig?.provider) !== undefined && (
|
||||
<div>
|
||||
@@ -1711,7 +1926,30 @@ export function TrajectoryTable({
|
||||
<dd>{selectedRequestSubtoolCalls}</dd>
|
||||
</div>
|
||||
)}
|
||||
{selectedRequestAssistant !== undefined && (
|
||||
{selectedRequestInfo?.error !== undefined && (
|
||||
<div>
|
||||
<dt>Error</dt>
|
||||
<dd>{selectedRequestInfo.error}</dd>
|
||||
</div>
|
||||
)}
|
||||
{selectedRequestInfo?.retry !== undefined && (
|
||||
<div>
|
||||
<dt>Retry</dt>
|
||||
<dd>
|
||||
Scheduled {selectedRequestInfo.retry}
|
||||
{selectedRequestInfo.maxRetries === undefined
|
||||
? ''
|
||||
: ` of ${selectedRequestInfo.maxRetries}`}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{selectedRequestInfo?.retryDelayMs !== undefined && (
|
||||
<div>
|
||||
<dt>Retry delay</dt>
|
||||
<dd>{formatDurationMs(selectedRequestInfo.retryDelayMs)}</dd>
|
||||
</div>
|
||||
)}
|
||||
{selectedRequestResult !== undefined && (
|
||||
<div>
|
||||
<dt>Result</dt>
|
||||
<dd className={css.overviewParentLinks}>
|
||||
@@ -1719,10 +1957,14 @@ export function TrajectoryTable({
|
||||
type="button"
|
||||
className={css.overviewHierarchyNavLink}
|
||||
onClick={() => {
|
||||
openRecordSummary(selectedRequestAssistant)
|
||||
openRecordSummary(selectedRequestResult)
|
||||
}}
|
||||
>
|
||||
<span>Assistant Message</span>
|
||||
<span>
|
||||
{selectedRequestInfo?.purpose === 'compaction'
|
||||
? 'Compacted'
|
||||
: 'Assistant Message'}
|
||||
</span>
|
||||
<IconChevronRightOutline14
|
||||
className={css.overviewHierarchyJumpIconTight}
|
||||
size={11}
|
||||
@@ -1745,6 +1987,7 @@ export function TrajectoryTable({
|
||||
<RequestTiming
|
||||
assistant={selectedRequestAssistant}
|
||||
anchor={selectedRequestAnchor}
|
||||
request={selectedRequestInfo}
|
||||
/>
|
||||
</OverviewSection>
|
||||
</div>
|
||||
@@ -1763,30 +2006,93 @@ export function TrajectoryTable({
|
||||
<RequestTiming
|
||||
assistant={selectedRequestAssistant}
|
||||
anchor={selectedRequestAnchor}
|
||||
request={selectedRequestInfo}
|
||||
/>
|
||||
)}
|
||||
{promptSelected
|
||||
&& selectedPreviousPrompt !== undefined
|
||||
&& activeTab === 'diff' && (
|
||||
<SystemPromptDiff
|
||||
before={selectedPreviousPrompt}
|
||||
after={selectedPrompt}
|
||||
/>
|
||||
)}
|
||||
{promptSelected && activeTab === 'system-prompt' && (
|
||||
prompt === undefined
|
||||
? <p className={css.noPayload}>Request header not recorded</p>
|
||||
: prompt.system === ''
|
||||
selectedPrompt.system === ''
|
||||
? <p className={css.noPayload}>No system prompt in this request</p>
|
||||
: (
|
||||
<div className={`${css.markdownPayload} ${css.systemPrompt}`}>
|
||||
<MarkdownText text={prompt.system} />
|
||||
<MarkdownText text={selectedPrompt.system} />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{promptSelected && activeTab === 'tools' && (
|
||||
prompt === undefined
|
||||
? <p className={css.noPayload}>Request header not recorded</p>
|
||||
: <ToolCatalog tools={prompt.tools} />
|
||||
<ToolCatalog tools={selectedPrompt.tools} />
|
||||
)}
|
||||
{!promptSelected && selected !== undefined && selectedState !== undefined && activeTab === 'overview' && (
|
||||
{!promptSelected
|
||||
&& selected?.cell.kind === 'compacted'
|
||||
&& selectedState !== undefined
|
||||
&& activeTab === 'overview' && (
|
||||
<>
|
||||
<dl className={css.overview}>
|
||||
<div>
|
||||
<dt>Status</dt>
|
||||
<dd>{statusLabel(selectedState)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Duration</dt>
|
||||
<dd>{formatElapsedSeconds(selected.cell.timeSeconds)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Tokens</dt>
|
||||
<dd>{tokenSummary(selected.cell)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{selected.cell.outputDetail !== undefined && (
|
||||
<div className={css.compactedSummary}>
|
||||
<MarkdownRecordContent
|
||||
record={selected}
|
||||
rendered
|
||||
thinkingExpanded={thinkingExpanded}
|
||||
onThinkingExpandedChange={setThinkingExpanded}
|
||||
onOpenCall={openCallSummary}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!promptSelected
|
||||
&& selected !== undefined
|
||||
&& selected.cell.kind !== 'compacted'
|
||||
&& selectedState !== undefined
|
||||
&& activeTab === 'overview' && (
|
||||
<>
|
||||
<dl className={css.overview}>
|
||||
{selected.cell.messageSource !== undefined && (
|
||||
<div>
|
||||
<dt>Origin</dt>
|
||||
<dd className={css.overviewParentLinks}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.overviewHierarchyNavLink}
|
||||
onClick={() => { activateTab('origin') }}
|
||||
>
|
||||
<span>{messageOriginLabel(selected.cell.messageSource)}</span>
|
||||
<IconChevronRightOutline14
|
||||
className={css.overviewHierarchyJumpIconTight}
|
||||
size={11}
|
||||
/>
|
||||
</button>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{hasSelectedHierarchy && (
|
||||
<div>
|
||||
<dt>Hierarchy</dt>
|
||||
<dt>
|
||||
{selectedAssistantRequestTarget !== undefined
|
||||
? 'Origin'
|
||||
: 'Hierarchy'}
|
||||
</dt>
|
||||
<dd className={css.overviewParentLinks}>
|
||||
{selectedAssistantRequestTarget !== undefined && (
|
||||
<button
|
||||
@@ -1915,6 +2221,9 @@ export function TrajectoryTable({
|
||||
onOpenCall={openCallSummary}
|
||||
/>
|
||||
)}
|
||||
{!promptSelected && selected !== undefined && activeTab === 'origin' && (
|
||||
<MessageOrigin record={selected} />
|
||||
)}
|
||||
{!promptSelected && selected !== undefined && activeTab === 'input' && (
|
||||
<RecordPayload record={selected} direction="input" />
|
||||
)}
|
||||
|
||||
@@ -32,36 +32,6 @@
|
||||
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;
|
||||
@@ -74,7 +44,7 @@
|
||||
flex: none;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
height: 26px;
|
||||
height: 24px;
|
||||
padding: 0 7px;
|
||||
gap: 6px;
|
||||
border: 0;
|
||||
|
||||
@@ -3,10 +3,6 @@
|
||||
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. */
|
||||
@@ -27,8 +23,6 @@ export interface TrajectoryToolbarProps {
|
||||
* @returns the toolbar element.
|
||||
*/
|
||||
export function TrajectoryToolbar({
|
||||
contextLabel,
|
||||
contextCurrent,
|
||||
collapsibleTurns,
|
||||
allTurnsCollapsed,
|
||||
onToggleAllTurns,
|
||||
@@ -41,15 +35,6 @@ 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
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {
|
||||
AssistantMessageNode, ConversationContext,
|
||||
AssistantMessageNode, CompactionRequestView, ConversationContext,
|
||||
ConversationPromptChange, ModelRequestView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { ContextsPanel, contextLabel } from './ContextsPanel.tsx'
|
||||
import {
|
||||
deriveTrajectoryContextBranches, trajectoryBranchContainsSeq,
|
||||
} from './context-branches.ts'
|
||||
import {
|
||||
TrajectoryTable,
|
||||
type TrajectoryRequestNumber,
|
||||
@@ -16,6 +19,9 @@ import { deriveTrajectoryLayout } from './layout.ts'
|
||||
import css from './views.module.css'
|
||||
|
||||
const EMPTY_IDS: ReadonlySet<number> = new Set()
|
||||
const EMPTY_COMPACTION_REQUESTS: readonly CompactionRequestView[] = []
|
||||
const EMPTY_MODEL_REQUESTS: readonly ModelRequestView[] = []
|
||||
const EMPTY_PROMPT_CHANGES: readonly ConversationPromptChange[] = []
|
||||
|
||||
interface UsageLike {
|
||||
inputTokens?: number
|
||||
@@ -61,22 +67,21 @@ function addUsage(
|
||||
}
|
||||
}
|
||||
|
||||
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 [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 [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_IDS)
|
||||
const [collapsedAssistants, setCollapsedAssistants] =
|
||||
useState<ReadonlySet<number>>(EMPTY_IDS)
|
||||
const nodes = useSession((s) => s.nodes)
|
||||
const projectedContexts = useSession((s) => s.contexts)
|
||||
const compactionRequests = useSession(
|
||||
(s) => s.compactionRequests ?? EMPTY_COMPACTION_REQUESTS,
|
||||
)
|
||||
const requestAttempts = useSession(
|
||||
(s) => s.requestAttempts ?? EMPTY_MODEL_REQUESTS,
|
||||
)
|
||||
const promptChanges = useSession(
|
||||
(s) => s.promptChanges ?? EMPTY_PROMPT_CHANGES,
|
||||
)
|
||||
const partial = useSession((s) => s.partial)
|
||||
const runningCalls = useSession((s) => s.runningCalls)
|
||||
const callSchemas = useSession((s) => s.callSchemas)
|
||||
@@ -87,95 +92,201 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
|
||||
: 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 requestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
|
||||
const requestsBySeq = new Map<number, AssistantMessageNode>()
|
||||
const branches = useMemo(
|
||||
() => deriveTrajectoryContextBranches(contexts),
|
||||
[contexts],
|
||||
)
|
||||
const currentBranch = branches.at(-1)
|
||||
if (currentBranch === undefined) throw new Error('trajectory branch projection must not be empty')
|
||||
const selectedNodes = currentBranch.nodes
|
||||
const globalRequestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
|
||||
const assistantsByStep = new Map<string, AssistantMessageNode>()
|
||||
for (const context of contexts) {
|
||||
for (const node of context.nodes) {
|
||||
if (node.kind !== 'assistant' || node.step <= 0) continue
|
||||
requestsBySeq.set(node.seq, node)
|
||||
assistantsByStep.set(`${node.turn}\u0000${node.step}`, node)
|
||||
}
|
||||
}
|
||||
for (const node of nodes) {
|
||||
if (node.kind !== 'assistant' || node.step <= 0) continue
|
||||
requestsBySeq.set(node.seq, node)
|
||||
assistantsByStep.set(`${node.turn}\u0000${node.step}`, node)
|
||||
}
|
||||
const orderedRequests = [...requestsBySeq.values()]
|
||||
.sort((left, right) => left.seq - right.seq)
|
||||
const requestBySeq = new Map<number, TrajectoryRequestNumber>()
|
||||
const attemptsByStep = new Map(
|
||||
requestAttempts.map(request => [
|
||||
`${request.turn}\u0000${request.step}`,
|
||||
request,
|
||||
]),
|
||||
)
|
||||
const orderedRequests = [
|
||||
...requestAttempts.map(request => ({
|
||||
seq: request.startSeq,
|
||||
kind: 'ordinary' as const,
|
||||
request,
|
||||
node: assistantsByStep.get(`${request.turn}\u0000${request.step}`),
|
||||
})),
|
||||
...[...assistantsByStep.entries()].flatMap(([key, node]) =>
|
||||
attemptsByStep.has(key)
|
||||
? []
|
||||
: [{
|
||||
seq: node.seq,
|
||||
kind: 'ordinary' as const,
|
||||
request: undefined,
|
||||
node,
|
||||
}],
|
||||
),
|
||||
...compactionRequests.map(request => ({
|
||||
seq: request.startSeq,
|
||||
kind: 'compaction' as const,
|
||||
request,
|
||||
node: undefined,
|
||||
})),
|
||||
].sort((left, right) => left.seq - right.seq)
|
||||
const numbered: TrajectoryRequestNumber[] = []
|
||||
let cumulativeUsage: TrajectoryUsage | undefined
|
||||
for (const [index, node] of orderedRequests.entries()) {
|
||||
const usage = requestUsage(node.usage)
|
||||
for (const [index, entry] of orderedRequests.entries()) {
|
||||
const usage = requestUsage(
|
||||
entry.kind === 'compaction'
|
||||
? entry.request.usage
|
||||
: entry.request?.usage ?? entry.node?.usage,
|
||||
)
|
||||
cumulativeUsage = addUsage(cumulativeUsage, usage)
|
||||
requestBySeq.set(node.seq, {
|
||||
turn: node.turn,
|
||||
step: node.step,
|
||||
if (entry.kind === 'ordinary') {
|
||||
const request = entry.request
|
||||
const node = entry.node
|
||||
const turn = request?.turn ?? node?.turn
|
||||
const step = request?.step ?? node?.step
|
||||
if (turn === undefined || step === undefined) continue
|
||||
const provider = request?.provenance?.provider ?? node?.provenance?.provider
|
||||
const model = request?.provenance?.model ?? node?.provenance?.model
|
||||
const requestConfig = request?.requestConfig ?? node?.requestConfig
|
||||
numbered.push({
|
||||
seq: entry.seq,
|
||||
turn,
|
||||
step,
|
||||
group: `Step ${step}`,
|
||||
number: index + 1,
|
||||
...(request?.status === undefined ? {} : { status: request.status }),
|
||||
...(request?.startedAt === undefined ? {} : { startedAt: request.startedAt }),
|
||||
...(request?.completedAt === undefined ? {} : { completedAt: request.completedAt }),
|
||||
...(request?.error === undefined ? {} : { error: request.error }),
|
||||
...(request?.resultSeq === undefined ? {} : { resultSeq: request.resultSeq }),
|
||||
...(request?.retry === undefined ? {} : { retry: request.retry }),
|
||||
...(request?.maxRetries === undefined ? {} : { maxRetries: request.maxRetries }),
|
||||
...(request?.retryDelayMs === undefined
|
||||
? {}
|
||||
: { retryDelayMs: request.retryDelayMs }),
|
||||
...(provider === undefined ? {} : { provider }),
|
||||
...(model === undefined ? {} : { model }),
|
||||
...(requestConfig === undefined ? {} : { requestConfig }),
|
||||
...(usage === undefined ? {} : { usage }),
|
||||
...(cumulativeUsage === undefined ? {} : { cumulativeUsage }),
|
||||
})
|
||||
continue
|
||||
}
|
||||
const request = entry.request
|
||||
numbered.push({
|
||||
seq: request.startSeq,
|
||||
turn: request.turn,
|
||||
step: 0,
|
||||
group: `Compaction ${request.startSeq}`,
|
||||
number: index + 1,
|
||||
...(node.provenance?.provider === undefined
|
||||
purpose: 'compaction',
|
||||
status: request.status,
|
||||
startedAt: request.startedAt,
|
||||
completedAt: request.completedAt,
|
||||
...(request.error === undefined ? {} : { error: request.error }),
|
||||
resultSeq: request.startSeq,
|
||||
...(request.provenance?.provider === undefined
|
||||
? {}
|
||||
: { provider: node.provenance.provider }),
|
||||
...(node.provenance?.model === undefined
|
||||
: { provider: request.provenance.provider }),
|
||||
...(request.provenance?.model === undefined
|
||||
? {}
|
||||
: { model: node.provenance.model }),
|
||||
...(node.requestConfig === undefined ? {} : { requestConfig: node.requestConfig }),
|
||||
: { model: request.provenance.model }),
|
||||
...(request.requestConfig === undefined ? {} : { requestConfig: request.requestConfig }),
|
||||
...(usage === undefined ? {} : { usage }),
|
||||
...(cumulativeUsage === undefined ? {} : { cumulativeUsage }),
|
||||
})
|
||||
}
|
||||
|
||||
const selected: TrajectoryRequestNumber[] = []
|
||||
const selectedKeys = new Set<string>()
|
||||
for (const node of selectedNodes) {
|
||||
if (node.kind !== 'assistant' || node.step <= 0) continue
|
||||
const request = requestBySeq.get(node.seq)
|
||||
if (request === undefined) continue
|
||||
selected.push(request)
|
||||
selectedKeys.add(`${node.turn}\u0000${node.step}`)
|
||||
}
|
||||
if (viewingCurrent && partial !== null && partial.step > 0) {
|
||||
if (partial !== null && partial.step > 0) {
|
||||
const key = `${partial.turn}\u0000${partial.step}`
|
||||
if (!selectedKeys.has(key)) {
|
||||
selected.push({
|
||||
const recorded = numbered.some(request =>
|
||||
`${request.turn}\u0000${request.step}` === key,
|
||||
)
|
||||
if (!recorded) {
|
||||
numbered.push({
|
||||
turn: partial.turn,
|
||||
step: partial.step,
|
||||
group: `Step ${partial.step}`,
|
||||
number: orderedRequests.length + 1,
|
||||
...(currentContext.prompt?.config?.provider === undefined
|
||||
...(currentBranch.latest.prompt?.config?.provider === undefined
|
||||
? {}
|
||||
: { provider: currentContext.prompt.config.provider }),
|
||||
...(currentContext.prompt?.config?.model === undefined
|
||||
: { provider: currentBranch.latest.prompt.config.provider }),
|
||||
...(currentBranch.latest.prompt?.config?.model === undefined
|
||||
? {}
|
||||
: { model: currentContext.prompt.config.model }),
|
||||
...(currentContext.prompt?.config === undefined
|
||||
: { model: currentBranch.latest.prompt.config.model }),
|
||||
...(currentBranch.latest.prompt?.config === undefined
|
||||
? {}
|
||||
: { requestConfig: currentContext.prompt.config }),
|
||||
: { requestConfig: currentBranch.latest.prompt.config }),
|
||||
...(cumulativeUsage === undefined ? {} : { cumulativeUsage }),
|
||||
})
|
||||
}
|
||||
}
|
||||
return selected
|
||||
}, [contexts, currentContext.prompt, nodes, partial, selectedNodes, viewingCurrent])
|
||||
const collapsedTurns = collapsedTurnsByContext.get(selectedContext.id) ?? EMPTY_IDS
|
||||
const collapsedAssistants = collapsedAssistantsByContext.get(selectedContext.id) ?? EMPTY_IDS
|
||||
return numbered
|
||||
}, [
|
||||
compactionRequests, contexts, currentBranch.latest.prompt, nodes, partial,
|
||||
requestAttempts,
|
||||
])
|
||||
const requestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
|
||||
return globalRequestNumbers.filter(request =>
|
||||
request.seq === undefined
|
||||
|| trajectoryBranchContainsSeq(currentBranch, request.seq),
|
||||
)
|
||||
}, [currentBranch, globalRequestNumbers])
|
||||
const visibleRequestAttempts = useMemo(
|
||||
() => requestAttempts.filter(request =>
|
||||
requestNumbers.some(number =>
|
||||
number.purpose !== 'compaction' && number.seq === request.startSeq,
|
||||
)),
|
||||
[requestAttempts, requestNumbers],
|
||||
)
|
||||
const visibleCompactionRequests = useMemo(
|
||||
() => compactionRequests.filter(request =>
|
||||
requestNumbers.some(number =>
|
||||
number.purpose === 'compaction' && number.seq === request.startSeq,
|
||||
)),
|
||||
[compactionRequests, requestNumbers],
|
||||
)
|
||||
const visiblePromptChanges = useMemo(
|
||||
() => promptChanges.filter(change =>
|
||||
trajectoryBranchContainsSeq(currentBranch, change.seq)),
|
||||
[currentBranch, promptChanges],
|
||||
)
|
||||
const turns = useMemo(
|
||||
() => deriveTrajectoryLayout({
|
||||
nodes: selectedNodes,
|
||||
partial: viewingCurrent ? partial : null,
|
||||
runningCalls: viewingCurrent ? runningCalls : [],
|
||||
partial,
|
||||
runningCalls,
|
||||
compactionRequests: visibleCompactionRequests,
|
||||
requestAttempts: visibleRequestAttempts,
|
||||
promptChanges: visiblePromptChanges,
|
||||
callSchemas,
|
||||
codeDispatches,
|
||||
}),
|
||||
[
|
||||
selectedNodes, viewingCurrent, partial, runningCalls, callSchemas, codeDispatches,
|
||||
selectedNodes, partial, runningCalls, visibleCompactionRequests,
|
||||
visibleRequestAttempts, visiblePromptChanges, callSchemas, codeDispatches,
|
||||
],
|
||||
)
|
||||
const collapsibleTurnIds = useMemo(
|
||||
() => turns
|
||||
.filter(turn => turn.groups.reduce((count, group) => count + group.cells.length, 0) > 1)
|
||||
.filter(turn =>
|
||||
turn.groups.reduce(
|
||||
(count, group) =>
|
||||
count + group.cells.filter(cell =>
|
||||
cell.requestOnly !== true && cell.kind !== 'system').length,
|
||||
0,
|
||||
) > 1)
|
||||
.map(turn => turn.turn),
|
||||
[turns],
|
||||
)
|
||||
@@ -198,68 +309,50 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
|
||||
&& collapsibleAssistantIds.every(index => collapsedAssistants.has(index))
|
||||
|
||||
const toggleTurn = (turn: number) => {
|
||||
setCollapsedTurnsByContext((current) => {
|
||||
const next = new Map(current)
|
||||
const collapsed = new Set(current.get(selectedContext.id) ?? EMPTY_IDS)
|
||||
setCollapsedTurns((current) => {
|
||||
const collapsed = new Set(current)
|
||||
if (collapsed.has(turn)) collapsed.delete(turn)
|
||||
else collapsed.add(turn)
|
||||
next.set(selectedContext.id, collapsed)
|
||||
return next
|
||||
return collapsed
|
||||
})
|
||||
}
|
||||
|
||||
const toggleAllTurns = () => {
|
||||
setCollapsedTurnsByContext((current) => {
|
||||
const next = new Map(current)
|
||||
const collapsed = new Set(current.get(selectedContext.id) ?? EMPTY_IDS)
|
||||
setCollapsedTurns((current) => {
|
||||
const collapsed = new Set(current)
|
||||
if (allTurnsCollapsed) {
|
||||
for (const turn of collapsibleTurnIds) collapsed.delete(turn)
|
||||
} else {
|
||||
for (const turn of collapsibleTurnIds) collapsed.add(turn)
|
||||
}
|
||||
next.set(selectedContext.id, collapsed)
|
||||
return next
|
||||
return collapsed
|
||||
})
|
||||
}
|
||||
|
||||
const toggleAssistant = (index: number) => {
|
||||
setCollapsedAssistantsByContext((current) => {
|
||||
const next = new Map(current)
|
||||
const collapsed = new Set(current.get(selectedContext.id) ?? EMPTY_IDS)
|
||||
setCollapsedAssistants((current) => {
|
||||
const collapsed = new Set(current)
|
||||
if (collapsed.has(index)) collapsed.delete(index)
|
||||
else collapsed.add(index)
|
||||
next.set(selectedContext.id, collapsed)
|
||||
return next
|
||||
return collapsed
|
||||
})
|
||||
}
|
||||
|
||||
const toggleAllAssistants = () => {
|
||||
setCollapsedAssistantsByContext((current) => {
|
||||
const next = new Map(current)
|
||||
const collapsed = new Set(current.get(selectedContext.id) ?? EMPTY_IDS)
|
||||
setCollapsedAssistants((current) => {
|
||||
const collapsed = new Set(current)
|
||||
if (allAssistantsCollapsed) {
|
||||
for (const index of collapsibleAssistantIds) collapsed.delete(index)
|
||||
} else {
|
||||
for (const index of collapsibleAssistantIds) collapsed.add(index)
|
||||
}
|
||||
next.set(selectedContext.id, collapsed)
|
||||
return next
|
||||
return collapsed
|
||||
})
|
||||
}
|
||||
|
||||
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}
|
||||
@@ -267,27 +360,16 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
|
||||
allAssistantsCollapsed={allAssistantsCollapsed}
|
||||
onToggleAllAssistants={toggleAllAssistants}
|
||||
/>
|
||||
<div className={css.contextLayout}>
|
||||
{contexts.length > 1 && (
|
||||
<ContextsPanel
|
||||
contexts={contexts}
|
||||
selectedId={selectedContext.id}
|
||||
currentId={currentContext.id}
|
||||
onSelect={selectContext}
|
||||
/>
|
||||
)}
|
||||
<div className={css.ledger}>
|
||||
<TrajectoryTable
|
||||
key={selectedContext.id}
|
||||
{...selectedContext.prompt === undefined ? {} : { prompt: selectedContext.prompt }}
|
||||
requestNumbers={requestNumbers}
|
||||
turns={turns}
|
||||
collapsedTurns={collapsedTurns}
|
||||
onToggleTurn={toggleTurn}
|
||||
collapsedAssistants={collapsedAssistants}
|
||||
onToggleAssistant={toggleAssistant}
|
||||
/>
|
||||
</div>
|
||||
<div className={css.ledger}>
|
||||
<TrajectoryTable
|
||||
key={currentBranch.id}
|
||||
requestNumbers={requestNumbers}
|
||||
turns={turns}
|
||||
collapsedTurns={collapsedTurns}
|
||||
onToggleTurn={toggleTurn}
|
||||
collapsedAssistants={collapsedAssistants}
|
||||
onToggleAssistant={toggleAssistant}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
123
packages/client/ui-trajectory/src/client/context-branches.ts
Normal file
123
packages/client/ui-trajectory/src/client/context-branches.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/** Rewind-delimited trajectory branches assembled across surface rewrites. */
|
||||
|
||||
import type {
|
||||
ConversationContext, ConversationNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** One continuous context branch; compactions stay inline while rewinds start a successor branch. */
|
||||
export interface TrajectoryContextBranch {
|
||||
id: number
|
||||
contexts: readonly ConversationContext[]
|
||||
latest: ConversationContext
|
||||
nodes: readonly ConversationNode[]
|
||||
ranges: readonly TrajectoryBranchRange[]
|
||||
}
|
||||
|
||||
/** One half-open session-event range carried by a rewind branch. */
|
||||
export interface TrajectoryBranchRange {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
interface MutableBranch {
|
||||
id: number
|
||||
contexts: ConversationContext[]
|
||||
latest: ConversationContext
|
||||
nodes: Map<number, ConversationNode>
|
||||
ranges: TrajectoryBranchRange[]
|
||||
}
|
||||
|
||||
function isCompactionCheckpoint(node: ConversationNode): boolean {
|
||||
if (node.kind !== 'context') return false
|
||||
const source = node.source
|
||||
return typeof source === 'object'
|
||||
&& source !== null
|
||||
&& 'kind' in source
|
||||
&& source.kind === 'plugin'
|
||||
&& 'plugin' in source
|
||||
&& source.plugin === 'compact'
|
||||
}
|
||||
|
||||
/**
|
||||
* Join context generations across compaction/rewrite operations and split only at rewind.
|
||||
* @param contexts - Append-only context generations from the runtime fold.
|
||||
* @returns Rewind-delimited branches in creation order.
|
||||
*/
|
||||
export function deriveTrajectoryContextBranches(
|
||||
contexts: readonly ConversationContext[],
|
||||
): readonly TrajectoryContextBranch[] {
|
||||
const mutable: MutableBranch[] = []
|
||||
for (const context of contexts) {
|
||||
const startsBranch = mutable.length === 0 || context.origin === 'rewind'
|
||||
if (startsBranch) {
|
||||
const previous = mutable.at(-1)
|
||||
const originSeq = context.originSeq ?? Number.POSITIVE_INFINITY
|
||||
if (previous !== undefined) {
|
||||
const openRange = previous.ranges.at(-1)
|
||||
if (openRange === undefined) {
|
||||
throw new Error('trajectory branch must contain an open event range')
|
||||
}
|
||||
openRange.end = originSeq
|
||||
}
|
||||
const retainedCutoff = Math.max(
|
||||
Number.NEGATIVE_INFINITY,
|
||||
...context.nodes
|
||||
.filter(node => node.seq < originSeq)
|
||||
.map(node => node.seq),
|
||||
)
|
||||
const inheritedNodes = previous === undefined
|
||||
? []
|
||||
: [...previous.nodes.values()].filter(node => node.seq <= retainedCutoff)
|
||||
const inheritedRanges = previous === undefined
|
||||
? []
|
||||
: previous.ranges.flatMap((range) => {
|
||||
const end = Math.min(range.end, retainedCutoff + 1)
|
||||
return end <= range.start ? [] : [{ start: range.start, end }]
|
||||
})
|
||||
mutable.push({
|
||||
id: context.id,
|
||||
contexts: [context],
|
||||
latest: context,
|
||||
nodes: new Map(
|
||||
[...inheritedNodes, ...context.nodes.filter(node => !isCompactionCheckpoint(node))]
|
||||
.map(node => [node.seq, node]),
|
||||
),
|
||||
ranges: [
|
||||
...inheritedRanges,
|
||||
{
|
||||
start: context.originSeq ?? Number.NEGATIVE_INFINITY,
|
||||
end: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
],
|
||||
})
|
||||
continue
|
||||
}
|
||||
const branch = mutable.at(-1)
|
||||
if (branch === undefined) continue
|
||||
branch.contexts.push(context)
|
||||
branch.latest = context
|
||||
for (const node of context.nodes) {
|
||||
if (!isCompactionCheckpoint(node)) branch.nodes.set(node.seq, node)
|
||||
}
|
||||
}
|
||||
return mutable.map(branch => ({
|
||||
id: branch.id,
|
||||
contexts: branch.contexts,
|
||||
latest: branch.latest,
|
||||
nodes: [...branch.nodes.values()].sort((left, right) => left.seq - right.seq),
|
||||
ranges: branch.ranges,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether a session event belongs to one rewind branch's continuous history.
|
||||
* @param branch - Branch carrying inherited and post-rewind log ranges.
|
||||
* @param seq - Session event sequence.
|
||||
* @returns Whether the event belongs to the branch.
|
||||
*/
|
||||
export function trajectoryBranchContainsSeq(
|
||||
branch: TrajectoryContextBranch,
|
||||
seq: number,
|
||||
): boolean {
|
||||
return branch.ranges.some(range => seq >= range.start && seq < range.end)
|
||||
}
|
||||
@@ -6,7 +6,10 @@ import type {
|
||||
AssistantBlock,
|
||||
AssistantMessageNode,
|
||||
CodeSubCall,
|
||||
CompactionRequestView,
|
||||
ConversationPromptChange,
|
||||
ConversationSnapshot,
|
||||
ModelRequestView,
|
||||
ToolResultNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
@@ -32,6 +35,9 @@ export interface TrajectoryLayoutInput {
|
||||
nodes: ConversationSnapshot['nodes']
|
||||
partial: ConversationSnapshot['partial']
|
||||
runningCalls: ConversationSnapshot['runningCalls']
|
||||
compactionRequests?: readonly CompactionRequestView[]
|
||||
requestAttempts?: readonly ModelRequestView[]
|
||||
promptChanges?: readonly ConversationPromptChange[]
|
||||
callSchemas?: ConversationSnapshot['callSchemas']
|
||||
/** run_code sub-dispatches by parent callId (sub-cells nest under the parent Tool cell). */
|
||||
codeDispatches: ConversationSnapshot['codeDispatches']
|
||||
@@ -62,13 +68,46 @@ interface TurnBucket {
|
||||
groups: LaidGroup[]
|
||||
}
|
||||
|
||||
type OrderedLayoutEntry =
|
||||
| {
|
||||
kind: 'node'
|
||||
seq: number
|
||||
node: ConversationSnapshot['nodes'][number]
|
||||
nodeIndex: number
|
||||
}
|
||||
| {
|
||||
kind: 'compaction'
|
||||
seq: number
|
||||
request: CompactionRequestView
|
||||
}
|
||||
| {
|
||||
kind: 'system'
|
||||
seq: number
|
||||
change: ConversationPromptChange
|
||||
}
|
||||
| {
|
||||
kind: 'request'
|
||||
seq: number
|
||||
request: ModelRequestView
|
||||
}
|
||||
|
||||
function layoutEntryOrder(entry: OrderedLayoutEntry): number {
|
||||
return entry.kind === 'system' && entry.change.kind === 'initial'
|
||||
? Number.NEGATIVE_INFINITY
|
||||
: entry.seq
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a snapshot into turn → Message/Step groups with expanded cells.
|
||||
* @param input - nodes plus in-flight partial/runningCalls.
|
||||
* @returns turns ordered by first appearance.
|
||||
*/
|
||||
export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly TrajectoryTurnModel[] {
|
||||
const { nodes, partial, runningCalls, callSchemas, codeDispatches } = input
|
||||
const {
|
||||
nodes, partial, runningCalls, compactionRequests = [], requestAttempts = [],
|
||||
promptChanges = [],
|
||||
callSchemas, codeDispatches,
|
||||
} = input
|
||||
const resultByCall = indexResults(nodes)
|
||||
const callStartById = new Map<string, number>()
|
||||
for (const result of resultByCall.values()) {
|
||||
@@ -114,10 +153,136 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
groups.push({ title, laid: [...laid] })
|
||||
}
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i]
|
||||
/* v8 ignore next -- dense-array guard: i stays within nodes.length, so the undefined arm needs a sparse array no caller builds. */
|
||||
if (node === undefined) continue
|
||||
const representedRequests = new Set<string>()
|
||||
for (const node of nodes) {
|
||||
if (node.kind === 'assistant' && node.step > 0) {
|
||||
representedRequests.add(`${node.turn}\u0000${node.step}`)
|
||||
}
|
||||
}
|
||||
if (partial !== null && partial.step > 0) {
|
||||
representedRequests.add(`${partial.turn}\u0000${partial.step}`)
|
||||
}
|
||||
for (const call of runningCalls) {
|
||||
if (call.step > 0) representedRequests.add(`${call.turn}\u0000${call.step}`)
|
||||
}
|
||||
|
||||
const entries: OrderedLayoutEntry[] = [
|
||||
...nodes.map((node, nodeIndex) => ({
|
||||
kind: 'node' as const,
|
||||
seq: node.seq,
|
||||
node,
|
||||
nodeIndex,
|
||||
})),
|
||||
...compactionRequests.map(request => ({
|
||||
kind: 'compaction' as const,
|
||||
seq: request.startSeq,
|
||||
request,
|
||||
})),
|
||||
...promptChanges.map(change => ({
|
||||
kind: 'system' as const,
|
||||
seq: change.seq,
|
||||
change,
|
||||
})),
|
||||
...requestAttempts
|
||||
.filter(request =>
|
||||
!representedRequests.has(`${request.turn}\u0000${request.step}`),
|
||||
)
|
||||
.map(request => ({
|
||||
kind: 'request' as const,
|
||||
seq: request.startSeq,
|
||||
request,
|
||||
})),
|
||||
].sort((left, right) => layoutEntryOrder(left) - layoutEntryOrder(right))
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.kind === 'request') {
|
||||
const { request } = entry
|
||||
pushStep(request.turn, request.step, [{
|
||||
absTime: finiteTime(request.startedAt),
|
||||
cell: {
|
||||
index: ++index,
|
||||
kind: 'message',
|
||||
text: '',
|
||||
sourceSeq: request.startSeq,
|
||||
requestOnly: true,
|
||||
timeSeconds: request.completedAt === null
|
||||
? null
|
||||
: durationSeconds(request.completedAt, request.startedAt),
|
||||
startedAt: finiteTime(request.startedAt),
|
||||
...(request.status === 'error' ? { isError: true } : {}),
|
||||
},
|
||||
}])
|
||||
prevAbsTime = finiteTime(request.completedAt)
|
||||
?? finiteTime(request.startedAt)
|
||||
?? prevAbsTime
|
||||
continue
|
||||
}
|
||||
if (entry.kind === 'system') {
|
||||
const { change } = entry
|
||||
const turn = enclosingPromptTurn(nodes, change.seq, partial)
|
||||
pushMessage(turn, {
|
||||
absTime: finiteTime(change.time),
|
||||
cell: {
|
||||
index: ++index,
|
||||
kind: 'system',
|
||||
text: promptChangeLabel(change),
|
||||
sourceSeq: change.seq,
|
||||
promptDetail: change.prompt,
|
||||
...(change.previous === undefined
|
||||
? {}
|
||||
: { previousPromptDetail: change.previous }),
|
||||
timeSeconds: 0,
|
||||
startedAt: finiteTime(change.time),
|
||||
},
|
||||
})
|
||||
prevAbsTime = finiteTime(change.time) ?? prevAbsTime
|
||||
continue
|
||||
}
|
||||
if (entry.kind === 'compaction') {
|
||||
const request = entry.request
|
||||
const rawOutput = request.rawOutput ?? request.summary
|
||||
const thinkingDetail = rawOutput === undefined
|
||||
? ''
|
||||
: detailReasoning(rawOutput)
|
||||
const cell: TrajectoryCellProps = {
|
||||
index: ++index,
|
||||
kind: 'compacted',
|
||||
text: request.status === 'running'
|
||||
? 'Compacting context…'
|
||||
: request.status === 'error'
|
||||
? request.error ?? 'Compaction failed'
|
||||
: request.summary === undefined
|
||||
? 'Context compacted'
|
||||
: summarizeContent(request.summary),
|
||||
sourceSeq: request.startSeq,
|
||||
...(request.summary === undefined
|
||||
? {}
|
||||
: {
|
||||
outputDetail: detailContent(request.summary),
|
||||
outputBlocks: request.summary.map(block => sourceBlock(block)),
|
||||
}),
|
||||
...(thinkingDetail === '' ? {} : { thinkingDetail }),
|
||||
...(rawOutput === undefined
|
||||
? {}
|
||||
: { sourceBlocks: rawOutput.map(block => sourceBlock(block)) }),
|
||||
...(request.status === 'error' ? { isError: true } : {}),
|
||||
timeSeconds: request.completedAt === null
|
||||
? null
|
||||
: durationSeconds(request.completedAt, request.startedAt),
|
||||
startedAt: finiteTime(request.startedAt),
|
||||
}
|
||||
attachUsage(cell, request.usage as UsageLike | undefined)
|
||||
bucket(request.turn).groups.push({
|
||||
title: `Compaction ${request.startSeq}`,
|
||||
laid: [{
|
||||
absTime: finiteTime(request.startedAt),
|
||||
cell,
|
||||
}],
|
||||
})
|
||||
prevAbsTime = finiteTime(request.completedAt) ?? finiteTime(request.startedAt) ?? prevAbsTime
|
||||
continue
|
||||
}
|
||||
const { node, nodeIndex: i } = entry
|
||||
if (node.kind === 'user' || node.kind === 'steering') {
|
||||
// user/message has no turn on the wire; enclose it in the next assistant
|
||||
// (or partial) turn, else open the turn after the last assistant.
|
||||
@@ -128,6 +293,9 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
absTime: finiteTime(node.time),
|
||||
cell: {
|
||||
index: ++index, kind: 'user', text: summarizeContent(node.content),
|
||||
sourceSeq: node.seq,
|
||||
messageSource: node.source,
|
||||
...(node.meta === undefined ? {} : { messageMeta: node.meta }),
|
||||
opensTurn: node.kind === 'user',
|
||||
inputDetail: detailContent(node.content),
|
||||
sourceBlocks: node.content.map(block => sourceBlock(block)),
|
||||
@@ -159,6 +327,9 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
index: ++index,
|
||||
kind: 'context',
|
||||
text: summarizeContent(node.content),
|
||||
sourceSeq: node.seq,
|
||||
messageSource: node.source,
|
||||
...(node.meta === undefined ? {} : { messageMeta: node.meta }),
|
||||
inputDetail: detailContent(node.content),
|
||||
sourceBlocks: node.content.map(block => sourceBlock(block)),
|
||||
timeSeconds: 0,
|
||||
@@ -178,6 +349,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
cell: {
|
||||
index: ++index,
|
||||
kind: 'tool',
|
||||
sourceSeq: node.seq,
|
||||
text: node.call !== null
|
||||
? summarizeCall(node.call.name, node.call.argsRaw)
|
||||
: summarizeResult(node),
|
||||
@@ -241,7 +413,8 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
laidList.push(laid)
|
||||
index = laid.cell.index
|
||||
}
|
||||
pushStep(call.turn, call.step > 0 ? call.step : 1, laidList)
|
||||
if (call.step > 0) pushStep(call.turn, call.step, laidList)
|
||||
else for (const laid of laidList) pushMessage(call.turn, laid)
|
||||
}
|
||||
|
||||
// Orphan turn-0 cells (orphaned tools / steering turn 0) fold into Turn 1.
|
||||
@@ -365,6 +538,7 @@ function expandAssistant(
|
||||
const message: TrajectoryCellProps = {
|
||||
index: ++index,
|
||||
kind: 'message',
|
||||
sourceSeq: node.seq,
|
||||
text: messageText !== ''
|
||||
? summarizeText(messageText)
|
||||
: thinkingText !== ''
|
||||
@@ -432,6 +606,13 @@ function summarizeAssistantActivity(blocks: readonly AssistantBlock[]): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
function promptChangeLabel(change: ConversationPromptChange): string {
|
||||
if (change.kind === 'initial') return 'Initial System Prompt'
|
||||
if (change.kind === 'system') return 'System Prompt Updated'
|
||||
if (change.kind === 'tools') return 'Tools Updated'
|
||||
return 'System Prompt and Tools Updated'
|
||||
}
|
||||
|
||||
function assistantSourceBlock(block: AssistantBlock): TrajectorySourceBlock {
|
||||
switch (block.kind) {
|
||||
case 'text': return { type: 'text', content: block.text }
|
||||
@@ -524,6 +705,17 @@ function enclosingUserTurn(
|
||||
return 1
|
||||
}
|
||||
|
||||
function enclosingPromptTurn(
|
||||
nodes: ConversationSnapshot['nodes'],
|
||||
seq: number,
|
||||
partial: ConversationSnapshot['partial'],
|
||||
): number {
|
||||
const next = nodes.find(node =>
|
||||
node.seq > seq && node.kind === 'assistant' && node.step > 0)
|
||||
if (next?.kind === 'assistant') return next.turn
|
||||
return partial?.turn ?? 1
|
||||
}
|
||||
|
||||
/** Copy provider usage onto a Message cell when present. */
|
||||
function attachUsage(cell: TrajectoryCellProps, usage: UsageLike | undefined): void {
|
||||
if (usage === undefined) return
|
||||
@@ -641,7 +833,7 @@ function summarizeResult(node: ToolResultNode): string {
|
||||
return summarizeText(block.text)
|
||||
}
|
||||
}
|
||||
return node.call?.name ?? node.callId
|
||||
return 'No output'
|
||||
}
|
||||
|
||||
function detailResult(node: ToolResultNode): string {
|
||||
@@ -655,6 +847,11 @@ function detailResult(node: ToolResultNode): string {
|
||||
.map(block => block.type === 'text' ? block.text : '')
|
||||
.join('\n')
|
||||
if (text !== '') return text
|
||||
if (
|
||||
node.content.length === 0
|
||||
|| node.content.every(block =>
|
||||
block.type === 'text' && (typeof block.text !== 'string' || block.text === ''))
|
||||
) return 'No output'
|
||||
return JSON.stringify(node.content, null, 2)
|
||||
}
|
||||
|
||||
@@ -665,6 +862,13 @@ function detailContent(content: readonly { type: string; text?: string }[]): str
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function detailReasoning(content: readonly { type: string; text?: string }[]): string {
|
||||
return content
|
||||
.filter(block => block.type === 'reasoning' && typeof block.text === 'string')
|
||||
.map(block => block.text ?? '')
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function summarizeContent(content: readonly { type: string; text?: string }[]): string {
|
||||
for (const block of content) {
|
||||
if (block.type === 'text' && typeof block.text === 'string') return summarizeText(block.text)
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
/** Shared trajectory record data and formatting contracts. */
|
||||
|
||||
import type { HTMLAttributes } from 'react'
|
||||
import type { ConversationPromptSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** Closed set of trajectory record kinds. */
|
||||
export type TrajectoryCellKind = 'user' | 'context' | 'message' | 'tool' | 'subtool'
|
||||
export type TrajectoryCellKind =
|
||||
| 'system'
|
||||
| 'user'
|
||||
| 'context'
|
||||
| 'compacted'
|
||||
| 'message'
|
||||
| 'tool'
|
||||
| 'subtool'
|
||||
|
||||
/** Recorded inputs needed to derive assistant TTFT and decode throughput. */
|
||||
export interface AssistantMetricDetail {
|
||||
@@ -34,8 +42,20 @@ export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
|
||||
text: string
|
||||
/** Whether this user record opens a new model turn. */
|
||||
opensTurn?: boolean
|
||||
/** Source session-event seq for cross-record navigation. */
|
||||
sourceSeq?: number
|
||||
/** Producer provenance from a user-role message or context injection. */
|
||||
messageSource?: unknown
|
||||
/** Producer-owned model-hidden metadata carried beside the message source. */
|
||||
messageMeta?: unknown
|
||||
/** A separator-only anchor for an auxiliary request with no visible record. */
|
||||
requestOnly?: boolean
|
||||
/** Full request/message content for the details panel. */
|
||||
inputDetail?: string
|
||||
/** Complete system-prompt/tool-catalog state introduced by a SYSTEM record. */
|
||||
promptDetail?: ConversationPromptSnapshot
|
||||
/** System-prompt/tool-catalog state replaced by a SYSTEM update. */
|
||||
previousPromptDetail?: ConversationPromptSnapshot
|
||||
/** Full assistant/tool result content for the details panel. */
|
||||
outputDetail?: string
|
||||
/** Full assistant reasoning content for the details panel. */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* Full-bleed, fixed-height host for the trajectory ledger and waterfall. */
|
||||
.root {
|
||||
--dsh-trajectory-toolbar-height: 48px;
|
||||
--dsh-trajectory-toolbar-height: 40px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -23,14 +23,6 @@
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
.contextLayout {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ledger {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
|
||||
Reference in New Issue
Block a user