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

View File

@@ -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

View File

@@ -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.

View File

@@ -0,0 +1,25 @@
# Agent Note: 侧边栏缩放不显示胶囊
Status: implemented
[English](2026-07-30-sidebar-resize-without-visible-pill.md) | 中文
## 问题
AppFrame 在两个栏位边界都显示相同的浮动胶囊。左侧胶囊在主导航旁增加了不必要的视觉负担,但侧边栏的缩放交互仍有用。
## 决策
AppFrame 保留侧边栏宽 8px 的缩放命中条带、`col-resize` 光标、指针捕获、动画帧节流和宽度更新,但不再生成侧边栏手柄的胶囊形伪元素。详情栏边界同时保留命中条带和浮动胶囊。
布局组件测试继续固定侧边栏拖动行为,以及两个手柄随面板折叠时的生命周期。一个无密钥浏览器场景读取实际交付组合所生成的伪元素,并拖动不可见的侧边栏边界,证明该交互仍然有效。
## 曾考虑的替代方案
**随胶囊一并移除侧边栏拖动交互。** 不予采纳,因为本次要求只改视觉表现;移除正常工作的几何控制会不必要地缩减交互方式。
**保留胶囊,但降低其视觉强调。** 更小或对比度更低的胶囊仍会在侧边栏边界留下一个不需要的物体。
## 后果
侧边栏边界在视觉上保持简洁,同时仍可在边界处通过指针调整宽度,并保留缩放光标。与详情栏控件不同,该交互没有可见胶囊。

View File

@@ -3,15 +3,19 @@
// unselected states, and closes it only when a different Session takes ownership. // unselected states, and closes it only when a different Session takes ownership.
import { readFile } from 'node:fs/promises' import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright' import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright' import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { import {
acknowledgeReloadConnectionLoss, fixtureUserPrompts, launchWebScaffold, seedSession, watchConsole, acknowledgeReloadConnectionLoss, assertFixtureInventory, compareOrRefreshGolden,
webSnapshotMode, type WebScaffold, fixtureUserPrompts, launchWebScaffold, seedSession, watchConsole, webSnapshotMode,
type WebScaffold,
} from './scaffold.ts' } from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.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 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 SEED_FIXTURE = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.' 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. */ /** AppFrame is the only product element with an inline grid track template. */
function appFrame(page: Page) { function appFrame(page: Page) {
return page.locator('[style*="grid-template-columns"]').first() 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', () => { describe.skipIf(MODE === 'record')('web e2e: details panel follows the current Session lifecycle', () => {
let scaffold: WebScaffold let scaffold: WebScaffold
let browser: Browser 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) await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0)
expect(await page.getByText('详情', { exact: true }).isVisible()).toBe(false) 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 const warningStart = tripwire.warnings.length
await page.reload({ waitUntil: 'load' }) 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) await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0)
expect(tripwire.pageErrors).toEqual([]) expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([]) expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['handles.expected.md'])
}, 90_000) }, 90_000)
}) })

View File

@@ -0,0 +1,7 @@
# AppFrame drag handles
## sidebar
- hit strip present: true
- cursor: col-resize
- pill generated: false

View File

@@ -11,7 +11,7 @@
- cell "SYSTEM" - cell "SYSTEM"
- cell "Initial System Prompt" - 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."': - '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."' - '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."': - '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"': - 'cell "Request #1 ASSISTANT"':
@@ -33,7 +33,7 @@
- text: ASSISTANT - text: ASSISTANT
- cell "FIRST_DONE" - 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."': - '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."' - '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": - row "Request 3, ASSISTANT, Navigation Summary alpha nav beta nav echo WATERFALL":
- 'cell "Request #3 ASSISTANT"': - 'cell "Request #3 ASSISTANT"':

View File

