Merge pull request #977 from deepseek-harness/xtr/trajectory-timeline-click-focus

feat(ui): refine trajectory navigation and streaming performance
This commit is contained in:
Tianyi Cui
2026-07-31 01:05:38 +08:00
committed by GitHub
24 changed files with 1082 additions and 128 deletions
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.md
2026-07-30-sidebar-resize-without-visible-pill.md: cc41898990fa23ff2937140186a8324217911d2e
2026-07-30-sidebar-resize-without-visible-pill.zh.md: 9f1f521df2848b15f5015719bfa6f0e0e9b7be0c
@@ -0,0 +1,25 @@
# Agent Note: Sidebar resize without a visible pill
Status: implemented
English | [中文](2026-07-30-sidebar-resize-without-visible-pill.zh.md)
## Problem
The AppFrame exposed identical floating pills on both column borders. The left pill added unnecessary visual weight beside primary navigation, but the sidebar's resize interaction remains useful.
## Decision
AppFrame keeps the sidebar's 8px resize hit strip, `col-resize` cursor, pointer capture, animation-frame throttling, and width updates, but does not generate the sidebar handle's pill pseudo-element. The details boundary retains both its hit strip and floating pill.
The layout component test continues to pin sidebar dragging and both handles' collapse lifecycle. A keyless browser scenario reads the generated pseudo-elements from the shipped composition and drags the invisible sidebar boundary to prove the interaction remains live.
## Alternatives considered
**Remove the sidebar drag interaction with the pill.** Rejected because the requested change is visual; removing a working geometry control would unnecessarily narrow the interaction.
**Keep the pill but reduce its emphasis.** A smaller or lower-contrast pill still leaves an unwanted object on the sidebar boundary.
## Consequences
The sidebar boundary is visually quiet while pointer resizing remains available from the boundary and retains the resize cursor. Unlike the details control, that interaction has no visible pill.
@@ -0,0 +1,25 @@
# Agent Note: 侧边栏缩放不显示胶囊
Status: implemented
[English](2026-07-30-sidebar-resize-without-visible-pill.md) | 中文
## 问题
AppFrame 在两个栏位边界都显示相同的浮动胶囊。左侧胶囊在主导航旁增加了不必要的视觉负担,但侧边栏的缩放交互仍有用。
## 决策
AppFrame 保留侧边栏宽 8px 的缩放命中条带、`col-resize` 光标、指针捕获、动画帧节流和宽度更新,但不再生成侧边栏手柄的胶囊形伪元素。详情栏边界同时保留命中条带和浮动胶囊。
布局组件测试继续固定侧边栏拖动行为,以及两个手柄随面板折叠时的生命周期。一个无密钥浏览器场景读取实际交付组合所生成的伪元素,并拖动不可见的侧边栏边界,证明该交互仍然有效。
## 曾考虑的替代方案
**随胶囊一并移除侧边栏拖动交互。** 不予采纳,因为本次要求只改视觉表现;移除正常工作的几何控制会不必要地缩减交互方式。
**保留胶囊,但降低其视觉强调。** 更小或对比度更低的胶囊仍会在侧边栏边界留下一个不需要的物体。
## 后果
侧边栏边界在视觉上保持简洁,同时仍可在边界处通过指针调整宽度,并保留缩放光标。与详情栏控件不同,该交互没有可见胶囊。
@@ -3,15 +3,19 @@
// unselected states, and closes it only when a different Session takes ownership.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import {
acknowledgeReloadConnectionLoss, fixtureUserPrompts, launchWebScaffold, seedSession, watchConsole,
webSnapshotMode, type WebScaffold,
acknowledgeReloadConnectionLoss, assertFixtureInventory, compareOrRefreshGolden,
fixtureUserPrompts, launchWebScaffold, seedSession, watchConsole, webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/details-session-lifecycle', import.meta.url))
const HANDLES_EXPECTED = join(SNAPSHOT_DIR, 'handles.expected.md')
const FIXTURE = fileURLToPath(new URL('./snapshots/lifecycle-chrome/session.jsonl', import.meta.url))
const SEED_FIXTURE = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
@@ -25,11 +29,41 @@ async function detailsTrack(page: Page): Promise<number> {
})
}
/** First AppFrame grid track in CSS pixels. */
async function sidebarTrack(page: Page): Promise<number> {
return await appFrame(page).evaluate((element) => {
const tracks = getComputedStyle(element).gridTemplateColumns.split(' ')
return Number.parseFloat(tracks[0] ?? 'NaN')
})
}
/** AppFrame is the only product element with an inline grid track template. */
function appFrame(page: Page) {
return page.locator('[style*="grid-template-columns"]').first()
}
/** Render the two boundary affordances without platform-dependent coordinates. */
async function handleSnapshot(page: Page): Promise<string> {
const handles = await page.locator('[class*="handle"]').evaluateAll(elements =>
elements.map(element => ({
side: element.getAttribute('data-side'),
cursor: getComputedStyle(element).cursor,
pillGenerated: getComputedStyle(element, '::after').content !== 'none',
})))
return [
'# AppFrame drag handles',
'',
...handles.flatMap(handle => [
`## ${handle.side}`,
'',
'- hit strip present: true',
`- cursor: ${handle.cursor}`,
`- pill generated: ${String(handle.pillGenerated)}`,
'',
]),
].join('\n').trimEnd()
}
describe.skipIf(MODE === 'record')('web e2e: details panel follows the current Session lifecycle', () => {
let scaffold: WebScaffold
let browser: Browser
@@ -65,6 +99,18 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0)
expect(await page.getByText('详情', { exact: true }).isVisible()).toBe(false)
await compareOrRefreshGolden(HANDLES_EXPECTED, await handleSnapshot(page), MODE)
const sidebarBefore = await sidebarTrack(page)
const sidebarHandle = page.locator('[data-side="sidebar"]')
const sidebarBox = await sidebarHandle.boundingBox()
expect(sidebarBox).not.toBeNull()
const dragStartX = sidebarBox!.x + sidebarBox!.width / 2
await page.mouse.move(dragStartX, sidebarBox!.y + 200)
await page.mouse.down()
await page.mouse.move(dragStartX + 70, sidebarBox!.y + 200, { steps: 6 })
await page.mouse.up()
await expect.poll(() => sidebarTrack(page), { timeout: 5_000 }).toBe(sidebarBefore + 70)
const warningStart = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
@@ -101,5 +147,6 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['handles.expected.md'])
}, 90_000)
})
@@ -0,0 +1,7 @@
# AppFrame drag handles
## sidebar
- hit strip present: true
- cursor: col-resize
- pill generated: false
@@ -11,7 +11,7 @@
- cell "SYSTEM"
- cell "Initial System Prompt"
- 'row "USER, NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop."':
- cell "Turn 1 USER"
- cell "Turn 1 USER": USER
- 'cell "NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop."'
- 'row "Request 1, ASSISTANT, The user wants me to follow a specific navigation scenario. Let me: Run bash to print \"NAVIGATION_OK\" Read nav-a.md and nav-b.md in two read calls in ONE message Reply with \"FIRST_DONE\" Let me start with the bash command and the reads."':
- 'cell "Request #1 ASSISTANT"':
@@ -33,7 +33,7 @@
- text: ASSISTANT
- cell "FIRST_DONE"
- 'row "USER, Reply in markdown with: a level-2 heading \"Navigation Summary\", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop."':
- cell "Turn 2 USER"
- cell "Turn 2 USER": USER
- 'cell "Reply in markdown with: a level-2 heading \"Navigation Summary\", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop."'
- row "Request 3, ASSISTANT, Navigation Summary alpha nav beta nav echo WATERFALL":
- 'cell "Request #3 ASSISTANT"':
@@ -1,12 +1,14 @@
import type {
HistoryEntry, IApiClient, MuxFrame, RpcError, SessionId,
} from '@deepseek-ai/dsh-client-connection/client'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import type {
SessionHistoryFace, SessionHistorySnapshot,
} from '../contract/session-history.ts'
import { createHistoryInspection } from '../sessions/history.ts'
import { Notifier } from '../sessions/notifier.ts'
import { PartialAccumulator } from '../sessions/partial.ts'
const HISTORY_PAGE_MESSAGES = 50
@@ -33,6 +35,9 @@ export class SessionHistorySource implements SessionHistoryFace {
entries: readonly HistoryEntry[]
value: SessionHistorySnapshot['inspection']
} | null = null
private streamPublishToken: object | null = null
private streamBaseInspection: SessionHistorySnapshot['inspection'] | null = null
private streamPartial: PartialAccumulator | null = null
private snapshotCache: SessionHistorySnapshot
private readonly notifier = new Notifier(() => {
this.snapshotCache = this.buildSnapshot()
@@ -125,7 +130,7 @@ export class SessionHistorySource implements SessionHistoryFace {
if (this.state !== 'cold') {
this.state = 'cold'
this.error = null
this.notifier.markDirty()
this.publishDirtyNow()
}
}
@@ -143,7 +148,7 @@ export class SessionHistorySource implements SessionHistoryFace {
this.hasMore = false
this.state = 'cold'
this.error = null
this.notifier.markDirty()
this.publishDirtyNow()
void this.loadForConsumers()
}
@@ -155,6 +160,9 @@ export class SessionHistorySource implements SessionHistoryFace {
this.openPromise = null
this.olderPromise = null
this.liveBuffer = []
this.streamPublishToken = null
this.streamBaseInspection = null
this.streamPartial = null
}
private open(): Promise<void> {
@@ -188,7 +196,7 @@ export class SessionHistorySource implements SessionHistoryFace {
private async doOpen(generation: number): Promise<void> {
this.state = 'loading'
this.error = null
this.notifier.markDirty()
this.publishDirtyNow()
try {
let { result } = await this.api.sessions.history({
sessionId: this.sessionId,
@@ -222,7 +230,7 @@ export class SessionHistorySource implements SessionHistoryFace {
/* v8 ignore next -- transportError always returns the error branch. */
this.error = folded.ok ? null : folded.error
} finally {
if (generation === this.generation) this.notifier.markDirty()
if (generation === this.generation) this.publishDirtyNow()
}
}
@@ -261,7 +269,7 @@ export class SessionHistorySource implements SessionHistoryFace {
const settled = operation.finally(() => {
if (this.olderPromise !== settled) return
this.olderPromise = null
this.notifier.markDirty()
this.publishDirtyNow()
})
this.olderPromise = settled
return settled
@@ -286,7 +294,7 @@ export class SessionHistorySource implements SessionHistoryFace {
const buffered = this.liveBuffer
this.liveBuffer = []
for (const entry of buffered) this.appendLive(entry)
this.notifier.markDirty()
this.publishDirtyNow()
}
private acceptLive(entry: HistoryEntry): void {
@@ -301,8 +309,16 @@ export class SessionHistorySource implements SessionHistoryFace {
void this.repairGap()
return
}
if (
entry.event.type === 'assistant/chunk'
&& entry.event.data.chunk.type !== 'usage'
) {
if (!this.appendIncrementalChunk(entry, entry.event)) return
this.publishStreamDirty()
return
}
this.appendLive(entry)
this.notifier.markDirty()
this.publishDirtyNow()
}
private appendLive(entry: HistoryEntry): void {
@@ -311,6 +327,66 @@ export class SessionHistorySource implements SessionHistoryFace {
this.entries = [...this.entries, entry]
}
/** Append a chunk against the cached finalized projection; false means no visible publish. */
private appendIncrementalChunk(
entry: HistoryEntry,
event: SessionEvent<'assistant/chunk'>,
): boolean {
const { turn, step, chunk } = event.data
if (!isVisibleAssistantChunk(chunk.type)) {
const inspection = this.currentInspection()
this.appendLive(entry)
this.inspectionCache = { entries: this.entries, value: inspection }
return false
}
const base = this.streamBaseInspection ?? this.currentInspection()
this.streamBaseInspection = base
if (
this.streamPartial === null
|| this.streamPartial.turn !== turn
|| this.streamPartial.step !== step
) {
const current = base.partial
this.streamPartial = new PartialAccumulator(
turn,
step,
current?.turn === turn && current.step === step ? current.blocks : [],
)
}
this.streamPartial.push(chunk)
this.appendLive(entry)
this.inspectionCache = {
entries: this.entries,
value: { ...base, partial: this.streamPartial.toPartial() },
}
return true
}
/** Coalesce token-stream projection and rendering work to one publish per browser frame. */
private publishStreamDirty(): void {
if (this.streamPublishToken !== null) return
const token = {}
this.streamPublishToken = token
const publish = () => {
if (this.streamPublishToken !== token) return
this.streamPublishToken = null
this.notifier.markDirty()
}
if (typeof globalThis.requestAnimationFrame === 'function') {
globalThis.requestAnimationFrame(publish)
} else {
queueMicrotask(publish)
}
}
/** Publish structural changes immediately and invalidate an older scheduled stream publish. */
private publishDirtyNow(): void {
this.streamPublishToken = null
this.streamBaseInspection = null
this.streamPartial = null
this.notifier.markDirty()
}
private async repairGap(): Promise<void> {
if (this.stitching) return
this.stitching = true
@@ -335,6 +411,16 @@ export class SessionHistorySource implements SessionHistoryFace {
}
private buildSnapshot(): SessionHistorySnapshot {
return {
state: this.state,
error: this.error,
hasMore: this.hasMore,
inspection: this.currentInspection(),
}
}
/** Inspection pinned to the source's current immutable entry array. */
private currentInspection(): SessionHistorySnapshot['inspection'] {
if (this.inspectionCache?.entries !== this.entries) {
const entries = this.entries
this.inspectionCache = {
@@ -342,11 +428,14 @@ export class SessionHistorySource implements SessionHistoryFace {
value: createHistoryInspection(() => entries),
}
}
return {
state: this.state,
error: this.error,
hasMore: this.hasMore,
inspection: this.inspectionCache.value,
}
return this.inspectionCache.value
}
}
function isVisibleAssistantChunk(type: string): boolean {
return type === 'block-start'
|| type === 'text-delta'
|| type === 'reasoning-delta'
|| type === 'tool-call-delta'
|| type === 'block-end'
}
@@ -13,8 +13,18 @@ export class PartialAccumulator {
private changed = true
private snapshot: PartialAssistant
constructor(readonly turn: number, readonly step: number) {
this.snapshot = { turn, step, blocks: [] }
/**
* @param turn - Owning agent turn.
* @param step - Owning model step.
* @param initialBlocks - Materialized prefix when accumulation begins after history replay.
*/
constructor(
readonly turn: number,
readonly step: number,
initialBlocks: readonly AssistantBlock[] = [],
) {
this.blocks = [...initialBlocks]
this.snapshot = { turn, step, blocks: initialBlocks }
}
/**
@@ -41,6 +41,12 @@ describe('PartialAccumulator', () => {
expect(acc.toPartial().blocks).toEqual([{ kind: 'reasoning', text: '思考' }])
})
it('continues from a materialized history prefix', () => {
const acc = new PartialAccumulator(1, 0, [{ kind: 'text', text: '已有' }])
acc.push(chunk({ type: 'text-delta', index: 0, text: '增量' }))
expect(acc.toPartial().blocks).toEqual([{ kind: 'text', text: '已有增量' }])
})
it('folds tool-call deltas: first id pins callId, late name overrides, argsRaw concatenates', () => {
const acc = new PartialAccumulator(1, 0)
acc.push(chunk({ type: 'tool-call-delta', index: 0, id: 'c1', argumentsDelta: '{"a"' }))
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { SessionHistorySource } from '../src/client/session-history/source.ts'
@@ -7,6 +7,10 @@ import { entries, ev, plainTurn } from './event-script.ts'
const SID = 'history-s1' as SessionId
afterEach(() => {
vi.unstubAllGlobals()
})
function histResponse(events: SessionEvent[], hasMore = false) {
return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
}
@@ -52,6 +56,71 @@ describe('SessionHistorySource', () => {
.toEqual([1, 3, 6])
})
it('publishes multiple assistant chunks once per browser frame', async () => {
const api = new FakeApiClient()
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
const source = new SessionHistorySource(SID, api)
await source.loadAll()
const frames: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
frames.push(callback)
return frames.length
})
let notifications = 0
const unsubscribe = source.subscribe(() => { notifications++ })
const before = source.getSnapshot().inspection
const finalizedNodes = before.eventNodes
const requests = before.requests
const contexts = before.contexts
for (const event of [
ev.chunkStart(6, 1),
ev.chunkText(7, 1, 'stream '),
ev.chunkText(8, 1, 'content'),
]) {
source.handleMuxFrame({
type: 'session/event',
sessionId: SID,
event,
})
}
expect(frames).toHaveLength(1)
expect(notifications).toBe(0)
frames[0]?.(0)
await Promise.resolve()
expect(notifications).toBe(1)
const streamed = source.getSnapshot().inspection
expect(streamed.eventNodes).toBe(finalizedNodes)
expect(streamed.requests).toBe(requests)
expect(streamed.contexts).toBe(contexts)
expect(streamed.partial?.blocks).toEqual([
{ kind: 'text', text: 'stream content' },
])
source.handleMuxFrame({
type: 'session/event',
sessionId: SID,
event: ev.chunkText(9, 1, ' then final'),
})
source.handleMuxFrame({
type: 'session/event',
sessionId: SID,
event: ev.assistant(10, 1, 'stream content then final'),
})
await Promise.resolve()
expect(notifications).toBe(2)
const finalized = source.getSnapshot().inspection
expect(finalized.eventNodes).not.toBe(finalizedNodes)
expect(finalized.partial).toBeNull()
frames[1]?.(0)
await Promise.resolve()
expect(notifications).toBe(2)
unsubscribe()
})
it('stops loading when an older page fails to advance', async () => {
const api = new FakeApiClient()
api.onHistory = payload => payload.beforeSeq === undefined
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-layout/README.md
README.md: 0e92958c9b088071ab58f7e87e8af68f6c2df68d
README.zh.md: 0fb3b1cd85bbf6e070a2a1dd01b25981e5990df0
README.md: cb99023e6a9e3364c6f48190cf4a0cd71da2cbba
README.zh.md: 3681b4517670eb92d8f32be2ac62d5852ac745a3
+1 -1
View File
@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar is fixed-width (only details shrinks, then auto-closes); a closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto the document (`html { color-scheme }` for native UA chrome, `body[data-ds-dark-theme]` from the active color scheme, plus the theme's alias tokens as inline variables on body).
Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar resize boundary is an invisible hit strip, while the details boundary retains its floating pill; only details shrinks during concession and then auto-closes. A closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto the document (`html { color-scheme }` for native UA chrome, `body[data-ds-dark-theme]` from the active color scheme, plus the theme's alias tokens as inline variables on body).
AppFrame always mounts the conversation and details columns; a connected Session renders through `SessionProvider`. The transient layout store starts the sidebar at its default width and details closed, and it never reads or writes `localStorage`. Hero and other unselected states also derive a zero rendered details width without changing that stored preference. AppFrame retains the last non-blank Session id across those states: the first Session remains closed, an explicit details action opens the contract default width, returning to the same Session restores its unchanged width, and selecting a different Session closes details before paint. The conversation owner share is empty, while the sidebar owner share contains only `collapsed` and `width`; registrants obtain business data from standard hooks and actions from their own inject faces.
+1 -1
View File
@@ -2,7 +2,7 @@
[English](README.md) | 中文
外壳插件:三栏 AppFrame(拖动手柄与让步链)加 `ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot,并声明 `sidebar``conversation``details``conversation.empty`。侧边栏宽度固定(只会收缩详情栏,然后将其自动关闭);关闭的侧边栏仍保留 56px 控制轨道,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 document(用 `html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为 body 上的内联变量)。
外壳插件:三栏 AppFrame(拖动手柄与让步链)加 `ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot,并声明 `sidebar``conversation``details``conversation.empty`。侧边栏的缩放边界是不可见命中条带,详情栏边界则保留其浮动胶囊;让步期间只有详情栏会收缩并随后自动关闭关闭的侧边栏仍保留 56px 控制轨道,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 document(用 `html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为 body 上的内联变量)。
AppFrame 始终挂载会话栏和详情栏;已连接 Session 通过 `SessionProvider` 渲染。布局 store 是瞬时状态,侧边栏以默认宽度启动,详情栏则保持关闭,且该 store 从不读写 `localStorage`。hero 和其他未选中状态也会将详情栏的渲染宽度派生为零,但不会改变存储的首选宽度。AppFrame 会跨越这些状态保留最后一个非 blank 会话 id:首个会话保持关闭;显式打开详情栏的操作会使用契约默认宽度;返回同一会话时恢复其未改变的宽度;选择不同会话时,详情栏会在绘制前关闭。会话 owner share 为空,侧边栏 owner share 只包含 `collapsed``width`;注册方通过标准钩子获取业务数据,并从各自的 inject 表层获取操作。
@@ -49,9 +49,8 @@
}
/* 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. Details
adds a visible 12x32 pill at vertical center; sidebar keeps only the hit strip. */
.handle {
position: absolute;
top: 0;
@@ -76,7 +75,7 @@
}
}
.handle::after {
.handle[data-side='details']::after {
content: '';
position: absolute;
top: 50%;
@@ -88,23 +87,22 @@
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. */
/* Hover affordance: the details pill hides until the pointer is over its
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 {
.handle[data-side='details']:hover::after,
.handle[data-side='details'][data-dragging='true']::after {
opacity: 1;
}
.handle:hover::after,
.handle[data-dragging='true']::after {
.handle[data-side='details']:hover::after,
.handle[data-side='details'][data-dragging='true']::after {
background: var(--dsw-alias-button-floating-hover);
border-color: var(--dsw-alias-border-l3);
}
@@ -16,6 +16,7 @@
flex: 1;
min-width: 0;
overflow: auto;
container: trajectory-table / inline-size;
}
.table {
@@ -235,6 +236,18 @@
width: 3px;
}
.table tbody tr[data-error='true'] .turnRail {
background: color-mix(
in srgb,
var(--dsw-alias-state-error-primary) 22%,
var(--dsw-alias-bg-layer-1)
);
}
.table tbody tr[data-error='true'] .selectionRail {
background: var(--dsw-alias-state-error-primary);
}
.table tbody tr[data-turn-start='true'] td {
position: relative;
overflow: visible;
@@ -279,6 +292,10 @@
white-space: nowrap;
}
.turnLabelCompact {
display: none;
}
.turnLabelActive {
color: color-mix(
in srgb,
@@ -325,11 +342,66 @@
user-select: none;
}
.kindTagIcon {
display: none;
align-items: center;
justify-content: center;
width: 13px;
height: 13px;
}
.kindTagLabel {
display: inline;
}
.table .kindSlot .message {
justify-content: center;
width: 100%;
}
@container trajectory-table (max-width: 620px) {
.eventColumn {
width: 50px;
}
.event {
padding-right: 3px !important;
padding-left: 28px !important;
}
.requestBoundaryControl {
left: 6px;
}
.kindSlot {
width: 19px;
}
.kindTag,
.table .kindSlot .message {
justify-content: center;
width: 19px;
padding-right: 0;
padding-left: 0;
}
.kindTagIcon {
display: inline-flex;
}
.kindTagLabel {
display: none;
}
.turnLabelFull {
display: none;
}
.turnLabelCompact {
display: inline;
}
}
.user {
color: var(--dsw-alias-state-business-primary);
background: var(--dsw-alias-state-business-tertiary);
@@ -576,6 +648,28 @@
color: var(--dsw-alias-state-error-primary);
}
.overview dd.error {
color: var(--dsw-alias-state-error-primary);
}
.details .errorPayload {
color: var(--dsw-alias-state-error-primary);
}
.details .errorPayload .resultBlockText {
color: inherit;
}
.details .jsonPayload.errorPayload,
.details .jsonPreview.errorPayload {
--json-tree-property: var(--dsw-alias-state-error-primary);
--json-tree-string: var(--dsw-alias-state-error-primary);
--json-tree-number: var(--dsw-alias-state-error-primary);
--json-tree-keyword: var(--dsw-alias-state-error-primary);
--json-tree-punctuation: var(--dsw-alias-state-error-primary);
--json-tree-icon: var(--dsw-alias-state-error-primary);
}
.details {
position: relative;
display: flex;
@@ -1,9 +1,15 @@
/** Turn-aware trajectory event ledger with a local record inspector. */
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { CSSProperties, ReactNode } from 'react'
import {
extractMarkdownPlainText, IconChevronRightOutline14, JsonTree, MarkdownText,
IconChevronRightOutline14,
IconSettingsOutline16,
IconSparkle16,
IconUserOutline16,
JsonTree,
MarkdownText,
Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { structuredPatch } from 'diff'
import type {
@@ -13,7 +19,7 @@ import type {
AssistantMetricDetail, TrajectoryCellKind, TrajectoryCellProps, TrajectorySourceBlock,
} from './trajectory-record.ts'
import { formatElapsedSeconds } from './trajectory-record.ts'
import type { TrajectoryTurnModel } from './layout.ts'
import { trajectoryPreviewText, type TrajectoryTurnModel } from './layout.ts'
import css from './TrajectoryTable.module.css'
const KIND_LABEL: Record<TrajectoryCellKind, string> = {
@@ -26,6 +32,77 @@ const KIND_LABEL: Record<TrajectoryCellKind, string> = {
subtool: 'SUBTOOL',
}
function ToolWrenchIcon(): ReactNode {
return (
<svg
width="13"
height="13"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
data-role-icon="wrench"
aria-hidden="true"
>
<path d="M14 3.3a3.8 3.8 0 0 1-4.8 4.8l-5.1 5.1a1.6 1.6 0 1 1-2.3-2.3l5.1-5.1A3.8 3.8 0 0 1 11.7 1l-2.3 2.3 2.3 2.3L14 3.3Z" />
</svg>
)
}
function InformationIcon(): ReactNode {
return (
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
data-role-icon="information"
aria-hidden="true"
>
<circle cx="8" cy="8" r="6.7" />
<circle cx="8" cy="5.5" r=".85" fill="currentColor" stroke="none" />
<path d="M8 7.75v3.4" strokeWidth="1.8" />
</svg>
)
}
function CompactedIcon(): ReactNode {
return (
<svg
width="13"
height="13"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
data-role-icon="compacted"
aria-hidden="true"
>
<path d="m2.5 2.5 3.75 3.75M3 6.25h3.25V3" />
<path d="m13.5 2.5-3.75 3.75M13 6.25H9.75V3" />
<path d="m2.5 13.5 3.75-3.75M3 9.75h3.25V13" />
<path d="m13.5 13.5-3.75-3.75M13 9.75H9.75V13" />
</svg>
)
}
const KIND_ICON: Record<TrajectoryCellKind, ReactNode> = {
system: <IconSettingsOutline16 size={13} />,
user: <IconUserOutline16 size={13} />,
context: <InformationIcon />,
compacted: <CompactedIcon />,
message: <IconSparkle16 size={13} />,
tool: <ToolWrenchIcon />,
subtool: <ToolWrenchIcon />,
}
interface TableRecord {
turn: number
group: string
@@ -225,6 +302,8 @@ export interface TrajectoryTableProps {
onSelectedIndexChange?: (index: number | null) => void
/** Report a direct user selection from a ledger row. */
onRecordSelect?: (index: number) => void
/** One externally requested record selection; a new object repeats the request. */
recordSelection?: { readonly index: number } | null
/** Clear selection state owned by the ledger host. */
onClearSelection?: () => void
/** Turn ids whose rows after the first are folded into a summary. */
@@ -721,13 +800,13 @@ function detailTabs(record: TableRecord): readonly DetailTabItem[] {
function recordDisplayText(cell: TrajectoryCellProps): string {
if (isToolCallOnly(cell)) return ''
if (cell.text !== '') return cell.text
const markdown = cell.kind === 'user' || cell.kind === 'context'
? cell.inputDetail
: cell.kind === 'message'
? cell.outputDetail ?? cell.thinkingDetail
: undefined
if (!markdown) return cell.text
return extractMarkdownPlainText(markdown).replace(/\s+/g, ' ').trim()
return markdown === undefined ? '' : trajectoryPreviewText(markdown)
}
function toolCallTextParts(
@@ -1043,13 +1122,20 @@ function SystemPromptDiff({
function ToolOutputBlocks({
blocks,
error,
preview,
}: {
blocks: readonly TrajectorySourceBlock[]
error: boolean
preview: boolean
}) {
return (
<div className={preview ? `${css.resultBlocks} ${css.resultBlocksPreview}` : css.resultBlocks}>
<div className={[
css.resultBlocks,
preview ? css.resultBlocksPreview : undefined,
error ? css.errorPayload : undefined,
].filter((value): value is string => value !== undefined).join(' ')}
>
{blocks.map((block, index) => (
block.imageSrc !== undefined
? <PanelImage block={block} preview={preview} key={index} />
@@ -1222,6 +1308,9 @@ function RecordPayload({
? 'No payload captured'
: 'No result captured'
if (!value) return <p className={css.noPayload}>{missing}</p>
const error = direction === 'output' && record.cell.isError === true
const payloadClass = preview ? css.jsonPreview : css.jsonPayload
const payloadClassName = error ? `${payloadClass} ${css.errorPayload}` : payloadClass
const json = parseJsonContainer(value)
const singleTextResult = direction === 'output'
@@ -1232,7 +1321,7 @@ function RecordPayload({
<JsonTree
data={json}
label="Result JSON"
className={preview ? css.jsonPreview : css.jsonPayload}
className={payloadClassName}
/>
)
}
@@ -1245,6 +1334,7 @@ function RecordPayload({
return (
<ToolOutputBlocks
blocks={record.cell.outputBlocks}
error={error}
preview={preview}
/>
)
@@ -1258,7 +1348,11 @@ function RecordPayload({
)
if (markdown) {
return (
<div className={preview ? css.markdownPreview : css.markdownPayload}>
<div className={[
preview ? css.markdownPreview : css.markdownPayload,
error ? css.errorPayload : undefined,
].filter((className): className is string => className !== undefined).join(' ')}
>
<MarkdownText text={value} />
</div>
)
@@ -1268,7 +1362,7 @@ function RecordPayload({
<JsonTree
data={json}
label={`${direction === 'input' ? 'Payload' : 'Result'} JSON`}
className={preview ? css.jsonPreview : css.jsonPayload}
className={payloadClassName}
/>
)
}
@@ -1276,7 +1370,7 @@ function RecordPayload({
<pre className={[
css.payload,
preview ? css.payloadPreview : undefined,
record.cell.isError ? css.error : undefined,
error ? css.errorPayload : undefined,
value === 'No output' ? css.noOutputText : undefined,
].filter((value): value is string => value !== undefined).join(' ')}
>
@@ -1397,6 +1491,7 @@ export function TrajectoryTable({
searchMatchIndexes = null,
onSelectedIndexChange,
onRecordSelect,
recordSelection = null,
onClearSelection,
collapsedTurns,
onToggleTurn,
@@ -1406,15 +1501,16 @@ export function TrajectoryTable({
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
const [selectedRequest, setSelectedRequest] = useState<SelectedRequest | null>(null)
const [activeTab, setActiveTab] = useState<DetailTab>('overview')
const [thinkingExpanded, setThinkingExpanded] = useState(true)
const [thinkingExpanded, setThinkingExpanded] = useState(false)
const [detailsWidth, setDetailsWidth] = useState<number | null>(null)
const [toolRequestOffset, setToolRequestOffset] = useState<number | null>(null)
const detailsResizeDrag = useRef<DetailsResizeDrag | null>(null)
const appliedRecordSelection = useRef<TrajectoryTableProps['recordSelection']>(null)
const tabHistory = useRef<Set<DetailTab>>(new Set(['overview']))
useEffect(() => {
onSelectedIndexChange?.(selectedIndex)
}, [onSelectedIndexChange, selectedIndex])
const allRecords = flattenRecords(turns)
const allRecords = useMemo(() => flattenRecords(turns), [turns])
const requestNumbers = indexRequestNumbers(allRecords, sessionRequestNumbers)
const records = searchMatchIndexes === null
? collapseAssistantRecords(
@@ -1531,7 +1627,7 @@ export function TrajectoryTable({
onClearSelection?.()
}
const selectRecord = (index: number) => {
const selectRecord = useCallback((index: number) => {
const record = allRecords.find(candidate => candidate.cell.index === index)
onRecordSelect?.(index)
setSelectedRequest(null)
@@ -1541,7 +1637,15 @@ export function TrajectoryTable({
const available = new Set(tabs.map(tab => tab.id))
const recent = [...tabHistory.current].reverse().find(tab => available.has(tab))
setActiveTab(recent ?? tabs[0]?.id ?? 'overview')
}
}, [allRecords, onRecordSelect])
useEffect(() => {
if (
recordSelection === null
|| appliedRecordSelection.current === recordSelection
) return
appliedRecordSelection.current = recordSelection
selectRecord(recordSelection.index)
}, [recordSelection, selectRecord])
const selectRequest = (
request: SelectedRequest,
@@ -1714,8 +1818,14 @@ export function TrajectoryTable({
className={activeTurn === record.turn
? `${css.turnLabel} ${css.turnLabelActive}`
: css.turnLabel}
aria-label={`Turn ${record.turn}`}
>
Turn {record.turn}
<span className={css.turnLabelFull} aria-hidden="true">
Turn {record.turn}
</span>
<span className={css.turnLabelCompact} aria-hidden="true">
#{record.turn}
</span>
</span>
)}
<div className={css.eventInner}>
@@ -1723,24 +1833,33 @@ export function TrajectoryTable({
<span
className={css.kindSlot}
>
<span className={`${css.kindTag} ${
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'
? css.assistantVioletBright
: record.cell.kind === 'subtool'
? css.subtoolAmber
: css[record.cell.kind]
}`}
>
{KIND_LABEL[record.cell.kind]}
</span>
<Tooltip label={KIND_LABEL[record.cell.kind]} side="bottom">
<span
className={`${css.kindTag} ${
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'
? css.assistantVioletBright
: record.cell.kind === 'subtool'
? css.subtoolAmber
: css[record.cell.kind]
}`}
data-role-kind={record.cell.kind}
>
<span className={css.kindTagIcon} aria-hidden="true">
{KIND_ICON[record.cell.kind]}
</span>
<span className={css.kindTagLabel}>
{KIND_LABEL[record.cell.kind]}
</span>
</span>
</Tooltip>
</span>
)}
</div>
@@ -1973,7 +2092,9 @@ export function TrajectoryTable({
<dl className={css.overview}>
<div>
<dt>Status</dt>
<dd>{statusLabel(selectedRequestState)}</dd>
<dd className={selectedRequestState === 'error' ? css.error : undefined}>
{statusLabel(selectedRequestState)}
</dd>
</div>
{selectedRequestInfo?.purpose === 'compaction' && (
<div>
@@ -2014,7 +2135,7 @@ export function TrajectoryTable({
{selectedRequestInfo?.error !== undefined && (
<div>
<dt>Error</dt>
<dd>{selectedRequestInfo.error}</dd>
<dd className={css.error}>{selectedRequestInfo.error}</dd>
</div>
)}
{selectedRequestInfo?.retry !== undefined && (
@@ -2122,7 +2243,9 @@ export function TrajectoryTable({
<dl className={css.overview}>
<div>
<dt>Status</dt>
<dd>{statusLabel(selectedState)}</dd>
<dd className={selectedState === 'error' ? css.error : undefined}>
{statusLabel(selectedState)}
</dd>
</div>
<div>
<dt>Duration</dt>
@@ -2225,7 +2348,9 @@ export function TrajectoryTable({
)}
<div>
<dt>Status</dt>
<dd>{statusLabel(selectedState)}</dd>
<dd className={selectedState === 'error' ? css.error : undefined}>
{statusLabel(selectedState)}
</dd>
</div>
{selected.cell.kind === 'message' && (
<TokenRows cell={selected.cell} />
@@ -70,16 +70,29 @@
.lanes {
position: absolute;
z-index: 2;
inset: 7px 0;
top: 7px;
bottom: 7px;
left: var(--trajectory-domain-left);
width: var(--trajectory-domain-width);
}
.turnBoundaries {
position: absolute;
z-index: 3;
inset: 0;
top: 0;
bottom: 0;
left: var(--trajectory-domain-left);
width: var(--trajectory-domain-width);
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.lanes[data-animate-viewport='true'],
.turnBoundaries[data-animate-viewport='true'] {
transition: left 180ms ease-out;
}
}
.turnBoundary {
position: absolute;
top: 0;
@@ -133,6 +146,10 @@
);
}
.span[data-error='true'] {
background: var(--dsw-alias-state-error-primary);
}
.span[data-equal-duration='true'] {
width: 8px;
min-width: 8px;
@@ -142,6 +159,18 @@
opacity: 0.2;
}
.span[data-hovered='true']:not([data-current='true']) {
z-index: 1;
opacity: 0.78;
box-shadow:
0 0 0 1px var(--dsw-alias-bg-layer-2),
0 0 0 2px color-mix(
in srgb,
var(--dsw-alias-state-business-primary) 80%,
transparent
);
}
.span[data-current='true'] {
z-index: 1;
opacity: 1;
@@ -15,12 +15,20 @@ import css from './TrajectoryTimeline.module.css'
const MINIMUM_DRAG_PX = 3
const MINIMUM_ZOOM_OPERATIONS = 4
const EDGE_PAN_ZONE_FRACTION = 0.08
const EDGE_PAN_STEP_FRACTION = 0.025
const MAXIMUM_EDGE_PAN_PX = 32
interface FractionRange {
start: number
end: number
}
interface HoverPoint {
fraction: number
recordIndex: number | null
}
/** Props for the fixed full-domain overview above the trajectory ledger. */
export interface TrajectoryTimelineProps {
turns: readonly TrajectoryTurnModel[]
@@ -30,6 +38,9 @@ export interface TrajectoryTimelineProps {
/** Record indexes matching the active ledger search, or null without a query. */
searchMatchIndexes?: ReadonlySet<number> | null
onRangeChange: (range: TrajectoryTimeRange | null) => void
/** Select a directly clicked timeline block. */
onRecordSelect?: (index: number) => void
/** Bring the nearest record into view after clicking timeline whitespace. */
onRecordFocus?: (index: number) => void
}
@@ -41,11 +52,16 @@ function clampFraction(value: number): number {
return Math.min(1, Math.max(0, value))
}
function centeredRange(center: number, width: number): FractionRange {
const clampedWidth = Math.min(1, Math.max(0, width))
function centeredRange(
center: number,
width: number,
minimum: number,
maximum: number,
): FractionRange {
const clampedWidth = Math.min(maximum - minimum, Math.max(0, width))
const start = Math.min(
Math.max(center - clampedWidth / 2, 0),
1 - clampedWidth,
Math.max(center - clampedWidth / 2, minimum),
maximum - clampedWidth,
)
return { start, end: start + clampedWidth }
}
@@ -79,6 +95,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
selectedIndex = null,
searchMatchIndexes = null,
onRangeChange,
onRecordSelect,
onRecordFocus,
}: TrajectoryTimelineProps) {
const model = useMemo(() => deriveTrajectoryTimeline(turns, mode), [mode, turns])
@@ -94,10 +111,16 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
)),
[turns],
)
const dragRef = useRef<{ pointerId: number; anchor: number; width: number } | null>(null)
const [draft, setDraft] = useState<FractionRange | null>(null)
const [hover, setHover] = useState<number | null>(null)
const dragRef = useRef<{
pointerId: number
anchorTime: number
anchorClientX: number
recordIndex: number | null
} | null>(null)
const [draft, setDraft] = useState<TrajectoryTimeRange | null>(null)
const [hover, setHover] = useState<HoverPoint | null>(null)
const [viewport, setViewport] = useState<TrajectoryTimeRange | null>(null)
const [animateViewport, setAnimateViewport] = useState(false)
useEffect(() => {
if (
model !== null
@@ -109,11 +132,35 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
}, [model, onRangeChange, range])
useEffect(() => {
if (model === null) return
setAnimateViewport(false)
setViewport(current =>
current !== null && (current.end < model.start || current.start > model.end)
? null
: current)
}, [model])
useEffect(() => {
if (model === null || selectedIndex === null) return
const selectedSpan = model.spans.find(span => span.index === selectedIndex)
if (selectedSpan === undefined) return
setAnimateViewport(true)
setViewport((current) => {
if (current === null) return current
if (
selectedSpan.end > current.start
&& selectedSpan.start < current.end
) return current
const duration = Math.max(1, current.end - current.start)
const desiredStart = selectedSpan.end <= current.start
? selectedSpan.start
: selectedSpan.end - duration
const nextStart = Math.min(
Math.max(desiredStart, model.start),
Math.max(model.start, model.end - duration),
)
if (nextStart === current.start) return current
return { start: nextStart, end: nextStart + duration }
})
}, [model, selectedIndex])
const fullDuration = Math.max(1, (model?.end ?? 0) - (model?.start ?? 0))
const viewportDuration = Math.min(
fullDuration,
@@ -127,16 +174,21 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
)
const domainDuration = viewport === null ? fullDuration : viewportDuration
const domainStart = viewport === null ? model?.start ?? 0 : viewportStart
const projectedDomainStyle = model === null
? undefined
: {
'--trajectory-domain-left':
`${-(domainStart - model.start) / domainDuration * 100}%`,
'--trajectory-domain-width': `${fullDuration / domainDuration * 100}%`,
} as CSSProperties
const committed = model === null || range === null
? null
: rangeFraction(range, domainStart, domainDuration)
const visibleRange = draft ?? committed
const activeRange = draft === null
? range
: {
start: domainStart + draft.start * domainDuration,
end: domainStart + draft.end * domainDuration,
}
const draftFraction = model === null || draft === null
? null
: rangeFraction(draft, domainStart, domainDuration)
const visibleRange = draftFraction ?? committed
const activeRange = draft ?? range
if (model === null) {
return (
@@ -151,9 +203,9 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
)
}
const minimumSelectionFraction = Math.min(
1,
fullDuration / domainDuration / model.spans.length,
const minimumSelectionDuration = Math.min(
domainDuration,
fullDuration / model.spans.length,
)
const fractionAt = (event: PointerEvent<HTMLDivElement>): number => {
@@ -161,51 +213,107 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
return clampFraction((event.clientX - rect.left) / Math.max(1, rect.width))
}
const commit = (fraction: FractionRange) => {
onRangeChange({
start: domainStart + fraction.start * domainDuration,
end: domainStart + fraction.end * domainDuration,
})
const recordIndexAt = (event: PointerEvent<HTMLDivElement>): number | null => {
const target = event.target instanceof HTMLElement ? event.target : null
const value = target?.closest<HTMLElement>('[data-timeline-record-index]')
?.dataset.timelineRecordIndex
if (value === undefined) return null
const index = Number(value)
return Number.isFinite(index) ? index : null
}
const commit = (nextRange: TrajectoryTimeRange) => {
onRangeChange(nextRange)
}
const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return
const rect = event.currentTarget.getBoundingClientRect()
const anchor = fractionAt(event)
setHover(anchor)
dragRef.current = { pointerId: event.pointerId, anchor, width: Math.max(1, rect.width) }
const anchorTime = domainStart + anchor * domainDuration
const recordIndex = recordIndexAt(event)
setHover({ fraction: anchor, recordIndex })
dragRef.current = {
pointerId: event.pointerId,
anchorTime,
anchorClientX: event.clientX,
recordIndex,
}
if (typeof event.currentTarget.setPointerCapture === 'function') {
event.currentTarget.setPointerCapture(event.pointerId)
}
setDraft({ start: anchor, end: anchor })
setDraft({ start: anchorTime, end: anchorTime })
}
const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current
const rect = event.currentTarget.getBoundingClientRect()
const fraction = fractionAt(event)
setHover(fraction)
setHover({ fraction, recordIndex: recordIndexAt(event) })
if (drag === null || drag.pointerId !== event.pointerId) return
setDraft(orderedRange(drag.anchor, fraction))
let nextDomainStart = domainStart
if (viewport !== null) {
const localX = event.clientX - rect.left
const edgeWidth = Math.min(
MAXIMUM_EDGE_PAN_PX,
Math.max(1, rect.width * EDGE_PAN_ZONE_FRACTION),
)
const direction = localX < edgeWidth
? -1
: localX > rect.width - edgeWidth ? 1 : 0
if (direction !== 0) {
const edgeDistance = direction < 0
? edgeWidth - localX
: localX - (rect.width - edgeWidth)
const strength = clampFraction(edgeDistance / edgeWidth)
const desiredStart = domainStart
+ direction * domainDuration * EDGE_PAN_STEP_FRACTION
* Math.max(0.2, strength)
nextDomainStart = Math.min(
Math.max(desiredStart, model.start),
model.end - domainDuration,
)
if (nextDomainStart !== domainStart) {
setAnimateViewport(false)
setViewport({
start: nextDomainStart,
end: nextDomainStart + domainDuration,
})
}
}
}
const pointTime = nextDomainStart + fraction * domainDuration
setDraft(orderedRange(drag.anchorTime, pointTime))
}
const onPointerEnd = (event: PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current
if (drag === null || drag.pointerId !== event.pointerId) return
const point = fractionAt(event)
const selected = orderedRange(drag.anchor, point)
setHover(point)
const pointFraction = fractionAt(event)
const pointTime = domainStart + pointFraction * domainDuration
const selected = orderedRange(drag.anchorTime, pointTime)
setHover({ fraction: pointFraction, recordIndex: recordIndexAt(event) })
dragRef.current = null
setDraft(null)
const click = (selected.end - selected.start) * drag.width < MINIMUM_DRAG_PX
const committedRange = selected.end - selected.start < minimumSelectionFraction
const click = Math.abs(event.clientX - drag.anchorClientX) < MINIMUM_DRAG_PX
const clickedSpan = click && drag.recordIndex !== null
? model.spans.find(span => span.index === drag.recordIndex)
: undefined
if (clickedSpan !== undefined) {
onRangeChange(null)
onRecordSelect?.(clickedSpan.index)
return
}
const committedRange = selected.end - selected.start < minimumSelectionDuration
? centeredRange(
click ? selected.start : (selected.start + selected.end) / 2,
minimumSelectionFraction,
minimumSelectionDuration,
model.start,
model.end,
)
: selected
commit(committedRange)
if (click) {
const timelinePoint = domainStart + selected.start * domainDuration
const timelinePoint = selected.start
const nearest = model.spans.reduce((candidate, span) => {
const candidateDistance = timelinePoint < candidate.start
? candidate.start - timelinePoint
@@ -233,6 +341,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
const onWheel = (event: WheelEvent<HTMLDivElement>) => {
event.preventDefault()
setAnimateViewport(false)
const rect = event.currentTarget.getBoundingClientRect()
const anchorFraction =
clampFraction((event.clientX - rect.left) / Math.max(1, rect.width))
@@ -278,16 +387,18 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
onWheel={onWheel}
onContextMenu={(event) => {
event.preventDefault()
setAnimateViewport(false)
onRangeChange(null)
setViewport(null)
}}
>
{hover !== null && draft === null && (
{hover !== null && hover.recordIndex === null && draft === null && (
<div
className={css.hoverLine}
data-timeline-hover-line
aria-hidden="true"
style={{
'--trajectory-hover-left': `${hover * 100}%`,
'--trajectory-hover-left': `${hover.fraction * 100}%`,
} as CSSProperties}
/>
)}
@@ -313,7 +424,12 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
/>
</>
)}
<div className={css.turnBoundaries} aria-hidden="true">
<div
className={css.turnBoundaries}
data-animate-viewport={animateViewport || undefined}
aria-hidden="true"
style={projectedDomainStyle}
>
{model.turnBoundaries
.slice(1)
.filter(boundary =>
@@ -326,24 +442,35 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
key={boundary.turn}
style={{
'--trajectory-turn-left':
`${(boundary.time - domainStart) / domainDuration * 100}%`,
`${(boundary.time - model.start) / fullDuration * 100}%`,
} as CSSProperties}
/>
))}
</div>
<div className={css.lanes} aria-hidden="true">
<div
className={css.lanes}
data-animate-viewport={animateViewport || undefined}
data-timeline-domain
aria-hidden="true"
style={projectedDomainStyle}
>
{model.spans
.filter(span => span.end >= domainStart && span.start <= domainStart + domainDuration)
.filter(span =>
span.index === selectedIndex
|| (span.end >= domainStart && span.start <= domainStart + domainDuration))
.map((span) => {
const left = (span.start - domainStart) / domainDuration
const width = (span.end - span.start) / domainDuration
const left = (span.start - model.start) / fullDuration
const width = (span.end - span.start) / fullDuration
const durationMs = durationByIndex.get(span.index)
return (
<span
className={css.span}
data-timeline-span={span.kind}
data-timeline-record-index={span.index}
data-error={span.isError || undefined}
data-equal-duration={mode === 'time' || undefined}
data-current={span.index === selectedIndex || undefined}
data-hovered={hover?.recordIndex === span.index || undefined}
data-search-match={searchMatchIndexes === null
? undefined
: searchMatchIndexes.has(span.index) ? 'true' : 'false'}
@@ -147,6 +147,9 @@ export function TrajectoryView({
const [actualTime, setActualTime] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [selectedTimelineIndex, setSelectedTimelineIndex] = useState<number | null>(null)
const [timelineRecordSelection, setTimelineRecordSelection] = useState<{
readonly index: number
} | null>(null)
const ledgerRef = useRef<HTMLDivElement>(null)
const inspection = useHistory(snapshot => snapshot.inspection)
const nodes = inspection.eventNodes
@@ -478,7 +481,20 @@ export function TrajectoryView({
selectedIndex={selectedTimelineIndex}
searchMatchIndexes={searchMatchIndexes}
onRangeChange={(range) => {
setTimelineSelection(range === null ? null : { branchId: currentBranch.id, range })
setTimelineSelection(range === null ? null : {
branchId: currentBranch.id,
range,
})
}}
onRecordSelect={(index) => {
setTimelineSelection(null)
setTimelineRecordSelection({ index })
setSelectedTimelineIndex(index)
const row = ledgerRef.current
?.querySelector<HTMLElement>(`tr[data-record-index="${index}"]`)
if (row !== undefined && row !== null && typeof row.scrollIntoView === 'function') {
row.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}}
onRecordFocus={(index) => {
const row = ledgerRef.current
@@ -497,6 +513,7 @@ export function TrajectoryView({
searchMatchIndexes={searchMatchIndexes}
onSelectedIndexChange={setSelectedTimelineIndex}
onRecordSelect={handleRecordSelect}
recordSelection={timelineRecordSelection}
onClearSelection={() => { setTimelineSelection(null) }}
collapsedTurns={collapsedTurns}
onToggleTurn={toggleTurn}
@@ -12,6 +12,7 @@ import type {
RequestView,
ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { extractMarkdownPlainText } from '@deepseek-ai/dsh-client-ui-primitives'
import type {
TrajectoryCellProps,
TrajectorySourceBlock,
@@ -66,6 +67,9 @@ interface TurnBucket {
groups: LaidGroup[]
}
const PREVIEW_SOURCE_CHARACTERS = 2_048
const PREVIEW_OUTPUT_CHARACTERS = 512
type InputNode = Extract<
ConversationSnapshot['nodes'][number],
{ kind: 'user' | 'steering' | 'context' }
@@ -126,6 +130,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
nodes, partial, runningCalls, requests = [], callSchemas, codeDispatches,
} = input
const resultByCall = indexResults(nodes)
const emittedCallIds = indexAssistantCallIds(nodes)
const callStartById = new Map<string, number>()
for (const result of resultByCall.values()) {
const startedAt = finiteTime(result.callTime)
@@ -353,7 +358,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
continue
}
if (node.kind === 'tool-result') {
if (!callEmittedInAssistant(nodes, node.callId)) {
if (!emittedCallIds.has(node.callId)) {
const toolName = node.call?.name
const laidList: LaidCell[] = [{
absTime: finiteTime(node.callTime ?? node.time),
@@ -764,12 +769,15 @@ function indexResults(nodes: ConversationSnapshot['nodes']): Map<string, ToolRes
return map
}
function callEmittedInAssistant(nodes: ConversationSnapshot['nodes'], callId: string): boolean {
function indexAssistantCallIds(nodes: ConversationSnapshot['nodes']): ReadonlySet<string> {
const ids = new Set<string>()
for (const node of nodes) {
if (node.kind !== 'assistant') continue
if (node.blocks.some(b => b.kind === 'tool-call' && b.callId === callId)) return true
for (const block of node.blocks) {
if (block.kind === 'tool-call') ids.add(block.callId)
}
}
return false
return ids
}
function collectCallIds(
@@ -849,7 +857,7 @@ function expandSubCalls(
}
function summarizeCall(name: string, argsRaw: string): string {
const args = argsRaw.replace(/\s+/g, ' ').trim()
const args = trajectoryPreviewText(argsRaw)
if (args === '') return name
return `${name} · ${args}`
}
@@ -907,5 +915,20 @@ function summarizeContent(content: readonly { type: string; text?: string }[]):
}
function summarizeText(text: string): string {
return text.replace(/\s+/g, ' ').trim()
return trajectoryPreviewText(text)
}
/**
* Build a bounded one-line ledger preview without parsing the complete Markdown document.
* Full source remains on the cell for the inspector.
* @param text - Untrusted message, reasoning, payload, or result text.
* @returns A compact preview capped independently from the retained source.
*/
export function trajectoryPreviewText(text: string): string {
const source = text.slice(0, PREVIEW_SOURCE_CHARACTERS)
const compact = extractMarkdownPlainText(source).replace(/\s+/g, ' ').trim()
const preview = compact.slice(0, PREVIEW_OUTPUT_CHARACTERS).trimEnd()
return source.length < text.length || preview.length < compact.length
? `${preview}`
: preview
}
@@ -15,6 +15,7 @@ export interface TrajectoryTimeRange {
/** One ledger record projected into the active timeline domain. */
export interface TrajectoryTimelineSpan extends TrajectoryTimeRange {
index: number
isError: boolean
kind: TrajectoryCellKind
label: string
lane: number
@@ -94,6 +95,7 @@ export function deriveTrajectoryTimeline(
start: spans.length + offset,
end: spans.length + offset + 1,
index: cell.index,
isError: cell.isError === true,
kind: cell.kind,
label: cell.text,
lane: laneFor(cell.kind),
@@ -129,6 +131,7 @@ function deriveTimedTimeline(
: [{
...range,
index: cell.index,
isError: cell.isError === true,
kind: cell.kind,
label: cell.text,
lane: laneFor(cell.kind),
@@ -176,6 +176,25 @@ describe('deriveTrajectoryLayout', () => {
})
})
it('bounds a long Markdown-like thinking preview while retaining its full detail', () => {
const thinking = `# Investigation\n\n**NAVIGATION_OK file_path** ${'- repeated detail '.repeat(1_000)}`
const nodes = [{
kind: 'assistant', seq: 1, time: 5_000, turn: 1, step: 0,
blocks: [{ kind: 'reasoning', text: thinking }],
}] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({
codeDispatches: new Map(), nodes, partial: null, runningCalls: [],
})
const message = turns[0]?.groups.flatMap(group => group.cells)
.find(cell => cell.kind === 'message')
expect(message?.text.startsWith('Investigation NAVIGATION_OK file_path')).toBe(true)
expect(message?.text.endsWith('…')).toBe(true)
expect(message?.text.length).toBeLessThanOrEqual(513)
expect(message?.thinkingDetail).toBe(thinking)
})
it('advances the duration cursor over context nodes', () => {
const nodes = [
{ kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'hi' }], source: null },
@@ -83,6 +83,31 @@ describe('TrajectoryTable', () => {
expect(screen.getByText('15 tok')).toBeTruthy()
})
it('keeps long thinking collapsed until the user asks to render it', () => {
const thinking = 'private chain '.repeat(1_000)
const turns: readonly TrajectoryTurnModel[] = [{
turn: 1,
groups: [{
title: 'Step 1',
cells: [{
index: 1,
kind: 'message',
text: 'private chain…',
thinkingDetail: thinking,
timeSeconds: 1,
}],
}],
}]
render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ }))
const toggle = screen.getByRole('button', { name: 'Thinking ...' })
expect(screen.queryByText(thinking)).toBeNull()
fireEvent.click(toggle)
expect(toggle.parentElement?.textContent?.length).toBeGreaterThan(thinking.length)
})
it('keeps raw HTML tags in a Markdown-derived context preview', () => {
const html = [
'<background-task-complete id="trajectory-ui-watch">',
@@ -144,8 +169,53 @@ describe('TrajectoryTable', () => {
expect(screen.getByText('Pending')).toBeTruthy()
fireEvent.click(screen.getByRole('row', { name: /TOOL, bash \{"command":"false"\}/ }))
expect(screen.getByText('Failed')).toBeTruthy()
expect(screen.getByText('Failed').className).toContain('error')
fireEvent.click(screen.getByRole('tab', { name: 'Result' }))
expect(screen.getByText('ToolError: non_zero_exit')).toBeTruthy()
const errorResult = screen.getByText('ToolError: non_zero_exit')
expect(errorResult.closest('[class*="errorPayload"]')).toBeTruthy()
})
it('renders responsive role icons with a custom tooltip', () => {
const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
const toolTag = view.container.querySelector<HTMLElement>('[data-role-kind="tool"]')
expect(toolTag).not.toBeNull()
expect(toolTag?.getAttribute('title')).toBeNull()
expect(toolTag?.querySelector('[data-role-icon="wrench"]')).toBeTruthy()
fireEvent.mouseEnter(toolTag as HTMLElement)
expect(screen.getByRole('tooltip').textContent).toBe('TOOL')
fireEvent.mouseLeave(toolTag as HTMLElement)
expect(screen.queryByRole('tooltip')).toBeNull()
})
it('uses information and compression glyphs for injected and compacted context', () => {
const turns: readonly TrajectoryTurnModel[] = [{
turn: 1,
groups: [{
title: 'Context',
cells: [
{ index: 1, kind: 'context', text: 'Workspace context', timeSeconds: 0 },
{ index: 2, kind: 'compacted', text: 'Compacted history', timeSeconds: 0 },
],
}],
}]
const view = render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
expect(view.container.querySelector(
'[data-role-kind="context"] [data-role-icon="information"]',
)).toBeTruthy()
expect(view.container.querySelector(
'[data-role-kind="compacted"] [data-role-icon="compacted"]',
)).toBeTruthy()
})
it('keeps a compact turn label available for narrow layouts', () => {
render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
const turnLabel = screen.getByLabelText('Turn 1')
expect(turnLabel.textContent).toContain('Turn 1')
expect(turnLabel.textContent).toContain('#1')
})
it('renders a single-text JSON tool result as a JSON tree', () => {
@@ -25,6 +25,7 @@ import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/cli
import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory'
import type { TrajectoryTurnModel } from '../src/client/layout.ts'
import { TrajectoryTimeline } from '../src/client/TrajectoryTimeline.tsx'
import {
TrajectoryView, type TrajectoryViewInjected,
} from '../src/client/TrajectoryView.tsx'
@@ -309,6 +310,44 @@ describe('tab switching in ConversationRoot', () => {
.toBeNull()
})
it('clicking a timeline block clears the range, selects the record, and opens its inspector', async () => {
const b = await bench()
const view = mount(b.slots)
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72,
toJSON: () => ({}),
})
const toolSpan = view.container.querySelector<HTMLElement>(
'[data-timeline-span="tool"]',
)
expect(toolSpan).not.toBeNull()
const recordIndex = toolSpan?.dataset.timelineRecordIndex
expect(recordIndex).toBeTruthy()
fireEvent.pointerMove(toolSpan as HTMLElement, { clientX: 50, pointerId: 1 })
expect(view.container.querySelector('[data-timeline-hover-line]')).toBeNull()
expect(toolSpan?.getAttribute('data-hovered')).toBe('true')
fireEvent.pointerDown(plot, { button: 0, clientX: 5, pointerId: 1 })
fireEvent.pointerMove(plot, { clientX: 95, pointerId: 1 })
fireEvent.pointerUp(plot, { clientX: 95, pointerId: 1 })
expect(view.container.querySelector('tr[data-timeline-focus]')).toBeTruthy()
fireEvent.pointerDown(toolSpan as HTMLElement, {
button: 0, clientX: 50, pointerId: 2,
})
fireEvent.pointerUp(toolSpan as HTMLElement, { clientX: 50, pointerId: 2 })
const selectedRow = view.container.querySelector<HTMLElement>(
`tr[data-record-index="${recordIndex}"]`,
)
expect(selectedRow?.getAttribute('aria-selected')).toBe('true')
expect(view.container.querySelector('tr[data-timeline-focus]')).toBeNull()
expect(screen.getByRole('complementary', { name: 'Event details' })).toBeTruthy()
})
it('empty window keeps the toolbar and reports no timing data', async () => {
const b = await bench(historySnapshot([]))
mount(b.slots)
@@ -332,6 +371,97 @@ describe('timeline projection', () => {
],
}],
}] satisfies readonly TrajectoryTurnModel[]
const longTurns = [{
turn: 1,
groups: [{
title: 'Step 1',
cells: Array.from({ length: 10 }, (_, index) => ({
index,
kind: 'message' as const,
text: `record ${index}`,
timeSeconds: 1,
})),
}],
}] satisfies readonly TrajectoryTurnModel[]
it('pans the zoomed viewport only far enough to reveal a newly selected record', async () => {
const onRangeChange = vi.fn()
const view = render(
<TrajectoryTimeline
turns={longTurns}
mode="sequence"
range={null}
onRangeChange={onRangeChange}
/>,
)
const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72,
toJSON: () => ({}),
})
fireEvent.wheel(plot, { clientX: 50, deltaY: -1_000 })
view.rerender(
<TrajectoryTimeline
turns={longTurns}
mode="sequence"
range={null}
selectedIndex={1}
onRangeChange={onRangeChange}
/>,
)
await vi.waitFor(() => {
const domain = view.container.querySelector<HTMLElement>(
'[data-timeline-domain]',
)
expect(domain?.style.getPropertyValue('--trajectory-domain-left')).toBe('-25%')
})
view.rerender(
<TrajectoryTimeline
turns={longTurns}
mode="sequence"
range={null}
selectedIndex={8}
onRangeChange={onRangeChange}
/>,
)
await vi.waitFor(() => {
const domain = view.container.querySelector<HTMLElement>(
'[data-timeline-domain]',
)
expect(domain?.style.getPropertyValue('--trajectory-domain-left')).toBe('-125%')
})
})
it('auto-pans a zoomed viewport while a range drag pushes against an edge', () => {
const onRangeChange = vi.fn()
render(
<TrajectoryTimeline
turns={longTurns}
mode="sequence"
range={null}
onRangeChange={onRangeChange}
/>,
)
const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72,
toJSON: () => ({}),
})
fireEvent.wheel(plot, { clientX: 50, deltaY: -1_000 })
fireEvent.pointerDown(plot, { button: 0, clientX: 50, pointerId: 1 })
for (let index = 0; index < 24; index++) {
fireEvent.pointerMove(plot, { clientX: 99, pointerId: 1 })
}
fireEvent.pointerUp(plot, { clientX: 99, pointerId: 1 })
const selectedRange = onRangeChange.mock.calls.at(-1)?.[0] as
| { start: number; end: number }
| undefined
expect(selectedRange).toBeDefined()
expect((selectedRange?.end ?? 0) - (selectedRange?.start ?? 0)).toBeGreaterThan(4)
})
it('uses equal-width operation slots and stable semantic lanes', () => {
expect(deriveTrajectoryTimeline(turns)).toEqual({
@@ -339,15 +469,50 @@ describe('timeline projection', () => {
end: 3,
spans: [
{
index: 1, kind: 'message', label: 'assistant', lane: 1, start: 0, end: 1,
index: 1, isError: false, kind: 'message', label: 'assistant',
lane: 1, start: 0, end: 1,
},
{
index: 2, isError: false, kind: 'tool', label: 'bash',
lane: 2, start: 1, end: 2,
},
{
index: 3, isError: false, kind: 'user', label: 'unknown',
lane: 0, start: 2, end: 3,
},
{ index: 2, kind: 'tool', label: 'bash', lane: 2, start: 1, end: 2 },
{ index: 3, kind: 'user', label: 'unknown', lane: 0, start: 2, end: 3 },
],
turnBoundaries: [{ turn: 1, time: 0 }],
})
})
it('marks error records directly on timeline spans', () => {
const errorTurns = [{
turn: 1,
groups: [{
title: 'Step 1',
cells: [{
index: 1,
kind: 'tool' as const,
text: 'failed tool',
timeSeconds: 0.1,
isError: true,
}],
}],
}] satisfies readonly TrajectoryTurnModel[]
const view = render(
<TrajectoryTimeline
turns={errorTurns}
mode="sequence"
range={null}
onRangeChange={() => {}}
/>,
)
expect(view.container.querySelector(
'[data-timeline-span="tool"][data-error="true"]',
)).toBeTruthy()
})
it('ignores durations and idle gaps while retaining turn boundaries', () => {
const separatedTurns = [
{