@@ -1,12 +1,14 @@
import type { import type {
HistoryEntry, IApiClient, MuxFrame, RpcError, SessionId, HistoryEntry, IApiClient, MuxFrame, RpcError, SessionId,
} from '@deepseek-ai/dsh-client-connection/client' } 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 { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { import type {
SessionHistoryFace, SessionHistorySnapshot, SessionHistoryFace, SessionHistorySnapshot,
} from '../contract/session-history.ts' } from '../contract/session-history.ts'
import { createHistoryInspection } from '../sessions/history.ts' import { createHistoryInspection } from '../sessions/history.ts'
import { Notifier } from '../sessions/notifier.ts' import { Notifier } from '../sessions/notifier.ts'
import { PartialAccumulator } from '../sessions/partial.ts'
const HISTORY_PAGE_MESSAGES = 50 const HISTORY_PAGE_MESSAGES = 50
@@ -33,6 +35,9 @@ export class SessionHistorySource implements SessionHistoryFace {
entries: readonly HistoryEntry[] entries: readonly HistoryEntry[]
value: SessionHistorySnapshot['inspection'] value: SessionHistorySnapshot['inspection']
} | null = null } | null = null
private streamPublishToken: object | null = null
private streamBaseInspection: SessionHistorySnapshot['inspection'] | null = null
private streamPartial: PartialAccumulator | null = null
private snapshotCache: SessionHistorySnapshot private snapshotCache: SessionHistorySnapshot
private readonly notifier = new Notifier(() => { private readonly notifier = new Notifier(() => {
this.snapshotCache = this.buildSnapshot() this.snapshotCache = this.buildSnapshot()
@@ -125,7 +130,7 @@ export class SessionHistorySource implements SessionHistoryFace {
if (this.state !== 'cold') { if (this.state !== 'cold') {
this.state = 'cold' this.state = 'cold'
this.error = null this.error = null
this.notifier.markDirty() this.publishDirtyNow()
} }
} }
@@ -143,7 +148,7 @@ export class SessionHistorySource implements SessionHistoryFace {
this.hasMore = false this.hasMore = false
this.state = 'cold' this.state = 'cold'
this.error = null this.error = null
this.notifier.markDirty() this.publishDirtyNow()
void this.loadForConsumers() void this.loadForConsumers()
} }
@@ -155,6 +160,9 @@ export class SessionHistorySource implements SessionHistoryFace {
this.openPromise = null this.openPromise = null
this.olderPromise = null this.olderPromise = null
this.liveBuffer = [] this.liveBuffer = []
this.streamPublishToken = null
this.streamBaseInspection = null
this.streamPartial = null
} }
private open(): Promise<void> { private open(): Promise<void> {
@@ -188,7 +196,7 @@ export class SessionHistorySource implements SessionHistoryFace {
private async doOpen(generation: number): Promise<void> { private async doOpen(generation: number): Promise<void> {
this.state = 'loading' this.state = 'loading'
this.error = null this.error = null
this.notifier.markDirty() this.publishDirtyNow()
try { try {
let { result } = await this.api.sessions.history({ let { result } = await this.api.sessions.history({
sessionId: this.sessionId, sessionId: this.sessionId,
@@ -222,7 +230,7 @@ export class SessionHistorySource implements SessionHistoryFace {
/* v8 ignore next -- transportError always returns the error branch. */ /* v8 ignore next -- transportError always returns the error branch. */
this.error = folded.ok ? null : folded.error this.error = folded.ok ? null : folded.error
} finally { } 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(() => { const settled = operation.finally(() => {
if (this.olderPromise !== settled) return if (this.olderPromise !== settled) return
this.olderPromise = null this.olderPromise = null
this.notifier.markDirty() this.publishDirtyNow()
}) })
this.olderPromise = settled this.olderPromise = settled
return settled return settled
@@ -286,7 +294,7 @@ export class SessionHistorySource implements SessionHistoryFace {
const buffered = this.liveBuffer const buffered = this.liveBuffer
this.liveBuffer = [] this.liveBuffer = []
for (const entry of buffered) this.appendLive(entry) for (const entry of buffered) this.appendLive(entry)
this.notifier.markDirty() this.publishDirtyNow()
} }
private acceptLive(entry: HistoryEntry): void { private acceptLive(entry: HistoryEntry): void {
@@ -301,8 +309,16 @@ export class SessionHistorySource implements SessionHistoryFace {
void this.repairGap() void this.repairGap()
return 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.appendLive(entry)
this.notifier.markDirty() this.publishDirtyNow()
} }
private appendLive(entry: HistoryEntry): void { private appendLive(entry: HistoryEntry): void {
@@ -311,6 +327,66 @@ export class SessionHistorySource implements SessionHistoryFace {
this.entries = [...this.entries, entry] 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> { private async repairGap(): Promise<void> {
if (this.stitching) return if (this.stitching) return
this.stitching = true this.stitching = true
@@ -335,6 +411,16 @@ export class SessionHistorySource implements SessionHistoryFace {
} }
private buildSnapshot(): SessionHistorySnapshot { 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) { if (this.inspectionCache?.entries !== this.entries) {
const entries = this.entries const entries = this.entries
this.inspectionCache = { this.inspectionCache = {
@@ -342,11 +428,14 @@ export class SessionHistorySource implements SessionHistoryFace {
value: createHistoryInspection(() => entries), value: createHistoryInspection(() => entries),
} }
} }
return { return this.inspectionCache.value
state: this.state,
error: this.error,
hasMore: this.hasMore,
inspection: 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'
}

View File

@@ -13,8 +13,18 @@ export class PartialAccumulator {
private changed = true private changed = true
private snapshot: PartialAssistant 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 }
} }
/** /**

View File

@@ -41,6 +41,12 @@ describe('PartialAccumulator', () => {
expect(acc.toPartial().blocks).toEqual([{ kind: 'reasoning', text: '思考' }]) 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', () => { it('folds tool-call deltas: first id pins callId, late name overrides, argsRaw concatenates', () => {
const acc = new PartialAccumulator(1, 0) const acc = new PartialAccumulator(1, 0)
acc.push(chunk({ type: 'tool-call-delta', index: 0, id: 'c1', argumentsDelta: '{"a"' })) acc.push(chunk({ type: 'tool-call-delta', index: 0, id: 'c1', argumentsDelta: '{"a"' }))

View File

@@ -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 { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { SessionHistorySource } from '../src/client/session-history/source.ts' 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 const SID = 'history-s1' as SessionId
afterEach(() => {
vi.unstubAllGlobals()
})
function histResponse(events: SessionEvent[], hasMore = false) { function histResponse(events: SessionEvent[], hasMore = false) {
return Promise.resolve(ok({ events: entries(events) as never[], hasMore })) return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
} }
@@ -52,6 +56,71 @@ describe('SessionHistorySource', () => {
.toEqual([1, 3, 6]) .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 () => { it('stops loading when an older page fails to advance', async () => {
const api = new FakeApiClient() const api = new FakeApiClient()
api.onHistory = payload => payload.beforeSeq === undefined api.onHistory = payload => payload.beforeSeq === undefined

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-layout/README.md # pnpm run verify-translation-pairing --write packages/client/ui-layout/README.md
README.md: 0e92958c9b088071ab58f7e87e8af68f6c2df68d README.md: cb99023e6a9e3364c6f48190cf4a0cd71da2cbba
README.zh.md: 0fb3b1cd85bbf6e070a2a1dd01b25981e5990df0 README.zh.md: 3681b4517670eb92d8f32be2ac62d5852ac745a3

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md) 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. 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.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文 [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 表层获取操作。 AppFrame 始终挂载会话栏和详情栏;已连接 Session 通过 `SessionProvider` 渲染。布局 store 是瞬时状态,侧边栏以默认宽度启动,详情栏则保持关闭,且该 store 从不读写 `localStorage`。hero 和其他未选中状态也会将详情栏的渲染宽度派生为零,但不会改变存储的首选宽度。AppFrame 会跨越这些状态保留最后一个非 blank 会话 id:首个会话保持关闭;显式打开详情栏的操作会使用契约默认宽度;返回同一会话时恢复其未改变的宽度;选择不同会话时,详情栏会在绘制前关闭。会话 owner share 为空,侧边栏 owner share 只包含 `collapsed` 和 `width`;注册方通过标准钩子获取业务数据,并从各自的 inject 表层获取操作。

View File

@@ -49,9 +49,8 @@
} }
/* Drag handles are frame children (columns clip overflow): an 8px hit strip /* Drag handles are frame children (columns clip overflow): an 8px hit strip
centered on the column border via inline left, above column content. The centered on the column border via inline left, above column content. Details
visible pill (12x32 r10, riding the border at vertical center) is the figma adds a visible 12x32 pill at vertical center; sidebar keeps only the hit strip. */
Handle component; the hit strip stays wider than the pill. */
.handle { .handle {
position: absolute; position: absolute;
top: 0; top: 0;
@@ -76,7 +75,7 @@
} }
} }
.handle::after { .handle[data-side='details']::after {
content: ''; content: '';
position: absolute; position: absolute;
top: 50%; top: 50%;
@@ -88,23 +87,22 @@
box-sizing: border-box; box-sizing: border-box;
background: var(--dsw-alias-button-floating-fill); background: var(--dsw-alias-button-floating-fill);
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
/* Hover affordance: the pill hides until the pointer is over the owning /* Hover affordance: the details pill hides until the pointer is over its
column (data-side pairs handle and column), the strip itself, or a drag. */ column, the strip itself, or a drag. */
opacity: 0; opacity: 0;
transition: transition:
opacity var(--ds-transition-duration-slow) var(--ds-ease-in-out), opacity var(--ds-transition-duration-slow) var(--ds-ease-in-out),
background 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, .detailsCol:hover ~ .handle[data-side='details']::after,
.handle:hover::after, .handle[data-side='details']:hover::after,
.handle[data-dragging='true']::after { .handle[data-side='details'][data-dragging='true']::after {
opacity: 1; opacity: 1;
} }
.handle:hover::after, .handle[data-side='details']:hover::after,
.handle[data-dragging='true']::after { .handle[data-side='details'][data-dragging='true']::after {
background: var(--dsw-alias-button-floating-hover); background: var(--dsw-alias-button-floating-hover);
border-color: var(--dsw-alias-border-l3); border-color: var(--dsw-alias-border-l3);
} }

View File

@@ -16,6 +16,7 @@
flex: 1; flex: 1;
min-width: 0; min-width: 0;
overflow: auto; overflow: auto;
container: trajectory-table / inline-size;
} }
.table { .table {
@@ -235,6 +236,18 @@
width: 3px; 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 { .table tbody tr[data-turn-start='true'] td {
position: relative; position: relative;
overflow: visible; overflow: visible;
@@ -279,6 +292,10 @@
white-space: nowrap; white-space: nowrap;
} }
.turnLabelCompact {
display: none;
}
.turnLabelActive { .turnLabelActive {
color: color-mix( color: color-mix(
in srgb, in srgb,
@@ -325,11 +342,66 @@
user-select: none; user-select: none;
} }
.kindTagIcon {
display: none;
align-items: center;
justify-content: center;
width: 13px;
height: 13px;
}
.kindTagLabel {
display: inline;
}
.table .kindSlot .message { .table .kindSlot .message {
justify-content: center; justify-content: center;
width: 100%; 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 { .user {
color: var(--dsw-alias-state-business-primary); color: var(--dsw-alias-state-business-primary);
background: var(--dsw-alias-state-business-tertiary); background: var(--dsw-alias-state-business-tertiary);
@@ -576,6 +648,28 @@
color: var(--dsw-alias-state-error-primary); 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 { .details {
position: relative; position: relative;
display: flex; display: flex;

View File

@@ -1,9 +1,15 @@
/** Turn-aware trajectory event ledger with a local record inspector. */ /** 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 type { CSSProperties, ReactNode } from 'react'
import { import {
extractMarkdownPlainText, IconChevronRightOutline14, JsonTree, MarkdownText, IconChevronRightOutline14,
IconSettingsOutline16,
IconSparkle16,
IconUserOutline16,
JsonTree,
MarkdownText,
Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives' } from '@deepseek-ai/dsh-client-ui-primitives'
import { structuredPatch } from 'diff' import { structuredPatch } from 'diff'
import type { import type {
@@ -13,7 +19,7 @@ import type {
AssistantMetricDetail, TrajectoryCellKind, TrajectoryCellProps, TrajectorySourceBlock, AssistantMetricDetail, TrajectoryCellKind, TrajectoryCellProps, TrajectorySourceBlock,
} from './trajectory-record.ts' } from './trajectory-record.ts'
import { formatElapsedSeconds } 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' import css from './TrajectoryTable.module.css'
const KIND_LABEL: Record<TrajectoryCellKind, string> = { const KIND_LABEL: Record<TrajectoryCellKind, string> = {
@@ -26,6 +32,77 @@ const KIND_LABEL: Record<TrajectoryCellKind, string> = {
subtool: 'SUBTOOL', 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 { interface TableRecord {
turn: number turn: number
group: string group: string
@@ -225,6 +302,8 @@ export interface TrajectoryTableProps {
onSelectedIndexChange?: (index: number | null) => void onSelectedIndexChange?: (index: number | null) => void
/** Report a direct user selection from a ledger row. */ /** Report a direct user selection from a ledger row. */
onRecordSelect?: (index: number) => void 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. */ /** Clear selection state owned by the ledger host. */
onClearSelection?: () => void onClearSelection?: () => void
/** Turn ids whose rows after the first are folded into a summary. */ /** 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 { function recordDisplayText(cell: TrajectoryCellProps): string {
if (isToolCallOnly(cell)) return '' if (isToolCallOnly(cell)) return ''
if (cell.text !== '') return cell.text
const markdown = cell.kind === 'user' || cell.kind === 'context' const markdown = cell.kind === 'user' || cell.kind === 'context'
? cell.inputDetail ? cell.inputDetail
: cell.kind === 'message' : cell.kind === 'message'
? cell.outputDetail ?? cell.thinkingDetail ? cell.outputDetail ?? cell.thinkingDetail
: undefined : undefined
if (!markdown) return cell.text return markdown === undefined ? '' : trajectoryPreviewText(markdown)
return extractMarkdownPlainText(markdown).replace(/\s+/g, ' ').trim()
} }
function toolCallTextParts( function toolCallTextParts(
@@ -1043,13 +1122,20 @@ function SystemPromptDiff({
function ToolOutputBlocks({ function ToolOutputBlocks({
blocks, blocks,
error,
preview, preview,
}: { }: {
blocks: readonly TrajectorySourceBlock[] blocks: readonly TrajectorySourceBlock[]
error: boolean
preview: boolean preview: boolean
}) { }) {
return ( 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) => ( {blocks.map((block, index) => (
block.imageSrc !== undefined block.imageSrc !== undefined
? <PanelImage block={block} preview={preview} key={index} /> ? <PanelImage block={block} preview={preview} key={index} />
@@ -1222,6 +1308,9 @@ function RecordPayload({
? 'No payload captured' ? 'No payload captured'
: 'No result captured' : 'No result captured'
if (!value) return <p className={css.noPayload}>{missing}</p> 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 json = parseJsonContainer(value)
const singleTextResult = direction === 'output' const singleTextResult = direction === 'output'
@@ -1232,7 +1321,7 @@ function RecordPayload({
<JsonTree <JsonTree
data={json} data={json}
label="Result JSON" label="Result JSON"
className={preview ? css.jsonPreview : css.jsonPayload} className={payloadClassName}
/> />
) )
} }
@@ -1245,6 +1334,7 @@ function RecordPayload({
return ( return (
<ToolOutputBlocks <ToolOutputBlocks
blocks={record.cell.outputBlocks} blocks={record.cell.outputBlocks}
error={error}
preview={preview} preview={preview}
/> />
) )
@@ -1258,7 +1348,11 @@ function RecordPayload({
) )
if (markdown) { if (markdown) {
return ( 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} /> <MarkdownText text={value} />
</div> </div>
) )
@@ -1268,7 +1362,7 @@ function RecordPayload({
<JsonTree <JsonTree
data={json} data={json}
label={`${direction === 'input' ? 'Payload' : 'Result'} JSON`} label={`${direction === 'input' ? 'Payload' : 'Result'} JSON`}
className={preview ? css.jsonPreview : css.jsonPayload} className={payloadClassName}
/> />
) )
} }
@@ -1276,7 +1370,7 @@ function RecordPayload({
<pre className={[ <pre className={[
css.payload, css.payload,
preview ? css.payloadPreview : undefined, preview ? css.payloadPreview : undefined,
record.cell.isError ? css.error : undefined, error ? css.errorPayload : undefined,
value === 'No output' ? css.noOutputText : undefined, value === 'No output' ? css.noOutputText : undefined,
].filter((value): value is string => value !== undefined).join(' ')} ].filter((value): value is string => value !== undefined).join(' ')}
> >
@@ -1397,6 +1491,7 @@ export function TrajectoryTable({
searchMatchIndexes = null, searchMatchIndexes = null,
onSelectedIndexChange, onSelectedIndexChange,
onRecordSelect, onRecordSelect,
recordSelection = null,
onClearSelection, onClearSelection,
collapsedTurns, collapsedTurns,
onToggleTurn, onToggleTurn,
@@ -1406,15 +1501,16 @@ export function TrajectoryTable({
const [selectedIndex, setSelectedIndex] = useState<number | null>(null) const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
const [selectedRequest, setSelectedRequest] = useState<SelectedRequest | null>(null) const [selectedRequest, setSelectedRequest] = useState<SelectedRequest | null>(null)
const [activeTab, setActiveTab] = useState<DetailTab>('overview') const [activeTab, setActiveTab] = useState<DetailTab>('overview')
const [thinkingExpanded, setThinkingExpanded] = useState(true) const [thinkingExpanded, setThinkingExpanded] = useState(false)
const [detailsWidth, setDetailsWidth] = useState<number | null>(null) const [detailsWidth, setDetailsWidth] = useState<number | null>(null)
const [toolRequestOffset, setToolRequestOffset] = useState<number | null>(null) const [toolRequestOffset, setToolRequestOffset] = useState<number | null>(null)
const detailsResizeDrag = useRef<DetailsResizeDrag | null>(null) const detailsResizeDrag = useRef<DetailsResizeDrag | null>(null)
const appliedRecordSelection = useRef<TrajectoryTableProps['recordSelection']>(null)
const tabHistory = useRef<Set<DetailTab>>(new Set(['overview'])) const tabHistory = useRef<Set<DetailTab>>(new Set(['overview']))
useEffect(() => { useEffect(() => {
onSelectedIndexChange?.(selectedIndex) onSelectedIndexChange?.(selectedIndex)
}, [onSelectedIndexChange, selectedIndex]) }, [onSelectedIndexChange, selectedIndex])
const allRecords = flattenRecords(turns) const allRecords = useMemo(() => flattenRecords(turns), [turns])
const requestNumbers = indexRequestNumbers(allRecords, sessionRequestNumbers) const requestNumbers = indexRequestNumbers(allRecords, sessionRequestNumbers)
const records = searchMatchIndexes === null const records = searchMatchIndexes === null
? collapseAssistantRecords( ? collapseAssistantRecords(
@@ -1531,7 +1627,7 @@ export function TrajectoryTable({
onClearSelection?.() onClearSelection?.()
} }
const selectRecord = (index: number) => { const selectRecord = useCallback((index: number) => {
const record = allRecords.find(candidate => candidate.cell.index === index) const record = allRecords.find(candidate => candidate.cell.index === index)
onRecordSelect?.(index) onRecordSelect?.(index)
setSelectedRequest(null) setSelectedRequest(null)
@@ -1541,7 +1637,15 @@ export function TrajectoryTable({
const available = new Set(tabs.map(tab => tab.id)) const available = new Set(tabs.map(tab => tab.id))
const recent = [...tabHistory.current].reverse().find(tab => available.has(tab)) const recent = [...tabHistory.current].reverse().find(tab => available.has(tab))
setActiveTab(recent ?? tabs[0]?.id ?? 'overview') 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 = ( const selectRequest = (
request: SelectedRequest, request: SelectedRequest,
@@ -1714,8 +1818,14 @@ export function TrajectoryTable({
className={activeTurn === record.turn className={activeTurn === record.turn
? `${css.turnLabel} ${css.turnLabelActive}` ? `${css.turnLabel} ${css.turnLabelActive}`
: css.turnLabel} : 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> </span>
)} )}
<div className={css.eventInner}> <div className={css.eventInner}>
@@ -1723,24 +1833,33 @@ export function TrajectoryTable({
<span <span
className={css.kindSlot} className={css.kindSlot}
> >
<span className={`${css.kindTag} ${ <Tooltip label={KIND_LABEL[record.cell.kind]} side="bottom">
record.cell.kind === 'system' <span
? css.systemNeutral className={`${css.kindTag} ${
: record.cell.kind === 'context' record.cell.kind === 'system'
? css.contextGreen ? css.systemNeutral
: record.cell.kind === 'compacted' : record.cell.kind === 'context'
? css.compacted ? css.contextGreen
: record.cell.kind === 'tool' : record.cell.kind === 'compacted'
? css.toolAmber ? css.compacted
: record.cell.kind === 'message' : record.cell.kind === 'tool'
? css.assistantVioletBright ? css.toolAmber
: record.cell.kind === 'subtool' : record.cell.kind === 'message'
? css.subtoolAmber ? css.assistantVioletBright
: css[record.cell.kind] : record.cell.kind === 'subtool'
}`} ? css.subtoolAmber
> : css[record.cell.kind]
{KIND_LABEL[record.cell.kind]} }`}
</span> 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> </span>
)} )}
</div> </div>
@@ -1973,7 +2092,9 @@ export function TrajectoryTable({
<dl className={css.overview}> <dl className={css.overview}>
<div> <div>
<dt>Status</dt> <dt>Status</dt>
<dd>{statusLabel(selectedRequestState)}</dd> <dd className={selectedRequestState === 'error' ? css.error : undefined}>
{statusLabel(selectedRequestState)}
</dd>
</div> </div>
{selectedRequestInfo?.purpose === 'compaction' && ( {selectedRequestInfo?.purpose === 'compaction' && (
<div> <div>
@@ -2014,7 +2135,7 @@ export function TrajectoryTable({
{selectedRequestInfo?.error !== undefined && ( {selectedRequestInfo?.error !== undefined && (
<div> <div>
<dt>Error</dt> <dt>Error</dt>
<dd>{selectedRequestInfo.error}</dd> <dd className={css.error}>{selectedRequestInfo.error}</dd>
</div> </div>
)} )}
{selectedRequestInfo?.retry !== undefined && ( {selectedRequestInfo?.retry !== undefined && (
@@ -2122,7 +2243,9 @@ export function TrajectoryTable({
<dl className={css.overview}> <dl className={css.overview}>
<div> <div>
<dt>Status</dt> <dt>Status</dt>
<dd>{statusLabel(selectedState)}</dd> <dd className={selectedState === 'error' ? css.error : undefined}>
{statusLabel(selectedState)}
</dd>
</div> </div>
<div> <div>
<dt>Duration</dt> <dt>Duration</dt>
@@ -2225,7 +2348,9 @@ export function TrajectoryTable({
)} )}
<div> <div>
<dt>Status</dt> <dt>Status</dt>
<dd>{statusLabel(selectedState)}</dd> <dd className={selectedState === 'error' ? css.error : undefined}>
{statusLabel(selectedState)}
</dd>
</div> </div>
{selected.cell.kind === 'message' && ( {selected.cell.kind === 'message' && (
<TokenRows cell={selected.cell} /> <TokenRows cell={selected.cell} />

View File

@@ -70,16 +70,29 @@
.lanes { .lanes {
position: absolute; position: absolute;
z-index: 2; z-index: 2;
inset: 7px 0; top: 7px;
bottom: 7px;
left: var(--trajectory-domain-left);
width: var(--trajectory-domain-width);
} }
.turnBoundaries { .turnBoundaries {
position: absolute; position: absolute;
z-index: 3; z-index: 3;
inset: 0; top: 0;
bottom: 0;
left: var(--trajectory-domain-left);
width: var(--trajectory-domain-width);
pointer-events: none; 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 { .turnBoundary {
position: absolute; position: absolute;
top: 0; top: 0;
@@ -133,6 +146,10 @@
); );
} }
.span[data-error='true'] {
background: var(--dsw-alias-state-error-primary);
}
.span[data-equal-duration='true'] { .span[data-equal-duration='true'] {
width: 8px; width: 8px;
min-width: 8px; min-width: 8px;
@@ -142,6 +159,18 @@
opacity: 0.2; 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'] { .span[data-current='true'] {
z-index: 1; z-index: 1;
opacity: 1; opacity: 1;

View File

@@ -15,12 +15,20 @@ import css from './TrajectoryTimeline.module.css'
const MINIMUM_DRAG_PX = 3 const MINIMUM_DRAG_PX = 3
const MINIMUM_ZOOM_OPERATIONS = 4 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 { interface FractionRange {
start: number start: number
end: number end: number
} }
interface HoverPoint {
fraction: number
recordIndex: number | null
}
/** Props for the fixed full-domain overview above the trajectory ledger. */ /** Props for the fixed full-domain overview above the trajectory ledger. */
export interface TrajectoryTimelineProps { export interface TrajectoryTimelineProps {
turns: readonly TrajectoryTurnModel[] turns: readonly TrajectoryTurnModel[]
@@ -30,6 +38,9 @@ export interface TrajectoryTimelineProps {
/** Record indexes matching the active ledger search, or null without a query. */ /** Record indexes matching the active ledger search, or null without a query. */
searchMatchIndexes?: ReadonlySet<number> | null searchMatchIndexes?: ReadonlySet<number> | null
onRangeChange: (range: TrajectoryTimeRange | null) => void 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 onRecordFocus?: (index: number) => void
} }
@@ -41,11 +52,16 @@ function clampFraction(value: number): number {
return Math.min(1, Math.max(0, value)) return Math.min(1, Math.max(0, value))
} }
function centeredRange(center: number, width: number): FractionRange { function centeredRange(
const clampedWidth = Math.min(1, Math.max(0, width)) center: number,
width: number,
minimum: number,
maximum: number,
): FractionRange {
const clampedWidth = Math.min(maximum - minimum, Math.max(0, width))
const start = Math.min( const start = Math.min(
Math.max(center - clampedWidth / 2, 0), Math.max(center - clampedWidth / 2, minimum),
1 - clampedWidth, maximum - clampedWidth,
) )
return { start, end: start + clampedWidth } return { start, end: start + clampedWidth }
} }
@@ -79,6 +95,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
selectedIndex = null, selectedIndex = null,
searchMatchIndexes = null, searchMatchIndexes = null,
onRangeChange, onRangeChange,
onRecordSelect,
onRecordFocus, onRecordFocus,
}: TrajectoryTimelineProps) { }: TrajectoryTimelineProps) {
const model = useMemo(() => deriveTrajectoryTimeline(turns, mode), [mode, turns]) const model = useMemo(() => deriveTrajectoryTimeline(turns, mode), [mode, turns])
@@ -94,10 +111,16 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
)), )),
[turns], [turns],
) )
const dragRef = useRef<{ pointerId: number; anchor: number; width: number } | null>(null) const dragRef = useRef<{
const [draft, setDraft] = useState<FractionRange | null>(null) pointerId: number
const [hover, setHover] = useState<number | null>(null) 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 [viewport, setViewport] = useState<TrajectoryTimeRange | null>(null)
const [animateViewport, setAnimateViewport] = useState(false)
useEffect(() => { useEffect(() => {
if ( if (
model !== null model !== null
@@ -109,11 +132,35 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
}, [model, onRangeChange, range]) }, [model, onRangeChange, range])
useEffect(() => { useEffect(() => {
if (model === null) return if (model === null) return
setAnimateViewport(false)
setViewport(current => setViewport(current =>
current !== null && (current.end < model.start || current.start > model.end) current !== null && (current.end < model.start || current.start > model.end)
? null ? null
: current) : current)
}, [model]) }, [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 fullDuration = Math.max(1, (model?.end ?? 0) - (model?.start ?? 0))
const viewportDuration = Math.min( const viewportDuration = Math.min(
fullDuration, fullDuration,
@@ -127,16 +174,21 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
) )
const domainDuration = viewport === null ? fullDuration : viewportDuration const domainDuration = viewport === null ? fullDuration : viewportDuration
const domainStart = viewport === null ? model?.start ?? 0 : viewportStart 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 const committed = model === null || range === null
? null ? null
: rangeFraction(range, domainStart, domainDuration) : rangeFraction(range, domainStart, domainDuration)
const visibleRange = draft ?? committed const draftFraction = model === null || draft === null
const activeRange = draft === null ? null
? range : rangeFraction(draft, domainStart, domainDuration)
: { const visibleRange = draftFraction ?? committed
start: domainStart + draft.start * domainDuration, const activeRange = draft ?? range
end: domainStart + draft.end * domainDuration,
}
if (model === null) { if (model === null) {
return ( return (
@@ -151,9 +203,9 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
) )
} }
const minimumSelectionFraction = Math.min( const minimumSelectionDuration = Math.min(
1, domainDuration,
fullDuration / domainDuration / model.spans.length, fullDuration / model.spans.length,
) )
const fractionAt = (event: PointerEvent<HTMLDivElement>): number => { 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)) return clampFraction((event.clientX - rect.left) / Math.max(1, rect.width))
} }
const commit = (fraction: FractionRange) => { const recordIndexAt = (event: PointerEvent<HTMLDivElement>): number | null => {
onRangeChange({ const target = event.target instanceof HTMLElement ? event.target : null
start: domainStart + fraction.start * domainDuration, const value = target?.closest<HTMLElement>('[data-timeline-record-index]')
end: domainStart + fraction.end * domainDuration, ?.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>) => { const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return if (event.button !== 0) return
const rect = event.currentTarget.getBoundingClientRect()
const anchor = fractionAt(event) const anchor = fractionAt(event)
setHover(anchor) const anchorTime = domainStart + anchor * domainDuration
dragRef.current = { pointerId: event.pointerId, anchor, width: Math.max(1, rect.width) } const recordIndex = recordIndexAt(event)
setHover({ fraction: anchor, recordIndex })
dragRef.current = {
pointerId: event.pointerId,
anchorTime,
anchorClientX: event.clientX,
recordIndex,
}
if (typeof event.currentTarget.setPointerCapture === 'function') { if (typeof event.currentTarget.setPointerCapture === 'function') {
event.currentTarget.setPointerCapture(event.pointerId) event.currentTarget.setPointerCapture(event.pointerId)
} }
setDraft({ start: anchor, end: anchor }) setDraft({ start: anchorTime, end: anchorTime })
} }
const onPointerMove = (event: PointerEvent<HTMLDivElement>) => { const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current const drag = dragRef.current
const rect = event.currentTarget.getBoundingClientRect()
const fraction = fractionAt(event) const fraction = fractionAt(event)
setHover(fraction) setHover({ fraction, recordIndex: recordIndexAt(event) })
if (drag === null || drag.pointerId !== event.pointerId) return 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 onPointerEnd = (event: PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current const drag = dragRef.current
if (drag === null || drag.pointerId !== event.pointerId) return if (drag === null || drag.pointerId !== event.pointerId) return
const point = fractionAt(event) const pointFraction = fractionAt(event)
const selected = orderedRange(drag.anchor, point) const pointTime = domainStart + pointFraction * domainDuration
setHover(point) const selected = orderedRange(drag.anchorTime, pointTime)
setHover({ fraction: pointFraction, recordIndex: recordIndexAt(event) })
dragRef.current = null dragRef.current = null
setDraft(null) setDraft(null)
const click = (selected.end - selected.start) * drag.width < MINIMUM_DRAG_PX const click = Math.abs(event.clientX - drag.anchorClientX) < MINIMUM_DRAG_PX
const committedRange = selected.end - selected.start < minimumSelectionFraction 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( ? centeredRange(
click ? selected.start : (selected.start + selected.end) / 2, click ? selected.start : (selected.start + selected.end) / 2,
minimumSelectionFraction, minimumSelectionDuration,
model.start,
model.end,
) )
: selected : selected
commit(committedRange) commit(committedRange)
if (click) { if (click) {
const timelinePoint = domainStart + selected.start * domainDuration const timelinePoint = selected.start
const nearest = model.spans.reduce((candidate, span) => { const nearest = model.spans.reduce((candidate, span) => {
const candidateDistance = timelinePoint < candidate.start const candidateDistance = timelinePoint < candidate.start
? candidate.start - timelinePoint ? candidate.start - timelinePoint
@@ -233,6 +341,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
const onWheel = (event: WheelEvent<HTMLDivElement>) => { const onWheel = (event: WheelEvent<HTMLDivElement>) => {
event.preventDefault() event.preventDefault()
setAnimateViewport(false)
const rect = event.currentTarget.getBoundingClientRect() const rect = event.currentTarget.getBoundingClientRect()
const anchorFraction = const anchorFraction =
clampFraction((event.clientX - rect.left) / Math.max(1, rect.width)) clampFraction((event.clientX - rect.left) / Math.max(1, rect.width))
@@ -278,16 +387,18 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
onWheel={onWheel} onWheel={onWheel}
onContextMenu={(event) => { onContextMenu={(event) => {
event.preventDefault() event.preventDefault()
setAnimateViewport(false)
onRangeChange(null) onRangeChange(null)
setViewport(null) setViewport(null)
}} }}
> >
{hover !== null && draft === null && ( {hover !== null && hover.recordIndex === null && draft === null && (
<div <div
className={css.hoverLine} className={css.hoverLine}
data-timeline-hover-line
aria-hidden="true" aria-hidden="true"
style={{ style={{
'--trajectory-hover-left': `${hover * 100}%`, '--trajectory-hover-left': `${hover.fraction * 100}%`,
} as CSSProperties} } 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 {model.turnBoundaries
.slice(1) .slice(1)
.filter(boundary => .filter(boundary =>
@@ -326,24 +442,35 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
key={boundary.turn} key={boundary.turn}
style={{ style={{
'--trajectory-turn-left': '--trajectory-turn-left':
`${(boundary.time - domainStart) / domainDuration * 100}%`, `${(boundary.time - model.start) / fullDuration * 100}%`,
} as CSSProperties} } as CSSProperties}
/> />
))} ))}
</div> </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 {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) => { .map((span) => {
const left = (span.start - domainStart) / domainDuration const left = (span.start - model.start) / fullDuration
const width = (span.end - span.start) / domainDuration const width = (span.end - span.start) / fullDuration
const durationMs = durationByIndex.get(span.index) const durationMs = durationByIndex.get(span.index)
return ( return (
<span <span
className={css.span} className={css.span}
data-timeline-span={span.kind} data-timeline-span={span.kind}
data-timeline-record-index={span.index}
data-error={span.isError || undefined}
data-equal-duration={mode === 'time' || undefined} data-equal-duration={mode === 'time' || undefined}
data-current={span.index === selectedIndex || undefined} data-current={span.index === selectedIndex || undefined}
data-hovered={hover?.recordIndex === span.index || undefined}
data-search-match={searchMatchIndexes === null data-search-match={searchMatchIndexes === null
? undefined ? undefined
: searchMatchIndexes.has(span.index) ? 'true' : 'false'} : searchMatchIndexes.has(span.index) ? 'true' : 'false'}

View File

@@ -147,6 +147,9 @@ export function TrajectoryView({
const [actualTime, setActualTime] = useState(false) const [actualTime, setActualTime] = useState(false)
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [selectedTimelineIndex, setSelectedTimelineIndex] = useState<number | null>(null) const [selectedTimelineIndex, setSelectedTimelineIndex] = useState<number | null>(null)
const [timelineRecordSelection, setTimelineRecordSelection] = useState<{
readonly index: number
} | null>(null)
const ledgerRef = useRef<HTMLDivElement>(null) const ledgerRef = useRef<HTMLDivElement>(null)
const inspection = useHistory(snapshot => snapshot.inspection) const inspection = useHistory(snapshot => snapshot.inspection)
const nodes = inspection.eventNodes const nodes = inspection.eventNodes
@@ -478,7 +481,20 @@ export function TrajectoryView({
selectedIndex={selectedTimelineIndex} selectedIndex={selectedTimelineIndex}
searchMatchIndexes={searchMatchIndexes} searchMatchIndexes={searchMatchIndexes}
onRangeChange={(range) => { 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) => { onRecordFocus={(index) => {
const row = ledgerRef.current const row = ledgerRef.current
@@ -497,6 +513,7 @@ export function TrajectoryView({
searchMatchIndexes={searchMatchIndexes} searchMatchIndexes={searchMatchIndexes}
onSelectedIndexChange={setSelectedTimelineIndex} onSelectedIndexChange={setSelectedTimelineIndex}
onRecordSelect={handleRecordSelect} onRecordSelect={handleRecordSelect}
recordSelection={timelineRecordSelection}
onClearSelection={() => { setTimelineSelection(null) }} onClearSelection={() => { setTimelineSelection(null) }}
collapsedTurns={collapsedTurns} collapsedTurns={collapsedTurns}
onToggleTurn={toggleTurn} onToggleTurn={toggleTurn}

View File

@@ -12,6 +12,7 @@ import type {
RequestView, RequestView,
ToolResultNode, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client' } from '@deepseek-ai/dsh-client-runtime/client'
import { extractMarkdownPlainText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { import type {
TrajectoryCellProps, TrajectoryCellProps,
TrajectorySourceBlock, TrajectorySourceBlock,
@@ -66,6 +67,9 @@ interface TurnBucket {
groups: LaidGroup[] groups: LaidGroup[]
} }
const PREVIEW_SOURCE_CHARACTERS = 2_048
const PREVIEW_OUTPUT_CHARACTERS = 512
type InputNode = Extract< type InputNode = Extract<
ConversationSnapshot['nodes'][number], ConversationSnapshot['nodes'][number],
{ kind: 'user' | 'steering' | 'context' } { kind: 'user' | 'steering' | 'context' }
@@ -126,6 +130,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
nodes, partial, runningCalls, requests = [], callSchemas, codeDispatches, nodes, partial, runningCalls, requests = [], callSchemas, codeDispatches,
} = input } = input
const resultByCall = indexResults(nodes) const resultByCall = indexResults(nodes)
const emittedCallIds = indexAssistantCallIds(nodes)
const callStartById = new Map<string, number>() const callStartById = new Map<string, number>()
for (const result of resultByCall.values()) { for (const result of resultByCall.values()) {
const startedAt = finiteTime(result.callTime) const startedAt = finiteTime(result.callTime)
@@ -353,7 +358,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
continue continue
} }
if (node.kind === 'tool-result') { if (node.kind === 'tool-result') {
if (!callEmittedInAssistant(nodes, node.callId)) { if (!emittedCallIds.has(node.callId)) {
const toolName = node.call?.name const toolName = node.call?.name
const laidList: LaidCell[] = [{ const laidList: LaidCell[] = [{
absTime: finiteTime(node.callTime ?? node.time), absTime: finiteTime(node.callTime ?? node.time),
@@ -764,12 +769,15 @@ function indexResults(nodes: ConversationSnapshot['nodes']): Map<string, ToolRes
return map 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) { for (const node of nodes) {
if (node.kind !== 'assistant') continue 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( function collectCallIds(
@@ -849,7 +857,7 @@ function expandSubCalls(
} }
function summarizeCall(name: string, argsRaw: string): string { function summarizeCall(name: string, argsRaw: string): string {
const args = argsRaw.replace(/\s+/g, ' ').trim() const args = trajectoryPreviewText(argsRaw)
if (args === '') return name if (args === '') return name
return `${name} · ${args}` return `${name} · ${args}`
} }
@@ -907,5 +915,20 @@ function summarizeContent(content: readonly { type: string; text?: string }[]):
} }
function summarizeText(text: string): 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
} }

View File

@@ -15,6 +15,7 @@ export interface TrajectoryTimeRange {
/** One ledger record projected into the active timeline domain. */ /** One ledger record projected into the active timeline domain. */
export interface TrajectoryTimelineSpan extends TrajectoryTimeRange { export interface TrajectoryTimelineSpan extends TrajectoryTimeRange {
index: number index: number
isError: boolean
kind: TrajectoryCellKind kind: TrajectoryCellKind
label: string label: string
lane: number lane: number
@@ -94,6 +95,7 @@ export function deriveTrajectoryTimeline(
start: spans.length + offset, start: spans.length + offset,
end: spans.length + offset + 1, end: spans.length + offset + 1,
index: cell.index, index: cell.index,
isError: cell.isError === true,
kind: cell.kind, kind: cell.kind,
label: cell.text, label: cell.text,
lane: laneFor(cell.kind), lane: laneFor(cell.kind),
@@ -129,6 +131,7 @@ function deriveTimedTimeline(
: [{ : [{
...range, ...range,
index: cell.index, index: cell.index,
isError: cell.isError === true,
kind: cell.kind, kind: cell.kind,
label: cell.text, label: cell.text,
lane: laneFor(cell.kind), lane: laneFor(cell.kind),

View File

@@ -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', () => { it('advances the duration cursor over context nodes', () => {
const nodes = [ const nodes = [
{ kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'hi' }], source: null }, { kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'hi' }], source: null },

View File

@@ -83,6 +83,31 @@ describe('TrajectoryTable', () => {
expect(screen.getByText('15 tok')).toBeTruthy() 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', () => { it('keeps raw HTML tags in a Markdown-derived context preview', () => {
const html = [ const html = [
'<background-task-complete id="trajectory-ui-watch">', '<background-task-complete id="trajectory-ui-watch">',
@@ -144,8 +169,53 @@ describe('TrajectoryTable', () => {
expect(screen.getByText('Pending')).toBeTruthy() expect(screen.getByText('Pending')).toBeTruthy()
fireEvent.click(screen.getByRole('row', { name: /TOOL, bash \{"command":"false"\}/ })) fireEvent.click(screen.getByRole('row', { name: /TOOL, bash \{"command":"false"\}/ }))
expect(screen.getByText('Failed')).toBeTruthy() expect(screen.getByText('Failed')).toBeTruthy()
expect(screen.getByText('Failed').className).toContain('error')
fireEvent.click(screen.getByRole('tab', { name: 'Result' })) 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', () => { it('renders a single-text JSON tool result as a JSON tree', () => {

View File

@@ -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, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory' import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory'
import type { TrajectoryTurnModel } from '../src/client/layout.ts' import type { TrajectoryTurnModel } from '../src/client/layout.ts'
import { TrajectoryTimeline } from '../src/client/TrajectoryTimeline.tsx'
import { import {
TrajectoryView, type TrajectoryViewInjected, TrajectoryView, type TrajectoryViewInjected,
} from '../src/client/TrajectoryView.tsx' } from '../src/client/TrajectoryView.tsx'
@@ -309,6 +310,44 @@ describe('tab switching in ConversationRoot', () => {
.toBeNull() .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 () => { it('empty window keeps the toolbar and reports no timing data', async () => {
const b = await bench(historySnapshot([])) const b = await bench(historySnapshot([]))
mount(b.slots) mount(b.slots)
@@ -332,6 +371,97 @@ describe('timeline projection', () => {
], ],
}], }],
}] satisfies readonly TrajectoryTurnModel[] }] 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', () => { it('uses equal-width operation slots and stable semantic lanes', () => {
expect(deriveTrajectoryTimeline(turns)).toEqual({ expect(deriveTrajectoryTimeline(turns)).toEqual({
@@ -339,15 +469,50 @@ describe('timeline projection', () => {
end: 3, end: 3,
spans: [ 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 }], 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', () => { it('ignores durations and idle gaps while retaining turn boundaries', () => {
const separatedTurns = [ const separatedTurns = [
{ {