From 26742effdd63afbf3ad8d83384e34697c294d38b Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 4 Aug 2026 13:57:04 +0800 Subject: [PATCH 01/21] fix(client): fold high-sequence history windows --- .../client/session-history/history-fold.ts | 25 ++++------ .../client/runtime/tests/history-fold.spec.ts | 31 ++++++++++++ packages/core/session/README.i18n.yaml | 4 +- packages/core/session/README.md | 2 +- packages/core/session/README.zh.md | 2 +- packages/core/session/src/surface.ts | 48 +++++++++++++------ packages/core/session/tests/surface.spec.ts | 34 +++++++++++++ 7 files changed, 111 insertions(+), 35 deletions(-) diff --git a/packages/client/runtime/src/client/session-history/history-fold.ts b/packages/client/runtime/src/client/session-history/history-fold.ts index c4bc6ed9b5..68c8aebccf 100644 --- a/packages/client/runtime/src/client/session-history/history-fold.ts +++ b/packages/client/runtime/src/client/session-history/history-fold.ts @@ -49,18 +49,10 @@ function assistantStepKey(turn: number, step: number): string { return `${turn}\u0000${step}` } -// Trajectory owns surface-window reconstruction so its immutable ledger does -// not depend on Chat's live fold adapter or Session's mutable state. -/* jscpd:ignore-start */ -function paddingEvent(seq: number): SessionEvent { - return { type: 'noop/padding', seq, time: 0, data: {} } as unknown as SessionEvent -} - function replacementCrossesWindowHead(event: SessionEvent, baseSeq: number): boolean { if (!isSurfaceEvent(event) || event.surfaceOp === 'append') return false return event.surfaceOp.start < baseSeq || event.surfaceOp.end < baseSeq } -/* jscpd:ignore-end */ function contextOriginKind(event: SessionEvent | undefined): ConversationContextOriginKind { if (event?.type !== 'user/message') return 'rewrite' @@ -84,9 +76,12 @@ function isTokenDelta(chunk: SessionEvent<'assistant/chunk'>['data']['chunk']): } } -function foldContexts(events: readonly SessionEvent[]): readonly FoldedContext[] { +function foldContexts( + events: readonly SessionEvent[], + baseSeq: number, +): readonly FoldedContext[] { const replay: SessionEvent[] = [] - const surface = new SurfaceManager(replay) + const surface = new SurfaceManager(replay, baseSeq) const contexts: FoldedContext[] = [] let generation = 0 let originSeq: number | undefined @@ -332,10 +327,6 @@ export function projectConversationHistory( ): ConversationHistoryProjection { const events = entries.map(entry => entry.event) const baseSeq = events[0]?.seq ?? 0 - const padded = [ - ...Array.from({ length: baseSeq }, (_, seq) => paddingEvent(seq)), - ...events, - ] const callIndex = new Map() const resultViews = new Map() const assistantSteps = new Map() @@ -405,7 +396,7 @@ export function projectConversationHistory( const materialize = (seq: number): ConversationNode | undefined => { const cached = nodeCache.get(seq) if (cached !== undefined) return cached - const event = padded[seq] + const event = events[seq - baseSeq] if (event === undefined || !isSurfaceEligibleType(event.type)) return const node = materializeNode( event, @@ -431,7 +422,7 @@ export function projectConversationHistory( }] } else { try { - contexts = foldContexts(padded).map((context): ConversationContext => { + contexts = foldContexts(events, baseSeq).map((context): ConversationContext => { const nodes = context.nodes.flatMap((seq) => { const node = materialize(seq) return node === undefined ? [] : [node] @@ -444,7 +435,7 @@ export function projectConversationHistory( nodes, } } - const originEvent = padded[context.originSeq] + const originEvent = events[context.originSeq - baseSeq] return { id: context.generation, parentId: context.generation - 1, diff --git a/packages/client/runtime/tests/history-fold.spec.ts b/packages/client/runtime/tests/history-fold.spec.ts index 0c5421e491..ff703afa26 100644 --- a/packages/client/runtime/tests/history-fold.spec.ts +++ b/packages/client/runtime/tests/history-fold.spec.ts @@ -8,6 +8,37 @@ const at = (seq: number, event: Record): SessionEvent => ({ seq, time: 1_700_000_000_000 + seq, ...event }) as unknown as SessionEvent describe('projectConversationHistory', () => { + it('projects a high-sequence history window without synthesizing its unloaded prefix', () => { + const baseSeq = 400_000 + const events = [ + ev.user(baseSeq, 'loaded tail'), + at(baseSeq + 1, { + type: 'assistant/message', + surfaceOp: { op: 'replace', start: baseSeq, end: baseSeq }, + sourceEventSeqs: [baseSeq], + data: { + turn: 80, + step: 1, + message: createMessage({ + role: 'assistant', + content: [{ type: 'text', text: 'tail summary' }], + source: { kind: 'model', provider: 'fake', model: 'fake' }, + }), + }, + }), + ] + + const projection = projectConversationHistory(events.map(event => ({ event }))) + expect(projection.eventNodes.map(node => node.seq)).toEqual([baseSeq, baseSeq + 1]) + expect(projection.contexts.map(context => ({ + originSeq: context.originSeq, + nodes: context.nodes.map(node => node.seq), + }))).toEqual([ + { originSeq: undefined, nodes: [baseSeq] }, + { originSeq: baseSeq + 1, nodes: [baseSeq + 1] }, + ]) + }) + it('projects frozen surface generations without widening the core live surface', () => { const events = [ ev.user(0, 'a'), diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index 9f0aec4eb8..fa1a5a5fe2 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/README.i18n.yaml @@ -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/core/session/README.md -README.md: d78dc5bcfe1df2edd01280208f3859eb1b2d6763 -README.zh.md: 40c58a539d5027f2619b5b2102b94e76f2c73e23 +README.md: 892be8237d008b85418d8b815325a970b140a163 +README.zh.md: 031239faf0fd14d582988f05590816cd17c329b6 diff --git a/packages/core/session/README.md b/packages/core/session/README.md index d78dc5bcfe..892be8237d 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -58,7 +58,7 @@ Providers stream token-sized deltas, so a raw log stores hundreds of `assistant/ - `SurfaceOp` — how an event entered the ordered surface: `'append'` (normal tail append) or `{ op: 'replace', start, end }` (replace entries from `start` through `end` inclusive — both must be valid surface seqs; `start === end` replaces one entry). Used by compaction to shadow old events without deleting them. - `SurfaceIntent` — `{ surfaceOp: SurfaceOp; sourceEventSeqs?: number[] }`, the required third parameter to `session.append()` for surface-eligible types. - `SessionSurface` — the readonly live `nodes` and `replaceGeneration` projection exposed by `session.surface`; candidate validation remains private to `Session`. -- `foldSurface(events)` — replay the canonical surface contract into detached current event sequences and actual replacement ranges. The same pass rejects non-contiguous seqs, misplaced or malformed metadata, empty or duplicate provenance, non-earlier sources, invalid positional ranges, replacements that fail to cite every shadowed surface entry, and a `tool/result` replacement that changes anything except one current result's `content`; `SurfaceManager` shares the atomic transition while retaining only its incremental sequence cache. +- `foldSurface(events)` — replay the canonical surface contract into detached current event sequences and actual replacement ranges. The same pass rejects non-contiguous seqs, misplaced or malformed metadata, empty or duplicate provenance, non-earlier sources, invalid positional ranges, replacements that fail to cite every shadowed surface entry, and a `tool/result` replacement that changes anything except one current result's `content`; `SurfaceManager` shares the atomic transition while retaining only its incremental sequence cache. Its optional `baseSeq` folds a contiguous loaded window with absolute event sequences and no synthetic prefix; replacements must remain inside that window. - `isSurfaceEvent(event)` / `isSurfaceEligibleType(type)` — the first narrows a `SessionEvent` to a fully formed surface event; the second detects a surface-eligible event missing its marker when validating a seed or loaded log. - `isAppendSurfaceEvent(event)` / `isReplacementSurfaceEvent(event)` — split a formed surface event by marker variant. Append-origin events are the durable source for a human transcript, which is not the model-visible surface: a landed replacement shadows the range it summarizes, so projecting a transcript from `session.surface` erases conversation the reader already saw. Consumers that must send exactly what the model sees keep reading `session.surface`. diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index 40c58a539d..031239faf0 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -58,7 +58,7 @@ - `SurfaceOp`:事件进入有序 surface 的方式,即 `'append'`(正常尾部追加)或 `{ op: 'replace', start, end }`(替换从 `start` 到 `end` 的条目,含两端;二者都必须是有效的 surface 序号;`start === end` 时替换一个条目)。压缩用它遮蔽旧事件而不删除它们。 - `SurfaceIntent`:`{ surfaceOp: SurfaceOp; sourceEventSeqs?: number[] }`,可进入 surface 的类型调用 `session.append()` 时必需的第三个参数。 - `SessionSurface`:实时只读 `nodes` 和 `replaceGeneration` 投影,由 `session.surface` 暴露;候选校验仍由 `Session` 私有。 -- `foldSurface(events)`:回放规范 surface 契约,得到脱离的当前事件序列与实际替换范围。同一趟处理会拒绝不连续序号、错位或畸形元数据、空或重复溯源信息、来源并非更早事件、无效位置范围,以及没有引用所有已遮蔽 surface 条目的替换。如果一个 `tool/result` 替换修改了当前某个结果的 `content` 之外的任何内容,也会被拒绝;`SurfaceManager` 共享该原子状态转换,但只保留自己的增量序列缓存。 +- `foldSurface(events)`:回放规范 surface 契约,得到脱离的当前事件序列与实际替换范围。同一趟处理会拒绝不连续序号、错位或畸形元数据、空或重复溯源信息、来源并非更早事件、无效位置范围,以及没有引用所有已遮蔽 surface 条目的替换。如果一个 `tool/result` 替换修改了当前某个结果的 `content` 之外的任何内容,也会被拒绝;`SurfaceManager` 共享该原子状态转换,但只保留自己的增量序列缓存。其可选 `baseSeq` 可使用绝对事件序号折叠连续的已加载窗口,而无需构造合成前缀;替换范围必须位于该窗口内。 - `isSurfaceEvent(event)`/`isSurfaceEligibleType(type)`:前者将 `SessionEvent` 收窄为形态完整的 surface 事件;后者在校验种子或已加载日志时,检测缺少标记的可进入 surface 事件。 - `isAppendSurfaceEvent(event)`/`isReplacementSurfaceEvent(event)`:按标记变体拆分形态完整的 surface 事件。追加来源的事件是人类可读记录(transcript)的持久来源,而该记录并非模型可见的 surface:已落地的替换会遮蔽它所概括的范围,因此从 `session.surface` 投影记录会抹掉读者已经看到的对话。必须准确发送模型所见内容的消费方仍继续读取 `session.surface`。 diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index ad3d28127c..775e371ddf 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -242,13 +242,14 @@ function assertToolResultRewrite( event: SessionEvent, shadowedSeqs: readonly number[], events: readonly SessionEvent[], + baseSeq: number, ): void { if (event.type !== 'tool/result') return if (shadowedSeqs.length !== 1) { throw new Error('tool/result surface replacement must rewrite exactly one current node') } for (const originalSeq of shadowedSeqs) { - const original = events[originalSeq] + const original = events[originalSeq - baseSeq] if (original?.type !== 'tool/result') { throw new Error('tool/result surface replacement must target a current tool/result') } @@ -276,6 +277,7 @@ function planSurfaceEvent( event: SessionEvent, expectedSeq: number, events: readonly SessionEvent[], + baseSeq: number, ): SurfacePlan | undefined { if (event.seq !== expectedSeq) { throw new Error(`session event seq ${event.seq} is not contiguous; expected ${expectedSeq}`) @@ -288,7 +290,7 @@ function planSurfaceEvent( } const range = replacementRange(state, surfaceOp) assertProvenance(event, range.shadowedSeqs) - assertToolResultRewrite(event, range.shadowedSeqs, events) + assertToolResultRewrite(event, range.shadowedSeqs, events, baseSeq) return { kind: 'replace', seq: event.seq, @@ -304,8 +306,9 @@ function applySurfaceEvent( event: SessionEvent, expectedSeq: number, events: readonly SessionEvent[], + baseSeq: number, ): SurfaceFoldReplacement | undefined { - const plan = planSurfaceEvent(state, event, expectedSeq, events) + const plan = planSurfaceEvent(state, event, expectedSeq, events, baseSeq) if (plan?.kind === 'append') { state.nodes.push(plan.seq) } else if (plan?.kind === 'replace') { @@ -331,7 +334,7 @@ export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult const state = createFoldState() const replacements: SurfaceFoldReplacement[] = [] for (const [index, event] of events.entries()) { - const replacement = applySurfaceEvent(state, event, index, events) + const replacement = applySurfaceEvent(state, event, index, events, 0) if (replacement !== undefined) replacements.push(replacement) } return { nodes: [...state.nodes], replacements } @@ -341,38 +344,55 @@ export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult export class SurfaceManager implements SessionSurface { /** Shared transition state; replacement history is not retained. */ private _state = createFoldState() - /** Last processed seq; -1 folds a seeded log on first access. */ - private _lastProcessedSeq = -1 + /** Last processed absolute seq. */ + private _lastProcessedSeq: number - constructor(private log: readonly SessionEvent[]) {} + /** + * @param log - Contiguous complete log or loaded event window. + * @param baseSeq - Absolute sequence of the window's first event. + */ + constructor( + private log: readonly SessionEvent[], + private readonly baseSeq = 0, + ) { + this._lastProcessedSeq = baseSeq - 1 + } /** * Validate the next candidate without mutating the committed surface. * @param event - candidate event that has not entered the log yet. */ validateNext(event: SessionEvent): void { - if (this._lastProcessedSeq < this.log.length - 1) this._processDelta() - planSurfaceEvent(this._state, event, this.log.length, this.log) + if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta() + planSurfaceEvent( + this._state, + event, + this.baseSeq + this.log.length, + this.log, + this.baseSeq, + ) } /** Monotonic count of folded positional replacements. */ get replaceGeneration(): number { - if (this._lastProcessedSeq < this.log.length - 1) this._processDelta() + if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta() return this._state.replaceGeneration } /** Surface event sequences in model-visible order. */ get nodes(): readonly number[] { - if (this._lastProcessedSeq < this.log.length - 1) this._processDelta() + if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta() return this._state.nodes } /** Fold events appended since the previous access. */ private _processDelta(): void { - for (let i = this._lastProcessedSeq + 1; i < this.log.length; i++) { + const tailSeq = this.baseSeq + this.log.length - 1 + for (let seq = this._lastProcessedSeq + 1; seq <= tailSeq; seq++) { + const index = seq - this.baseSeq // oxlint-disable-next-line typescript/no-non-null-assertion -- bounded by the loop condition - applySurfaceEvent(this._state, this.log[i]!, i, this.log) - this._lastProcessedSeq = i + applySurfaceEvent(this._state, this.log[index]!, seq, this.log, this.baseSeq) + this._lastProcessedSeq = seq } } } diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index 017f0e0163..8bc76f2e0b 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -9,6 +9,7 @@ import { isSurfaceEligibleType, isSurfaceEvent, } from '@deepseek-ai/dsh-session' +import { SurfaceManager } from '@deepseek-ai/dsh-session/surface' import { createMessage, createToolResultMessage, @@ -239,6 +240,39 @@ describe('foldSurface tool-result rewrites', () => { }) describe('SurfaceManager', () => { + it('folds a contiguous window without materializing earlier event sequences', () => { + const baseSeq = 400_000 + const events = [ + provenanceEvent(baseSeq, undefined), + provenanceEvent(baseSeq + 1, undefined), + { + ...provenanceEvent(baseSeq + 2, [baseSeq]), + surfaceOp: { op: 'replace', start: baseSeq, end: baseSeq }, + }, + ] as SessionEvent[] + + const surface = new SurfaceManager(events, baseSeq) + expect(surface.nodes).toEqual([baseSeq + 2, baseSeq + 1]) + expect(surface.replaceGeneration).toBe(1) + }) + + it('validates tool-result rewrites against a nonzero window offset', () => { + const baseSeq = 400_000 + const original = toolResultEvent(baseSeq, 'call') + const events: SessionEvent[] = [ + original, + { + ...original, + seq: baseSeq + 1, + time: baseSeq + 1, + surfaceOp: { op: 'replace' as const, start: baseSeq, end: baseSeq }, + sourceEventSeqs: [baseSeq], + } as SessionEvent, + ] + + expect(new SurfaceManager(events, baseSeq).nodes).toEqual([baseSeq + 1]) + }) + it('shares ordered entries and nested replacement ranges with foldSurface', () => { const s = new Session(SessionId('shared-fold')) s.append('user/message', createUserMessage({ From ae1008b4fb4c141444428dee0b01387415fda6f1 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 4 Aug 2026 13:57:19 +0800 Subject: [PATCH 02/21] feat(trajectory): virtualize long session histories --- ...-27-trajectory-inspection-ledger.i18n.yaml | 4 +- ...2026-07-27-trajectory-inspection-ledger.md | 17 +- ...6-07-27-trajectory-inspection-ledger.zh.md | 17 +- THIRD_PARTY_NOTICES.md | 1 + packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- .../src/client/contract/session-history.ts | 14 +- .../src/client/session-history/source.ts | 51 ++-- .../tests/session-history-source.spec.ts | 27 +- .../client/ui-trajectory/README.i18n.yaml | 4 +- packages/client/ui-trajectory/README.md | 2 +- packages/client/ui-trajectory/README.zh.md | 2 +- packages/client/ui-trajectory/package.json | 8 +- .../src/client/TrajectoryTable.module.css | 66 ++++- .../src/client/TrajectoryTable.tsx | 278 ++++++++++++++++-- .../src/client/TrajectoryTimeline.module.css | 43 ++- .../src/client/TrajectoryTimeline.tsx | 70 ++++- .../src/client/TrajectoryView.tsx | 192 ++++++------ .../client/ui-trajectory/src/client/index.ts | 3 +- .../client/ui-trajectory/src/client/layout.ts | 56 ++++ .../ui-trajectory/tests/client-bundle.spec.ts | 1 + .../ui-trajectory/tests/layout.spec.tsx | 40 ++- .../client/ui-trajectory/tests/table.spec.tsx | 133 ++++++++- .../client/ui-trajectory/tests/views.spec.tsx | 101 ++++++- pnpm-lock.yaml | 26 ++ 26 files changed, 966 insertions(+), 198 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml index d7e0f75f2d..c0bf4a0d51 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md -2026-07-27-trajectory-inspection-ledger.md: cdeaa30ea64f47b0e0110baf566f747a4591a384 -2026-07-27-trajectory-inspection-ledger.zh.md: df2a3d266161a7c1c4444863971f3d177533af8c +2026-07-27-trajectory-inspection-ledger.md: 6447baaa7a0e949ba7cb357b3741a3b5c11851e6 +2026-07-27-trajectory-inspection-ledger.zh.md: 3c17b5f3bddeac3d27e7ef07b4aa54cea05fa3d3 diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md index cdeaa30ea6..6447baaa7a 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md @@ -16,13 +16,16 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested - Event kind and content form the two stable columns. Role tags align toward the content, nested subtools receive a small indentation, and CSS truncation preserves the available preview width. Token usage and duration stay in the inspector. - Product prose uses the existing sans stack. Turn ids, token counts, durations, tool calls, raw payloads, and other machine data use the existing code stack. - Existing theme tokens own both light and dark rendering. Neutral borders and surfaces form the structure; distinct low-emphasis role hues support scanning without carrying success or failure meaning, while business blue identifies selection, links, and focus. -- The client runtime exposes a read-only history source independent from Session and SessionManager. Each activated source owns its raw entries, paging, live gap repair, and reconnect rebuild; the ordinary conversation snapshot remains the folded Chat projection. Trajectory subscribes to that source, exhausts its paging only while mounted, and lazily derives its event order, context lineage, schema index, and Requests instead of imposing those structures on every conversation consumer. -- Ordinary generation and compaction calls form one chronological Request projection, distinguished by purpose rather than separate collections. Effective prompt state and its change ride the Request that introduced them; compaction and prompt changes are not independent inspection entities. Complete history makes global Request numbering and cumulative usage session-wide rather than tail-window-relative. +- The client runtime exposes a read-only history source independent from Session and SessionManager. Each activated source owns its raw entries, paging, live gap repair, and reconnect rebuild; the ordinary conversation snapshot remains the folded Chat projection. Trajectory opens the source's tail while mounted and requests one older page when the user reaches the loaded range's top, then lazily derives event order, context lineage, schema index, and Requests instead of imposing those structures on every conversation consumer. +- Ordinary generation and compaction calls form one chronological Request projection, distinguished by purpose rather than separate collections. Effective prompt state and its change ride the Request that introduced them; compaction and prompt changes are not independent inspection entities. Request numbering and cumulative usage cover the loaded history window and expand as older pages arrive. - Call schemas come from the active recorded Request header. Keyless snapshot fixtures deliberately replace that catalog with the non-array `{{tools}}` token, which the durable inspection boundary treats as unavailable instead of attempting to project or fabricate schemas. - Selecting a record or Request opens an inspector inside Trajectory. Tabs and Summary sections follow the selected entity: Markdown messages expose rendered, source, provenance, and hierarchy views; tools add JSON payload/result and schema views; Requests add options, usage, timing, and result navigation. Images render as media rather than serialized data. - Turn folding removes all rows after its first record and replaces them with a compact step/tool-call count; Assistant folding applies the same interaction to its tool-call descendants. Global controls fold or expand both levels. -- The separate Waterfall tab is removed. A fixed Overview above the ledger projects every record with known `startedAt` onto three semantic timing lanes using its own duration. Finalized Assistant spans divide the recorded interval at the first non-empty token delta, so distinct TTFT and decoding colors retain their actual ratio; incomplete timing falls back to one Assistant color. Hovering for 500 ms exposes exact start/end, total duration, TTFT, and decoding time without relying on the browser's native tooltip delay. Dragging left or right commits an inclusive interval filter: any record whose active interval overlaps either boundary remains visible, records without known timing leave the focused ledger, and clearing the selection restores the full branch. Wheel gestures zoom the time domain. A right-button click clears the interval selection; dragging instead pans an already zoomed viewport without mutating it. The Overview keeps the full time domain while focused so the selection can be resized or cleared without losing orientation. +- A long ledger initially positions the loaded tail at the bottom and mounts only the viewport's row window plus bounded overscan. Fixed row estimates and virtual spacer rows preserve the loaded scroll range, while selection, timeline focus, folding, search, and bottom following address records by their position in the projection rather than requiring their DOM rows to exist. An explicit loading row covers records until initial positioning finishes and while an older page is pending. Prepending that page restores the prior visible anchor instead of jumping to the new top. +- The separate Waterfall tab is removed. A fixed Overview above the ledger projects every loaded record with known `startedAt` onto three semantic timing lanes using its own duration. While an older prefix remains unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control covers the truncated edge and loads one earlier page without assigning unknown history a fabricated duration; hovering that control suppresses the ordinary timeline cursor. Finalized Assistant spans divide the recorded interval at the first non-empty token delta, so distinct TTFT and decoding colors retain their actual ratio; incomplete timing falls back to one Assistant color. Hovering for 500 ms exposes exact start/end, total duration, TTFT, and decoding time without relying on the browser's native tooltip delay. Dragging left or right commits an inclusive interval filter: any record whose active interval overlaps either boundary remains visible, records without known timing leave the focused ledger, and clearing the selection restores the full branch. Wheel gestures zoom the time domain. A right-button click clears the interval selection; dragging instead pans an already zoomed viewport without mutating it. The Overview keeps the full time domain while focused so the selection can be resized or cleared without losing orientation. - Live history updates retain the ledger's bottom position only while the user is already following its tail. Scrolling upward clears that follow state, so streamed chunks and newly appended records do not interrupt inspection of earlier rows. +- Token streaming reuses the finalized history inspection, layout, Request numbering, and Overview projection. A frame appends only the current partial Assistant cells; text and reasoning deltas do not re-fold the loaded prefix, while message completion, tool lifecycle, compaction, rewrites, and other structural events rebuild the affected projections. +- History folding passes the loaded window's absolute starting sequence into the canonical surface manager. Structural events therefore rebuild only the entries that are present instead of materializing synthetic events for every unloaded sequence before the window. - Trajectory opts into a conversation-owned composer overlay through `data-conversation-composer-overlay`. `ConversationRoot` positions the composer seat and publishes its live height; Trajectory keeps the ledger at full height and reserves that height plus 16 px inside its vertical table and inspector scrollers. Those panes adapt to the available width instead of exposing horizontal scrollbars beneath the overlay. - This local inspector remains independent from the conversation-wide Chat details column. At narrow widths it overlays the ledger and remains dismissible by keyboard or pointer. @@ -32,6 +35,12 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested **Keep one card per Turn and Step.** Rejected: repeated card chrome reduced the number of visible records and made cross-step comparison slower. +**Mount every projected record in the table.** Rejected: record projection remains useful for search, timing, and navigation, but keeping every row and its descendants in the DOM makes browser rendering scale with the complete session instead of the visible viewport. + +**Exhaust every history page when Trajectory mounts.** Rejected: complete session metrics would be immediately available, but transporting and repeatedly projecting old chunk-heavy pages delays inspection of the current tail. On-demand backward paging makes that cost follow the user's navigation. + +**Rebuild the loaded ledger for every streamed token chunk.** Rejected: virtual rows bound DOM work but do not make repeated history folding cheap. Keeping finalized projections stable makes ordinary deltas proportional to the current partial, while structural events remain the explicit full-rebuild boundary. + **Flatten every record without Turn or Request boundaries.** Rejected: a trajectory is not merely a log stream; those boundaries preserve the causal structure without consuming dedicated rows. **Reuse the global Chat details column.** Rejected: it would couple local inspection to conversation navigation and make a row click unexpectedly change another view's state. @@ -44,4 +53,4 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested ## Consequences -Trajectory shows more useful records per viewport while retaining Turn and Request orientation. Context rewrites and compactions remain inline with their surrounding history, while a rewind begins a successor branch that inherits only the retained prefix. The floating composer leaves the ledger visible to the viewport edge without covering its final rows or hiding horizontal controls. The main ledger omits token usage and duration so content receives the available width; the local inspector exposes those facts together with full payloads, provenance, schemas, and request timing. The Overview uses recorded start/duration and token-boundary facts without fabricating live elapsed time, and its inclusive focus behavior matches the interaction users already know from Chrome DevTools Network. Focused component tests pin tail following, timing projection, delayed detail disclosure, folding, record and interval selection, entity-specific tabs, and running/error semantics; the assembled Web snapshot pins the ledger, Overview timing details, composer overlay geometry, and inspector through the real client composition. +Trajectory shows more useful records per viewport while retaining Turn and Request orientation. Context rewrites and compactions remain inline with their surrounding history, while a rewind begins a successor branch that inherits only the retained prefix. The floating composer leaves the ledger visible to the viewport edge without covering its final rows or hiding horizontal controls. The main ledger omits token usage and duration so content receives the available width; the local inspector exposes those facts together with full payloads, provenance, schemas, and request timing. The Overview uses recorded start/duration and token-boundary facts without fabricating live elapsed time, and its inclusive focus behavior matches the interaction users already know from Chrome DevTools Network. Tail-first paging bounds initial transport and projection work, virtualization bounds mounted row elements, and incremental partial projection removes loaded-history length from ordinary token-frame work; structural rebuilds remain linear in the loaded window rather than its absolute tail sequence. Focused component tests pin tail-first paging, prepend anchoring, the virtual window, tail following, streaming structural sharing, high-sequence window folding, timing projection, delayed detail disclosure, folding, record and interval selection, entity-specific tabs, and running/error semantics; the assembled Web snapshot pins the ledger, Overview timing details, composer overlay geometry, and inspector through the real client composition. diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md index df2a3d2661..3c17b5f3bd 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md @@ -16,13 +16,16 @@ Status: implemented - 事件类型与内容构成两个稳定列。角色标签朝内容侧对齐,嵌套子工具略微缩进,内容预览使用 CSS 截断以适应可用宽度。token 用量和耗时留在检查器中。 - 产品正文使用现有无衬线字体栈。轮次 id、token 数、耗时、工具调用、原始载荷和其他机器数据使用现有代码字体栈。 - 现有主题 token 同时负责亮色和暗色渲染。中性边框与表面构成整体结构;区分度较低的角色色帮助扫读而不表达成功或失败语义,业务蓝色则标识选择状态、链接和焦点。 -- 客户端 runtime 提供独立于 Session 和 SessionManager 的只读历史数据源。每个已激活的数据源自行拥有原始条目、分页、实时缺口修复和重连重建;普通会话快照仍然只是 Chat 所需的折叠投影。Trajectory 订阅该数据源,仅在挂载期间补齐全部历史,并按需派生事件顺序、上下文谱系、schema 索引和请求,避免让所有会话消费者承担这些结构。 -- 普通生成调用与压缩调用形成一条按时间排序的请求投影,以 purpose 区分而不是放入不同集合。生效的提示词状态及其变化附着在引入它们的请求上;压缩和提示词变化都不是独立检查实体。完整历史使全局请求编号和累计用量以整个会话为范围,而不是相对于末尾窗口。 +- 客户端 runtime 提供独立于 Session 和 SessionManager 的只读历史数据源。每个已激活的数据源自行拥有原始条目、分页、实时缺口修复和重连重建;普通会话快照仍然只是 Chat 所需的折叠投影。Trajectory 在挂载期间打开该数据源的尾部,当用户到达已加载范围顶部时请求一页更早的历史,再按需派生事件顺序、上下文谱系、schema 索引和请求,避免让所有会话消费者承担这些结构。 +- 普通生成调用与压缩调用形成一条按时间排序的请求投影,以 purpose 区分而不是放入不同集合。生效的提示词状态及其变化附着在引入它们的请求上;压缩和提示词变化都不是独立检查实体。请求编号和累计用量覆盖已加载的历史窗口,并随更早页面到达而扩展。 - 调用 schema 来自当前生效且已记录的请求头。无密钥快照 fixture(测试前置数据)有意将该目录替换为非数组 token `{{tools}}`,持久化检查边界会将其视为不可用,而不是尝试投影或虚构 schema。 - 选择记录或请求后,轨迹视图内部会打开检查器,其标签页和概览区域随实体类型变化:Markdown 消息提供渲染、源码、来源和层级视图;工具提供 JSON 载荷/结果和 schema 视图;请求提供选项、用量、计时和结果跳转。图片以媒体形式渲染,而不是显示为序列化数据。 - 折叠轮次时保留其第一条记录,将后续行替换为紧凑的步骤和工具调用数量;折叠助手时对其工具调用后代应用相同交互。全局控件可以分别折叠或展开这两个层级。 -- 移除独立的 waterfall(瀑布式事件)标签页。固定在记录表上方的 Overview 区域将所有 `startedAt` 已知的记录按各自耗时投影到三条语义计时轨道。已完成的助手时间条以首个非空 token 增量为分界,用不同颜色按真实比例表示 TTFT 与解码时间;计时不完整时退化为单一助手色。悬停 500 ms 后会显示精确起止时刻、总耗时、TTFT 和解码时间,而不依赖浏览器原生 tooltip 的延迟。向左或向右拖动会提交包含边界的区间筛选:任何活动区间与所选区间相交的记录都会保留,计时未知的记录会从聚焦后的记录表中移除,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除区间选择;右键拖动则只会平移已放大的 viewport,不会改变该选区。聚焦后,Overview 区域仍保留完整时间范围,以便在不失去方位的情况下调整或清除选择。 +- 长记录表初始时将已加载尾部置于底部,只挂载视口对应的行窗口及有界的额外缓冲行。固定的行高估算与虚拟占位行保留已加载内容的完整滚动范围;选择、时间线聚焦、折叠、搜索和末尾跟随均按记录在投影中的位置定位,不要求对应 DOM 行已存在。初始定位完成前以及更早页面仍在等待时,明确的加载行会遮住真实记录。该页面补入后,会恢复此前的可见锚点,而不是跳到新的顶部。 +- 移除独立的 waterfall(瀑布式事件)标签页。固定在记录表上方的 Overview 区域将所有 `startedAt` 已知的已加载记录按各自耗时投影到三条语义计时轨道。仍有更早前缀尚未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会遮住截断边缘并加载一页更早历史,而不会为未知历史虚构耗时;悬停在该控件上会隐藏普通的时间线光标。已完成的助手时间条以首个非空 token 增量为分界,用不同颜色按真实比例表示 TTFT 与解码时间;计时不完整时退化为单一助手色。悬停 500 ms 后会显示精确起止时刻、总耗时、TTFT 和解码时间,而不依赖浏览器原生 tooltip 的延迟。向左或向右拖动会提交包含边界的区间筛选:任何活动区间与所选区间相交的记录都会保留,计时未知的记录会从聚焦后的记录表中移除,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除区间选择;右键拖动则只会平移已放大的 viewport,不会改变该选区。聚焦后,Overview 区域仍保留完整时间范围,以便在不失去方位的情况下调整或清除选择。 - 实时历史更新仅在用户已经跟随记录表末尾时保留底部位置。向上滚动会清除跟随状态,因此流式分块和新追加的记录不会打断对旧记录的检查。 +- token 流式输出会复用已完成历史的检查结果、布局、请求编号和 Overview 投影。每个帧只追加当前未完成助手的单元格;文本与推理(reasoning)增量不会重新折叠已加载前缀,而消息完成、工具生命周期、压缩、`rewrite` 及其他结构事件会重建受影响的投影。 +- 历史折叠会把已加载窗口的绝对起始序号传给规范 surface manager。因此,结构事件只重建实际存在的条目,而不会为窗口之前每个尚未加载的序号实体化合成事件。 - Trajectory 通过 `data-conversation-composer-overlay` 启用由会话持有的 composer 浮层模式。`ConversationRoot` 负责定位 composer seat 并发布其实时高度;Trajectory 让记录表保持全高,并在记录表与检查器的纵向滚动容器内预留该高度加 16 px。这两个窗格会根据可用宽度自适应,而不会在浮层下方暴露横向滚动条。 - 此局部检查器与会话级 Chat 详情栏相互独立。在窄屏下,检查器会覆盖记录表,并且仍可通过键盘或指针关闭。 @@ -32,6 +35,12 @@ Status: implemented **每个轮次和步骤保留一张卡片。** 不予采纳:重复的卡片框架减少了可见记录数量,并降低了跨步骤比较的速度。 +**在表格中挂载每条投影记录。** 不予采纳:记录投影仍可用于搜索、计时和导航,但把每一行及其后代都保留在 DOM 中,会使浏览器渲染开销随完整会话增长,而非随可见视口增长。 + +**Trajectory 挂载时补齐所有历史页面。** 不予采纳:完整会话指标可以立即获得,但传输并反复投影含大量分片的旧页面会延迟对当前尾部的检查。按需向前分页会让这项成本随用户导航产生。 + +**每收到一个流式 token 分片就重建已加载记录表。** 不予采纳:虚拟行限制了 DOM 工作量,却不会让反复折叠历史变得低廉。保持已完成投影稳定,可以让普通增量的成本只随当前未完成部分增长,而结构事件仍是显式的完整重建边界。 + **不使用轮次或请求边界,将所有记录完全扁平化。** 不予采纳:轨迹并非普通日志流;这些边界无需占用独立行,也能保留因果结构。 **复用全局 Chat 详情栏。** 不予采纳:这会让局部检查与会话导航耦合,还会使行点击意外改变另一个视图的状态。 @@ -44,4 +53,4 @@ Status: implemented ## 后果 -轨迹视图在保留轮次与请求定位的同时,每个视口可以显示更多有效记录。上下文 `rewrite` 与压缩保持在周边历史中的原始位置,`rewind` 则建立仅继承保留前缀的后继分支。浮动 composer 让记录表一直显示到视口边缘,同时不会遮住最后几行,也不会隐藏横向控件。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器展示这些数据以及完整载荷、来源、schema 和请求计时。Overview 区域使用记录的开始时间、耗时与 token 边界数据,而不虚构实时流逝时间,其包含边界的聚焦行为与用户熟悉的 Chrome DevTools Network 交互一致。针对性组件测试锁定末尾跟随、计时投影、延迟展示详情、折叠、记录与区间选择、实体特定标签页和运行/错误语义;组装后的 Web 快照则通过真实客户端组合锁定记录表、Overview 计时详情、composer 浮层几何形状与检查器。 +轨迹视图在保留轮次与请求定位的同时,每个视口可以显示更多有效记录。上下文 `rewrite` 与压缩保持在周边历史中的原始位置,`rewind` 则建立仅继承保留前缀的后继分支。浮动 composer 让记录表一直显示到视口边缘,同时不会遮住最后几行,也不会隐藏横向控件。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器展示这些数据以及完整载荷、来源、schema 和请求计时。Overview 区域使用记录的开始时间、耗时与 token 边界数据,而不虚构实时流逝时间,其包含边界的聚焦行为与用户熟悉的 Chrome DevTools Network 交互一致。尾部优先分页限制初始传输和投影工作量,虚拟化限制已挂载的行元素数量,未完成部分的增量投影则让普通 token 帧的工作量不再随已加载历史长度增长;结构重建的复杂度仍与已加载窗口线性相关,而非与尾部的绝对序号线性相关。针对性组件测试锁定尾部优先分页、向前补页锚定、虚拟窗口、末尾跟随、流式输出的结构共享、高序号窗口折叠、计时投影、延迟展示详情、折叠、记录与区间选择、实体特定标签页和运行/错误语义;组装后的 Web 快照则通过真实客户端组合锁定记录表、Overview 计时详情、composer 浮层几何形状与检查器。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 03fd24e5b7..64ce3eda17 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -47,6 +47,7 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`@opentelemetry/sdk-logs`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | | [`@shikijs/langs`](https://github.com/shikijs/shiki) | MIT | | [`@standard-schema/spec`](https://github.com/standard-schema/standard-schema) | MIT | +| [`@tanstack/react-virtual`](https://github.com/TanStack/virtual) | MIT | | [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT | | [`anser`](https://github.com/IonicaBizau/anser) | MIT | | [`chokidar`](https://github.com/paulmillr/chokidar) | MIT | diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index c3850804ff..dce2eb641b 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -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/runtime/README.md -README.md: 89e58f967f852bb0786a5b7d73fa8e924fa282e0 -README.zh.md: 960e2fceede1b500af9ee2063ec9283e2b7b271a +README.md: 3e9bcdd587764ee21326c27e0cd642914cf84f8f +README.zh.md: 8e13bc3d09ae220f7a64640b21892c03a37bae29 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 89e58f967f..3e9bcdd587 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions. +Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers, loading the current tail first and prepending one older page only when its consumer requests it; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions. ## Workspace and Session lists diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 960e2fceed..8e13bc3d09 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。 +客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本,先加载当前尾部,并仅在消费方请求时向前补入一页更早历史;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。 ## Workspace 与 Session 列表 diff --git a/packages/client/runtime/src/client/contract/session-history.ts b/packages/client/runtime/src/client/contract/session-history.ts index 2b6679fd05..ab8e38847d 100644 --- a/packages/client/runtime/src/client/contract/session-history.ts +++ b/packages/client/runtime/src/client/contract/session-history.ts @@ -17,11 +17,17 @@ export interface SessionHistoryFace extends ObservableSnapshot { readonly sessionId: SessionId /** - * Load the tail and exhaust every available older page. - * @param signal - Consumer lifetime; abort is observed between page requests. - * @returns When the available ledger is complete or stops advancing. + * Load the current tail without reading older pages. + * @param signal - Consumer lifetime. + * @returns When the tail is ready or loading fails. */ - loadAll(signal?: AbortSignal): Promise + loadTail(signal?: AbortSignal): Promise + /** + * Prepend one older page when the current window has a predecessor. + * @param signal - Consumer lifetime. + * @returns Whether the loaded window advanced. + */ + loadOlder(signal?: AbortSignal): Promise } /** Runtime service resolving independent history sources. */ diff --git a/packages/client/runtime/src/client/session-history/source.ts b/packages/client/runtime/src/client/session-history/source.ts index 4f3e86be6a..321cab2ac7 100644 --- a/packages/client/runtime/src/client/session-history/source.ts +++ b/packages/client/runtime/src/client/session-history/source.ts @@ -36,7 +36,6 @@ export class SessionHistorySource implements SessionHistoryFace { 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(() => { @@ -73,37 +72,34 @@ export class SessionHistorySource implements SessionHistoryFace { } /** - * Load the tail and exhaust all available older pages. + * Load the current tail without reading older pages. * @param signal - Consumer lifetime. - * @returns When paging completes, fails to advance, or is aborted. + * @returns When the tail is ready or loading fails. */ - async loadAll(signal?: AbortSignal): Promise { - if (signal?.aborted === true) return + async loadTail(signal?: AbortSignal): Promise { + if (isAborted(signal)) return this.trackConsumer(signal) await this.open() - while ( - !isAborted(signal) - && this.state === 'ready' - && this.hasMore - ) { - const previousBaseSeq = this.baseSeq - await this.loadOlder() - if (isAborted(signal) || this.baseSeq === previousBaseSeq) return - } } - /** Rebuild and page for whichever mounted consumers survive a reconnect. */ + /** + * Prepend one older page when the current window has a predecessor. + * @param signal - Consumer lifetime. + * @returns Whether the loaded window advanced. + */ + async loadOlder(signal?: AbortSignal): Promise { + if (isAborted(signal)) return false + this.trackConsumer(signal) + await this.open() + if (isAborted(signal)) return false + const previousBaseSeq = this.baseSeq + await this.loadOlderPage() + return this.baseSeq !== previousBaseSeq + } + + /** Rebuild the tail for whichever mounted consumers survive a reconnect. */ private async loadForConsumers(): Promise { await this.open() - while ( - this.hasConsumer() - && this.state === 'ready' - && this.hasMore - ) { - const previousBaseSeq = this.baseSeq - await this.loadOlder() - if (!this.hasConsumer() || this.baseSeq === previousBaseSeq) return - } } /** @@ -161,7 +157,6 @@ export class SessionHistorySource implements SessionHistoryFace { this.olderPromise = null this.liveBuffer = [] this.streamPublishToken = null - this.streamBaseInspection = null this.streamPartial = null } @@ -234,7 +229,7 @@ export class SessionHistorySource implements SessionHistoryFace { } } - private loadOlder(): Promise { + private loadOlderPage(): Promise { if (this.olderPromise !== null) return this.olderPromise if (this.state !== 'ready' || !this.hasMore) return Promise.resolve() const generation = this.generation @@ -339,8 +334,7 @@ export class SessionHistorySource implements SessionHistoryFace { this.inspectionCache = { entries: this.entries, value: inspection } return false } - const base = this.streamBaseInspection ?? this.currentInspection() - this.streamBaseInspection = base + const base = this.currentInspection() if ( this.streamPartial === null || this.streamPartial.turn !== turn @@ -382,7 +376,6 @@ export class SessionHistorySource implements SessionHistoryFace { /** 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() } diff --git a/packages/client/runtime/tests/session-history-source.spec.ts b/packages/client/runtime/tests/session-history-source.spec.ts index bcf25cd933..5458d870c8 100644 --- a/packages/client/runtime/tests/session-history-source.spec.ts +++ b/packages/client/runtime/tests/session-history-source.spec.ts @@ -16,7 +16,7 @@ function histResponse(events: SessionEvent[], hasMore = false) { } describe('SessionHistorySource', () => { - it('loads every older page without changing a Chat session', async () => { + it('loads the tail first and prepends older pages on demand', async () => { const pages = [ plainTurn(0, 0, '最早问', '最早答'), plainTurn(6, 1, '中间问', '中间答'), @@ -30,7 +30,16 @@ describe('SessionHistorySource', () => { } const source = new SessionHistorySource(SID, api) - await source.loadAll() + await source.loadTail() + + expect(api.callsOf('session.history')).toHaveLength(1) + expect(source.getSnapshot().hasMore).toBe(true) + expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq)) + .toEqual([13, 15]) + + expect(await source.loadOlder()).toBe(true) + expect(await source.loadOlder()).toBe(true) + expect(await source.loadOlder()).toBe(false) expect(api.callsOf('session.history')).toHaveLength(3) expect(source.getSnapshot().hasMore).toBe(false) @@ -42,7 +51,7 @@ describe('SessionHistorySource', () => { const api = new FakeApiClient() api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答')) const source = new SessionHistorySource(SID, api) - await source.loadAll() + await source.loadTail() const before = source.getSnapshot() source.handleMuxFrame({ @@ -60,7 +69,7 @@ describe('SessionHistorySource', () => { const api = new FakeApiClient() api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答')) const source = new SessionHistorySource(SID, api) - await source.loadAll() + await source.loadTail() const frames: FrameRequestCallback[] = [] vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { frames.push(callback) @@ -132,13 +141,14 @@ describe('SessionHistorySource', () => { })) const source = new SessionHistorySource(SID, api) - await source.loadAll() + await source.loadTail() + expect(await source.loadOlder()).toBe(false) expect(api.callsOf('session.history')).toHaveLength(2) expect(source.getSnapshot().hasMore).toBe(true) }) - it('observes consumer cancellation between older pages', async () => { + it('finishes an already started older page after consumer cancellation', async () => { const middle = deferred>>() const olderStarted = deferred() const api = new FakeApiClient() @@ -151,7 +161,8 @@ describe('SessionHistorySource', () => { } const source = new SessionHistorySource(SID, api) const controller = new AbortController() - const complete = source.loadAll(controller.signal) + await source.loadTail(controller.signal) + const complete = source.loadOlder(controller.signal) await olderStarted.promise controller.abort() middle.resolve(ok({ @@ -159,7 +170,7 @@ describe('SessionHistorySource', () => { hasMore: true, })) - await complete + expect(await complete).toBe(true) expect(api.callsOf('session.history')).toHaveLength(2) expect(source.getSnapshot().hasMore).toBe(true) diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index 36e56c4569..7868f4a913 100644 --- a/packages/client/ui-trajectory/README.i18n.yaml +++ b/packages/client/ui-trajectory/README.i18n.yaml @@ -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-trajectory/README.md -README.md: 5d0ea3bbbbfca2b8c0ee02ed07ca956fbd377e11 -README.zh.md: 1bfff4c18ea2e834781e2c6cb76773595eeed5ad +README.md: 18f8e637c0588a45c2bf33f7c8dbc36aee5def22 +README.zh.md: f81482cb4de7d5a76220145e99acde3aa4e33d17 diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index 5d0ea3bbbb..18f8e637c0 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. A fixed Overview above the ledger projects real record start/duration timing from left to right; Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full branch. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. Streaming updates keep the ledger pinned only when it was already at the bottom, so reading earlier records suspends tail following. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. The runtime's independent history source supplies raw context lineage and projects cancellation-frozen Assistant and Tool records, so Trajectory neither reads nor changes the Chat conversation snapshot. The package remains a pure-consumer plugin (registers one view tab into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8. +Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned and while an older page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full branch. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. The runtime's independent history source supplies raw context lineage and projects cancellation-frozen Assistant and Tool records, so Trajectory neither reads nor changes the Chat conversation snapshot. The package remains a pure-consumer plugin (registers one view tab into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8. ## Model Experience diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index 1bfff4c18e..f81482cb4d 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带数值所有者的压缩仍位于其所属轮次内。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。仅当记录表在流式更新前已经位于底部时,更新才会保持贴底;向上阅读旧记录会暂停跟随。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。运行时的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包(package)保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并)。契约:api-contracts v3 §8。 +Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带数值所有者的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前以及更早页面仍在等待时,记录表会用明确的加载行遮住真实记录。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。运行时的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包(package)保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并)。契约:api-contracts v3 §8。 ## 模型体验 diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index 8da6559866..d3c640b13b 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -35,6 +35,7 @@ }, "license": "BSD-3-Clause", "dependencies": { + "@tanstack/react-virtual": "^3.14.9", "diff": "^9.0.0" }, "peerDependencies": { @@ -42,7 +43,8 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", "cordis": "^4.0.0-rc.7", - "react": "^18.2.0" + "react": "^18.2.0", + "react-dom": "^18.2.0" }, "devDependencies": { "@deepseek-ai/dsh-client-runtime": "workspace:^", @@ -51,8 +53,10 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", + "@types/react-dom": "~18.3.0", "cordis": "^4.0.0-rc.7", - "react": "^18.2.0" + "react": "^18.2.0", + "react-dom": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index 69d9d620e3..e56cf24dc4 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -13,6 +13,7 @@ } .tablePane { + position: relative; flex: 1; min-width: 0; overflow-x: hidden; @@ -21,6 +22,53 @@ container: trajectory-table / inline-size; } +.historyLoading { + position: sticky; + z-index: 5; + top: 0; + height: 0; + overflow: visible; + pointer-events: none; +} + +.historyLoadingBar { + display: flex; + width: 100%; + height: 30px; + align-items: center; + justify-content: center; + gap: 6px; + box-sizing: border-box; + border-bottom: 1px solid var(--dsw-alias-border-l2); + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-secondary); + font: var(--dsw-font-xxs-12); +} + +.historyLoadingSpinner { + width: 10px; + height: 10px; + box-sizing: border-box; + border: 1.5px solid var(--dsw-alias-border-l2); + border-top-color: var(--dsw-alias-state-business-primary); + border-radius: 50%; + animation: history-loading-spin 700ms linear infinite; +} + +.table:not([data-scroll-ready='true']) { + visibility: hidden; +} + +@keyframes history-loading-spin { + to { transform: rotate(360deg); } +} + +@media (prefers-reduced-motion: reduce) { + .historyLoadingSpinner { + animation: none; + } +} + .table { --trajectory-turn-accent: color-mix( in srgb, @@ -79,7 +127,17 @@ white-space: nowrap; } -.table tbody tr:not([data-collapsed-summary]) { +.table tbody .virtualSpacer { + pointer-events: none; +} + +.table tbody .virtualSpacer td { + height: var(--trajectory-virtual-spacer-height); + padding: 0; + border: 0; +} + +.table tbody tr:not([data-collapsed-summary]):not([data-virtual-spacer]) { cursor: default; outline: none; transition: @@ -91,7 +149,7 @@ opacity: 0.24; } -.table tbody tr:not([data-collapsed-summary]):not([data-selected='true']):hover { +.table tbody tr:not([data-collapsed-summary]):not([data-virtual-spacer]):not([data-selected='true']):hover { background: var(--dsw-alias-interactive-bg-hover); } @@ -620,6 +678,10 @@ white-space: nowrap; } +.toolCallOnly { + color: var(--dsw-alias-label-tertiary); +} + .table tbody tr[data-collapsed-summary='turn'] td, .table tbody tr[data-collapsed-summary='assistant'] td { height: 20px; diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 6f37c852c5..8c6ff0bc2e 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import type { CSSProperties, ReactNode } from 'react' +import { useVirtualizer } from '@tanstack/react-virtual' import { IconChevronRightOutline14, IconSettingsOutline16, @@ -23,6 +24,12 @@ import { trajectoryPreviewText, type TrajectoryTurnModel } from './layout.ts' import css from './TrajectoryTable.module.css' const BOTTOM_FOLLOW_THRESHOLD_PX = 2 +const OLDER_LOAD_THRESHOLD_PX = 48 +const VIRTUALIZATION_THRESHOLD = 100 +const VIRTUAL_ROW_HEIGHT_PX = 30 +const VIRTUAL_FINAL_REQUEST_HEIGHT_PX = 9 +const VIRTUAL_OVERSCAN_ROWS = 12 +const VIRTUAL_INITIAL_VIEWPORT_HEIGHT_PX = 600 const KIND_LABEL: Record = { system: 'SYSTEM', @@ -195,6 +202,16 @@ type RequestBoundaryStyle = CSSProperties & { '--request-boundary-offset': string } +type VirtualSpacerStyle = CSSProperties & { + '--trajectory-virtual-spacer-height': string +} + +interface OlderLoadAnchor { + readonly historyStartSeq: number | undefined + readonly scrollHeight: number + readonly scrollTop: number +} + function clampDetailsWidth(width: number, splitWidth: number): number { const maxWidth = Math.max( DETAILS_MIN_WIDTH, @@ -312,6 +329,16 @@ export interface TrajectoryTableProps { onRecordSelect?: (index: number) => void /** One externally requested record selection; a new object repeats the request. */ recordSelection?: { readonly index: number } | null + /** One externally requested record focus without changing inspector selection. */ + recordFocus?: { readonly index: number } | null + /** Whether the initial history tail is still loading. */ + historyLoading?: boolean + /** First loaded history node, used to preserve scroll position after prepending a page. */ + historyStartSeq?: number | undefined + /** Whether one older history page can be requested. */ + hasOlderRecords?: boolean + /** Load one older history page. */ + onLoadOlder?: () => Promise /** Clear selection state owned by the ledger host. */ onClearSelection?: () => void /** Turn ids whose rows after the first are folded into a summary. */ @@ -399,6 +426,11 @@ function flattenRecords(turns: readonly TrajectoryTurnModel[]): TableRecord[] { }) } +function virtualRecordHeight(record: TableRecord, final: boolean): number { + if (record.cell.requestOnly !== true) return VIRTUAL_ROW_HEIGHT_PX + return final ? VIRTUAL_FINAL_REQUEST_HEIGHT_PX : 0 +} + function filterRecords( records: readonly TableRecord[], matches: ReadonlySet, @@ -492,7 +524,6 @@ function collapseTurnRecords( records: readonly TableRecord[], collapsedTurns: ReadonlySet, ): TableRecord[] { - if (collapsedTurns.size === 0) return [...records] const recordsByTurn = new Map() for (const record of records) { if (record.turn === null) continue @@ -552,7 +583,6 @@ function collapseAssistantRecords( records: readonly TableRecord[], collapsedAssistants: ReadonlySet, ): TableRecord[] { - if (collapsedAssistants.size === 0) return [...records] const out: TableRecord[] = [] for (let i = 0; i < records.length; i++) { const record = records[i] @@ -1538,6 +1568,11 @@ export function TrajectoryTable({ onSelectedIndexChange, onRecordSelect, recordSelection = null, + recordFocus = null, + historyLoading = false, + historyStartSeq, + hasOlderRecords = false, + onLoadOlder, onClearSelection, collapsedTurns, onToggleTurn, @@ -1554,18 +1589,65 @@ export function TrajectoryTable({ const [toolRequestOffset, setToolRequestOffset] = useState(null) const detailsResizeDrag = useRef(null) const appliedRecordSelection = useRef(null) + const appliedRecordFocus = useRef(null) const tabHistory = useRef>(new Set(['overview'])) + const rootRef = useRef(null) + const tablePaneRef = useRef(null) + const followsTableTail = useRef(false) + const tableScrollInitialized = useRef(false) + const [tableScrollReady, setTableScrollReady] = useState(false) + const pendingScrollIndex = useRef(null) + const loadingOlder = useRef(false) + const [olderLoading, setOlderLoading] = useState(false) + const olderLoadAnchor = useRef(null) useEffect(() => { onSelectedIndexChange?.(selectedIndex) }, [onSelectedIndexChange, selectedIndex]) const allRecords = useMemo(() => flattenRecords(turns), [turns]) - const requestNumbers = indexRequestNumbers(allRecords, sessionRequestNumbers) - const records = searchMatchIndexes === null - ? collapseAssistantRecords( - collapseTurnRecords(allRecords, collapsedTurns), - collapsedAssistants, - ) - : filterRecords(allRecords, searchMatchIndexes) + const requestNumbers = useMemo( + () => indexRequestNumbers(allRecords, sessionRequestNumbers), + [allRecords, sessionRequestNumbers], + ) + const records = useMemo(() => { + if (searchMatchIndexes !== null) return filterRecords(allRecords, searchMatchIndexes) + const turnRecords = collapsedTurns.size === 0 + ? allRecords + : collapseTurnRecords(allRecords, collapsedTurns) + return collapsedAssistants.size === 0 + ? turnRecords + : collapseAssistantRecords(turnRecords, collapsedAssistants) + }, [allRecords, collapsedAssistants, collapsedTurns, searchMatchIndexes]) + const virtualizationEnabled = records.length > VIRTUALIZATION_THRESHOLD + const rowVirtualizer = useVirtualizer({ + count: virtualizationEnabled ? records.length : 0, + enabled: virtualizationEnabled, + estimateSize: (index) => { + const record = records[index] + return record === undefined + ? VIRTUAL_ROW_HEIGHT_PX + : virtualRecordHeight(record, index === records.length - 1) + }, + getItemKey: (index) => { + const record = records[index] + return record === undefined + ? index + : `${record.cell.index}:${record.collapsedSummaryKind ?? 'record'}` + }, + getScrollElement: () => tablePaneRef.current, + initialRect: { width: 0, height: VIRTUAL_INITIAL_VIEWPORT_HEIGHT_PX }, + overscan: VIRTUAL_OVERSCAN_ROWS, + }) + const virtualRows = virtualizationEnabled ? rowVirtualizer.getVirtualItems() : [] + const virtualTop = virtualRows[0]?.start ?? 0 + const virtualBottom = virtualRows.length === 0 + ? 0 + : Math.max(0, rowVirtualizer.getTotalSize() - (virtualRows.at(-1)?.end ?? 0)) + const renderedRecords = virtualizationEnabled + ? virtualRows.flatMap((row) => { + const record = records[row.index] + return record === undefined ? [] : [{ record, position: row.index }] + }) + : records.map((record, position) => ({ record, position })) const requestBoundaryRuns = indexRequestBoundaryRuns(records) const selected = allRecords.find(record => record.cell.index === selectedIndex) const selectedPrompt = selected?.cell.kind === 'system' @@ -1697,7 +1779,13 @@ export function TrajectoryTable({ ) return appliedRecordSelection.current = recordSelection selectRecord(recordSelection.index) + pendingScrollIndex.current = recordSelection.index }, [recordSelection, selectRecord]) + useEffect(() => { + if (recordFocus === null || appliedRecordFocus.current === recordFocus) return + appliedRecordFocus.current = recordFocus + pendingScrollIndex.current = recordFocus.index + }, [recordFocus]) const selectRequest = ( request: SelectedRequest, @@ -1734,11 +1822,6 @@ export function TrajectoryTable({ // open its summary, and remember the row to scroll once the un-collapsed // ledger has rendered. Not-found leaves the request pending (`turns` in the // deps retries as history pages in); the ack clears the store field. - const rootRef = useRef(null) - const tablePaneRef = useRef(null) - const followsTableTail = useRef(false) - const tableScrollInitialized = useRef(false) - const pendingScrollIndex = useRef(null) const openRecordSummaryRef = useRef(openRecordSummary) openRecordSummaryRef.current = openRecordSummary useEffect(() => { @@ -1752,27 +1835,122 @@ export function TrajectoryTable({ useEffect(() => { const index = pendingScrollIndex.current if (index === null) return + const position = records.findIndex(record => + record.cell.index === index && record.collapsedSummary === undefined) + if (position === -1) return + pendingScrollIndex.current = null + if (virtualizationEnabled) { + rowVirtualizer.scrollToIndex(position, { behavior: 'smooth', align: 'center' }) + return + } const row = rootRef.current ?.querySelector(`tr[data-record-index="${index}"]`) - if (row === undefined || row === null) return - pendingScrollIndex.current = null /* v8 ignore next -- jsdom lacks scrollIntoView; browsers always have it. */ - if (typeof row.scrollIntoView === 'function') { + if (row !== undefined && row !== null && typeof row.scrollIntoView === 'function') { row.scrollIntoView({ behavior: 'smooth', block: 'center' }) } - }) + }, [records, rowVirtualizer, virtualizationEnabled]) + useEffect(() => { + if (timelineFocusIndexes === null || timelineFocusIndexes.size === 0) return + const focusedPositions = records.flatMap((record, position) => + record.collapsedSummary === undefined + && record.cell.requestOnly !== true + && timelineFocusIndexes.has(record.cell.index) + ? [position] + : []) + const first = focusedPositions.at(0) + const last = focusedPositions.at(-1) + if (first === undefined || last === undefined) return + if (!virtualizationEnabled) { + const ledger = rootRef.current + if (ledger === null) return + const focusedRows = [ + ...ledger.querySelectorAll('tr[data-timeline-focus="inside"]'), + ] + const firstRow = focusedRows.at(0) + const lastRow = focusedRows.at(-1) + if (firstRow === undefined || lastRow === undefined) return + const focusHeight = + lastRow.getBoundingClientRect().bottom - firstRow.getBoundingClientRect().top + const target = focusHeight > ledger.clientHeight + ? firstRow + : focusedRows[Math.floor((focusedRows.length - 1) / 2)] + /* v8 ignore next -- jsdom lacks scrollIntoView; browsers always have it. */ + if (target !== undefined && typeof target.scrollIntoView === 'function') { + target.scrollIntoView({ + behavior: 'smooth', + block: focusHeight > ledger.clientHeight ? 'start' : 'center', + }) + } + return + } + const paneHeight = tablePaneRef.current?.clientHeight ?? 0 + let focusHeight = 0 + for (let position = first; position <= last; position++) { + const record = records[position] + if (record === undefined) continue + focusHeight += virtualRecordHeight( + record, + position === records.length - 1, + ) + } + rowVirtualizer.scrollToIndex( + focusHeight > paneHeight ? first : focusedPositions[Math.floor((focusedPositions.length - 1) / 2)] ?? first, + { + behavior: 'smooth', + align: focusHeight > paneHeight ? 'start' : 'center', + }, + ) + }, [records, rowVirtualizer, timelineFocusIndexes, virtualizationEnabled]) + const requestOlder = useCallback((pane: HTMLDivElement) => { + if ( + !hasOlderRecords + || onLoadOlder === undefined + || loadingOlder.current + || pane.scrollTop > OLDER_LOAD_THRESHOLD_PX + ) return + loadingOlder.current = true + setOlderLoading(true) + olderLoadAnchor.current = { + historyStartSeq, + scrollHeight: pane.scrollHeight, + scrollTop: pane.scrollTop, + } + void onLoadOlder().then((advanced) => { + if (!advanced) olderLoadAnchor.current = null + }).finally(() => { + loadingOlder.current = false + setOlderLoading(false) + }) + }, [hasOlderRecords, historyStartSeq, onLoadOlder]) useLayoutEffect(() => { const pane = tablePaneRef.current if (pane === null) return - if (!tableScrollInitialized.current) { - tableScrollInitialized.current = true - followsTableTail.current = - pane.scrollHeight - pane.clientHeight - pane.scrollTop - <= BOTTOM_FOLLOW_THRESHOLD_PX + const anchor = olderLoadAnchor.current + if (anchor !== null && anchor.historyStartSeq !== historyStartSeq) { + pane.scrollTop = anchor.scrollTop + pane.scrollHeight - anchor.scrollHeight + olderLoadAnchor.current = null + followsTableTail.current = false return } - if (followsTableTail.current) pane.scrollTop = pane.scrollHeight - }, [turns]) + if (!tableScrollInitialized.current) { + if (historyLoading) return + tableScrollInitialized.current = true + followsTableTail.current = true + if (virtualizationEnabled) rowVirtualizer.scrollToEnd({ behavior: 'auto' }) + else pane.scrollTop = pane.scrollHeight + setTableScrollReady(true) + return + } + if (!followsTableTail.current) return + if (virtualizationEnabled) rowVirtualizer.scrollToEnd({ behavior: 'auto' }) + else pane.scrollTop = pane.scrollHeight + }, [historyLoading, historyStartSeq, rowVirtualizer, turns, virtualizationEnabled]) + + const loadingLabel = olderLoading + ? 'Loading earlier history…' + : 'Loading trajectory…' + const showLoading = historyLoading || olderLoading || !tableScrollReady return (
@@ -1784,23 +1962,48 @@ export function TrajectoryTable({ followsTableTail.current = pane.scrollHeight - pane.clientHeight - pane.scrollTop <= BOTTOM_FOLLOW_THRESHOLD_PX + requestOlder(pane) }} onClick={(event) => { if (event.target === event.currentTarget) clearAllSelections() }} > - + {showLoading && ( +
+ + +
+ )} +
- {records.map((record) => { + {virtualTop > 0 && ( + + + )} + {renderedRecords.map(({ record, position }) => { const displayText = recordDisplayText(record.cell) + const toolCallOnly = isToolCallOnly(record.cell) const toolCallText = toolCallTextParts(record.cell.kind, displayText) - const listDisplayText = toolCallText === undefined - ? displayText - : [toolCallText.name, toolCallText.args].filter(Boolean).join(' ') + const listDisplayText = toolCallOnly + ? '(tool call only)' + : toolCallText === undefined + ? displayText + : [toolCallText.name, toolCallText.args].filter(Boolean).join(' ') const isCollapsedSummary = record.collapsedSummary !== undefined const isRequestOnly = record.cell.requestOnly === true const isInitialSystem = record.cell.kind === 'system' @@ -1840,6 +2043,7 @@ export function TrajectoryTable({ : `${request === undefined ? '' : `Request ${request}, `}${KIND_LABEL[record.cell.kind]}, ${listDisplayText || 'no content'}`} aria-selected={!isCollapsedSummary && !isRequestOnly && selectedIndex === record.cell.index} data-kind={record.cell.kind} + data-virtual-position={virtualizationEnabled ? position : undefined} data-record-index={!isCollapsedSummary && !isRequestOnly ? record.cell.index : undefined} @@ -2013,8 +2217,8 @@ export function TrajectoryTable({ : `${listDisplayText} → ${record.cell.result}`} > - {isToolCallOnly(record.cell) - ? null + {toolCallOnly + ? (tool call only) : toolCallText === undefined ? listDisplayText || '—' : ( @@ -2047,6 +2251,16 @@ export function TrajectoryTable({ ) })} + {virtualBottom > 0 && ( + + + )}
diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css index 4d548e64d8..f9b06ad3af 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css @@ -61,6 +61,46 @@ cursor: grabbing; } +.earlierHistory { + position: absolute; + z-index: 5; + top: 0; + bottom: 0; + left: 0; + display: flex; + width: 28px; + align-items: center; + justify-content: flex-start; + appearance: none; + box-sizing: border-box; + padding-left: 3px; + border: 0; + outline: none; + background: linear-gradient( + to right, + var(--dsw-alias-bg-layer-2) 0, + var(--dsw-alias-bg-layer-2) 38%, + transparent 100% + ); + color: var(--dsw-alias-label-secondary); + font: var(--dsw-font-xs-13); + line-height: 1; + opacity: 0.72; + cursor: pointer; +} + +.earlierHistory:hover { + opacity: 1; +} + +.earlierHistory[aria-disabled='true'] { + cursor: default; +} + +.earlierHistory:focus-visible { + box-shadow: inset 0 0 0 1px var(--dsw-alias-border-l2); +} + .empty { position: absolute; top: 50%; @@ -115,7 +155,7 @@ top: calc(var(--trajectory-span-lane) * 14px); left: calc(var(--trajectory-span-left) + var(--trajectory-span-gap)); width: max( - 2px, + 0px, calc( var(--trajectory-span-width) - var(--trajectory-span-gap) @@ -123,7 +163,6 @@ ) ); height: 8px; - min-width: 2px; border-radius: 1px; background: var(--dsw-alias-label-secondary); opacity: 0.78; diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx index 87d7cdcd34..1cf92c8d78 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx @@ -132,6 +132,10 @@ export interface TrajectoryTimelineProps { turns: readonly TrajectoryTurnModel[] mode: TrajectoryTimelineMode range: TrajectoryTimeRange | null + /** Whether the loaded timeline omits an earlier history prefix. */ + hasEarlierRecords?: boolean + /** Load one earlier history page from the truncation control. */ + onLoadEarlier?: () => Promise selectedIndex?: number | null /** Record indexes matching the active ledger search, or null without a query. */ searchMatchIndexes?: ReadonlySet | null @@ -191,11 +195,49 @@ function LaneLabels() { ) } +function EarlierHistoryBoundary({ + loading, + onHover, + onLoad, +}: { + loading: boolean + onHover: () => void + onLoad: (() => void) | undefined +}) { + return ( + + + + ) +} + /** Overview renderer with drag ranges, click-sized focus, and Escape reset. */ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ turns, mode, range, + hasEarlierRecords = false, + onLoadEarlier, selectedIndex = null, searchMatchIndexes = null, onRangeChange, @@ -222,6 +264,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ const trackRef = useRef(null) const [draft, setDraft] = useState(null) const [hover, setHover] = useState(null) + const [loadingEarlier, setLoadingEarlier] = useState(false) const [panning, setPanning] = useState(false) const [viewport, setViewport] = useState(null) const [animateViewport, setAnimateViewport] = useState(false) @@ -278,6 +321,15 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ ) const domainDuration = viewport === null ? fullDuration : viewportDuration const domainStart = viewport === null ? model?.start ?? 0 : viewportStart + const showsEarlierBoundary = hasEarlierRecords + && model !== null + && domainStart === model.start + const loadEarlier = onLoadEarlier === undefined || loadingEarlier + ? undefined + : () => { + setLoadingEarlier(true) + void onLoadEarlier().finally(() => { setLoadingEarlier(false) }) + } const projectedDomainStyle = model === null ? undefined : { @@ -333,6 +385,13 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
No timing data + {hasEarlierRecords && ( + { setHover(null) }} + onLoad={loadEarlier} + /> + )}
@@ -541,6 +600,13 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ event.preventDefault() }} > + {showsEarlierBoundary && ( + { setHover(null) }} + onLoad={loadEarlier} + /> + )} {hover !== null && hover.recordIndex === null && draft === null && (
{ const left = (span.start - model.start) / fullDuration const width = (span.end - span.start) / fullDuration - const widthPercent = Math.max(width * 100, 0.35) + const widthPercent = width * 100 const detail = detailByIndex.get(span.index) const ttftMs = detail?.ttftMs const decodingMs = detail?.decodingMs @@ -646,7 +712,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ style={{ '--trajectory-span-left': `${left * 100}%`, '--trajectory-span-width': `${widthPercent}%`, - '--trajectory-span-gap': `clamp(0.25px, ${widthPercent * 0.08}%, 1px)`, + '--trajectory-span-gap': `min(${widthPercent * 0.08}%, 1px)`, '--trajectory-span-lane': span.lane, ...(ttftFraction === null ? {} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 9ba75abaac..3cad65b46e 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots' import type { - AssistantMessageNode, ConversationContext, + AssistantBlock, AssistantMessageNode, ConversationContext, ConversationSnapshot, SessionHistoryFace, SnapshotStore, } from '@deepseek-ai/dsh-client-runtime/client' import { @@ -17,7 +17,10 @@ import { } from './TrajectoryTable.tsx' import { TrajectoryToolbar } from './TrajectoryToolbar.tsx' import { TrajectoryTimeline } from './TrajectoryTimeline.tsx' -import { deriveTrajectoryLayout } from './layout.ts' +import { + appendTrajectoryPartialLayout, deriveTrajectoryLayout, + type TrajectoryTurnModel, +} from './layout.ts' import { trajectoryTimelineFocusIndexes, type TrajectoryTimelineMode, @@ -27,13 +30,45 @@ import css from './views.module.css' const EMPTY_IDS: ReadonlySet = new Set() +function lastCellIndex(turns: readonly TrajectoryTurnModel[]): number { + let last = 0 + for (const turn of turns) { + for (const group of turn.groups) { + for (const cell of group.cells) last = Math.max(last, cell.index) + } + } + return last +} + +function timelineBlock(block: AssistantBlock): AssistantBlock { + switch (block.kind) { + case 'text': return { kind: 'text', text: '' } + case 'reasoning': return { kind: 'reasoning', text: '' } + case 'tool-call': return { + kind: 'tool-call', + callId: block.callId, + name: block.name, + argsRaw: '', + } + case 'other': return { kind: 'other', block: null } + } +} + +function partialStructureSignature(partial: ConversationSnapshot['partial']): string { + if (partial === null) return '' + return partial.blocks.map(block => block.kind === 'tool-call' + ? `${block.kind}:${block.callId}:${block.name}` + : block.kind).join('\u0000') +} + /** Session-history paging needed by the event-complete trajectory view. */ export interface TrajectoryViewInjected { hooks: { history: SessionHistoryFace duration: SnapshotStore } - loadAllHistory: (signal: AbortSignal) => Promise + loadHistoryTail: (signal: AbortSignal) => Promise + loadOlderHistory: (signal: AbortSignal) => Promise setActualDuration: (actualDuration: boolean) => void } @@ -138,7 +173,8 @@ function searchMatches( } export function TrajectoryView({ - useHistory, useDuration, loadAllHistory, setActualDuration, inspect, onInspectDone, + useHistory, useDuration, loadHistoryTail, loadOlderHistory, setActualDuration, + inspect, onInspectDone, }: ConvViewProps & InjectFace) { const [collapsedTurns, setCollapsedTurns] = useState>(EMPTY_IDS) const [collapsedAssistants, setCollapsedAssistants] = @@ -154,26 +190,35 @@ export function TrajectoryView({ const [timelineRecordSelection, setTimelineRecordSelection] = useState<{ readonly index: number } | null>(null) - const ledgerRef = useRef(null) + const [timelineRecordFocus, setTimelineRecordFocus] = useState<{ + readonly index: number + } | null>(null) const inspection = useHistory(snapshot => snapshot.inspection) + const historyLoading = useHistory(snapshot => + snapshot.state === 'cold' || snapshot.state === 'loading') + const hasOlderHistory = useHistory(snapshot => snapshot.hasMore) const nodes = inspection.eventNodes const partial = inspection.partial const runningCalls = inspection.runningCalls const codeDispatches = inspection.codeDispatches - const loadAllHistoryRef = useRef(loadAllHistory) - loadAllHistoryRef.current = loadAllHistory + const loadHistoryTailRef = useRef(loadHistoryTail) + loadHistoryTailRef.current = loadHistoryTail + const historyControllerRef = useRef(null) useEffect(() => { const controller = new AbortController() - void loadAllHistoryRef.current(controller.signal) + historyControllerRef.current = controller + void loadHistoryTailRef.current(controller.signal) return () => { controller.abort() } }, []) const requests = inspection.requests const callSchemas = inspection.callSchemas + const historyContexts = inspection.contexts + const interruptedNodes = inspection.interruptedNodes const contexts = useMemo( - () => inspection.contexts.length === 0 + () => historyContexts.length === 0 ? [{ id: 0, nodes }] - : inspection.contexts, - [inspection, nodes], + : historyContexts, + [historyContexts, nodes], ) const branches = useMemo( () => deriveTrajectoryContextBranches(contexts), @@ -183,18 +228,18 @@ export function TrajectoryView({ if (currentBranch === undefined) throw new Error('trajectory branch projection must not be empty') const selectedNodes = useMemo(() => { const selected = new Map(currentBranch.nodes.map(node => [node.seq, node])) - for (const node of inspection.interruptedNodes) { + for (const node of interruptedNodes) { selected.set(node.seq, node) } return [...selected.values()].sort((left, right) => left.seq - right.seq) - }, [currentBranch, inspection]) + }, [currentBranch.nodes, interruptedNodes]) const selectedRequests = useMemo( () => requests.filter(request => trajectoryBranchContainsRequest(currentBranch, request), ), [currentBranch, requests], ) - const globalRequestNumbers = useMemo(() => { + const requestNumbers = useMemo(() => { const assistantsByStep = new Map() for (const context of contexts) { for (const node of context.nodes) { @@ -295,47 +340,44 @@ export function TrajectoryView({ }) } - if (partial !== null && partial.step > 0) { - const key = `${partial.turn}\u0000${partial.step}` - const recorded = numbered.some(request => - `${request.turn}\u0000${request.step}` === key, - ) - if (!recorded) { - numbered.push({ - turn: partial.turn, - step: partial.step, - group: `Step ${partial.step}`, - number: orderedRequests.length + 1, - ...(currentBranch.latest.prompt?.config.provider === undefined - ? {} - : { provider: currentBranch.latest.prompt.config.provider }), - ...(currentBranch.latest.prompt?.config.model === undefined - ? {} - : { model: currentBranch.latest.prompt.config.model }), - ...(currentBranch.latest.prompt?.config === undefined - ? {} - : { requestConfig: currentBranch.latest.prompt.config }), - ...(cumulativeUsage === undefined ? {} : { cumulativeUsage }), - }) - } - } return numbered }, [ - contexts, currentBranch.latest.prompt, nodes, partial, requests, + contexts, nodes, requests, ]) - const requestNumbers = globalRequestNumbers - const turns = useMemo( - () => deriveTrajectoryLayout({ + const partialTurn = partial?.turn ?? null + const partialStep = partial?.step ?? null + const finalized = useMemo(() => { + const turns = deriveTrajectoryLayout({ nodes: selectedNodes, - partial, + partial: partialTurn === null || partialStep === null + ? null + : { turn: partialTurn, step: partialStep, blocks: [] }, runningCalls, requests: selectedRequests, callSchemas, codeDispatches, - }), - [ - selectedNodes, partial, runningCalls, selectedRequests, callSchemas, codeDispatches, - ], + }) + return { turns, lastIndex: lastCellIndex(turns) } + }, [ + selectedNodes, partialTurn, partialStep, + runningCalls, selectedRequests, callSchemas, codeDispatches, + ]) + const turns = useMemo( + () => appendTrajectoryPartialLayout(finalized.turns, partial, finalized.lastIndex), + [finalized, partial], + ) + const timelinePartialSignature = partialStructureSignature(partial) + const timelinePartial = useMemo(() => partial === null + ? null + : { + turn: partial.turn, + step: partial.step, + blocks: partial.blocks.map(block => timelineBlock(block)), + }, + [partialStep, partialTurn, timelinePartialSignature]) + const timelineTurns = useMemo( + () => appendTrajectoryPartialLayout(finalized.turns, timelinePartial, finalized.lastIndex), + [finalized, timelinePartial], ) const timelineMode: TrajectoryTimelineMode = actualDuration ? actualTime ? 'actual' : 'duration' @@ -350,8 +392,8 @@ export function TrajectoryView({ const timelineFocusIndexes = useMemo( () => timelineRange === null ? null - : trajectoryTimelineFocusIndexes(turns, timelineRange, timelineMode), - [timelineMode, timelineRange, turns], + : trajectoryTimelineFocusIndexes(timelineTurns, timelineRange, timelineMode), + [timelineMode, timelineRange, timelineTurns], ) const handleRecordSelect = useCallback((index: number) => { if ( @@ -361,29 +403,6 @@ export function TrajectoryView({ setTimelineSelection(null) } }, [timelineFocusIndexes]) - useEffect(() => { - if (timelineFocusIndexes === null || timelineFocusIndexes.size === 0) return - const ledger = ledgerRef.current - if (ledger === null) return - const focusedRows = [ - ...ledger.querySelectorAll('tr[data-timeline-focus="inside"]'), - ] - const first = focusedRows.at(0) - const last = focusedRows.at(-1) - if (first === undefined || last === undefined) return - const focusHeight = - last.getBoundingClientRect().bottom - first.getBoundingClientRect().top - if (focusHeight > ledger.clientHeight) { - if (typeof first.scrollIntoView === 'function') { - first.scrollIntoView({ behavior: 'smooth', block: 'start' }) - } - return - } - const middle = focusedRows[Math.floor((focusedRows.length - 1) / 2)] - if (middle !== undefined && typeof middle.scrollIntoView === 'function') { - middle.scrollIntoView({ behavior: 'smooth', block: 'center' }) - } - }, [timelineFocusIndexes]) const collapsibleTurnIds = useMemo( () => turns .filter(turn => @@ -458,6 +477,13 @@ export function TrajectoryView({ }) } + const loadEarlierHistory = useCallback(() => { + const signal = historyControllerRef.current?.signal + return signal?.aborted === false + ? loadOlderHistory(signal) + : Promise.resolve(false) + }, [loadOlderHistory]) + return (
{ @@ -494,21 +522,12 @@ export function TrajectoryView({ setTimelineSelection(null) setTimelineRecordSelection({ index }) setSelectedTimelineIndex(index) - const row = ledgerRef.current - ?.querySelector(`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 - ?.querySelector(`tr[data-record-index="${index}"]`) - if (row !== undefined && row !== null && typeof row.scrollIntoView === 'function') { - row.scrollIntoView({ behavior: 'smooth', block: 'center' }) - } + setTimelineRecordFocus({ index }) }} /> -
+
{ setTimelineSelection(null) }} collapsedTurns={collapsedTurns} onToggleTurn={toggleTurn} diff --git a/packages/client/ui-trajectory/src/client/index.ts b/packages/client/ui-trajectory/src/client/index.ts index 94f512a9a8..1abb2016ce 100644 --- a/packages/client/ui-trajectory/src/client/index.ts +++ b/packages/client/ui-trajectory/src/client/index.ts @@ -35,7 +35,8 @@ export function apply(ctx: Context): void { const history = ctx.sessionHistory.source(sessionId) return { hooks: { history, duration }, - loadAllHistory: signal => history.loadAll(signal), + loadHistoryTail: signal => history.loadTail(signal), + loadOlderHistory: signal => history.loadOlder(signal), setActualDuration: (value) => { duration.set(value) }, } }, diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index 3364ebce3e..18a81c9509 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -475,6 +475,61 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T ].sort((left, right) => firstCellIndex(left) - firstCellIndex(right)) } +/** + * Append the changing in-flight assistant cells to a stable finalized layout. + * @param turns - Finalized layout derived with an empty-block partial anchor. + * @param partial - Current in-flight assistant projection. + * @param lastIndex - Highest cell index in the finalized layout. + * @returns The original layout without a partial, otherwise a layout sharing every unaffected turn. + */ +export function appendTrajectoryPartialLayout( + turns: readonly TrajectoryTurnModel[], + partial: ConversationSnapshot['partial'], + lastIndex: number, +): readonly TrajectoryTurnModel[] { + if (partial === null) return turns + const partialTurn = deriveTrajectoryLayout({ + nodes: [], + partial, + runningCalls: [], + codeDispatches: new Map(), + }).at(0) + if (partialTurn === undefined) return turns + const streamed: TrajectoryTurnModel = { + ...partialTurn, + groups: partialTurn.groups.map(group => ({ + ...group, + cells: group.cells.map(cell => ({ ...cell, index: cell.index + lastIndex })), + })), + } + const turnIndex = turns.findIndex(turn => turn.turn === streamed.turn) + if (turnIndex === -1) return [...turns, streamed] + const current = turns[turnIndex] + /* v8 ignore next -- findIndex proved the dense array position exists. */ + if (current === undefined) return turns + const groups = [...current.groups] + for (const streamedGroup of streamed.groups) { + const groupIndex = groups.findIndex(group => group.title === streamedGroup.title) + if (groupIndex === -1) { + groups.push(streamedGroup) + continue + } + const group = groups[groupIndex] + /* v8 ignore next -- findIndex proved the dense array position exists. */ + if (group === undefined) continue + groups[groupIndex] = { + ...streamedGroup, + cells: [ + ...group.cells.filter(cell => cell.requestOnly !== true), + ...streamedGroup.cells, + ], + } + } + const updated = [...turns] + updated[turnIndex] = { ...current, groups } + return updated +} + function attachToolSchema( laid: LaidCell, callSchemas: RequestInspectionSnapshot['callSchemas'] | undefined, @@ -566,6 +621,7 @@ function expandAssistant( callStarts: ReadonlyMap, opts?: { streaming?: boolean }, ): LaidCell[] { + if (opts?.streaming === true && node.blocks.length === 0) return [] const out: LaidCell[] = [] let index = startIndex - 1 const usage = node.usage as UsageLike | undefined diff --git a/packages/client/ui-trajectory/tests/client-bundle.spec.ts b/packages/client/ui-trajectory/tests/client-bundle.spec.ts index ebf4a87bd2..fb46571dcf 100644 --- a/packages/client/ui-trajectory/tests/client-bundle.spec.ts +++ b/packages/client/ui-trajectory/tests/client-bundle.spec.ts @@ -47,6 +47,7 @@ describe('tsdown client artifact', () => { const modules = new Map([ ['react', await import('react')], ['react/jsx-runtime', await import('react/jsx-runtime')], + ['react-dom', await import('react-dom')], ['@deepseek-ai/dsh-client-runtime/client', await import('@deepseek-ai/dsh-client-runtime/client')], ['@deepseek-ai/dsh-client-ui-primitives', await import('@deepseek-ai/dsh-client-ui-primitives')], ]) diff --git a/packages/client/ui-trajectory/tests/layout.spec.tsx b/packages/client/ui-trajectory/tests/layout.spec.tsx index dbd20b53f9..927ec43716 100644 --- a/packages/client/ui-trajectory/tests/layout.spec.tsx +++ b/packages/client/ui-trajectory/tests/layout.spec.tsx @@ -11,7 +11,9 @@ import type { import { TrajectoryGroupHeader } from '../src/client/TrajectoryGroupHeader.tsx' import { TrajectoryTurn } from '../src/client/TrajectoryTurn.tsx' import { TrajectoryTurnHeader } from '../src/client/TrajectoryTurnHeader.tsx' -import { deriveTrajectoryLayout } from '../src/client/layout.ts' +import { + appendTrajectoryPartialLayout, deriveTrajectoryLayout, +} from '../src/client/layout.ts' afterEach(cleanup) @@ -102,6 +104,42 @@ describe('deriveTrajectoryLayout', () => { }) }) + it('appends a streaming partial without rebuilding unaffected finalized turns', () => { + const nodes = [{ + kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, + blocks: [{ kind: 'text', text: 'finalized' }], + }] as unknown as ConversationSnapshot['nodes'] + const partial = { + turn: 2, + step: 1, + blocks: [{ kind: 'reasoning' as const, text: 'streaming' }], + } + const request = { + purpose: 'assistant', startSeq: 3, turn: 2, step: 1, + startedAt: 3_000, completedAt: null, status: 'running', + } as unknown as RequestView + const base = deriveTrajectoryLayout({ + codeDispatches: new Map(), + nodes, + partial: { ...partial, blocks: [] }, + requests: [request], + runningCalls: [], + }) + expect(base).toHaveLength(1) + + const streamed = appendTrajectoryPartialLayout(base, partial, 1) + + expect(streamed[0]).toBe(base[0]) + expect(streamed).toHaveLength(2) + expect(streamed[1]?.groups[0]?.cells).toMatchObject([{ + index: 2, + kind: 'message', + text: 'streaming', + timeSeconds: null, + }]) + expect(streamed[1]?.groups[0]?.cells[0]?.requestOnly).toBeUndefined() + }) + it('omits duration when node times are missing instead of rendering NaN', () => { const nodes = [ { kind: 'user', seq: 1, content: [{ type: 'text', text: 'hi' }], source: null }, diff --git a/packages/client/ui-trajectory/tests/table.spec.tsx b/packages/client/ui-trajectory/tests/table.spec.tsx index b9f5e5d7b7..7886e09d41 100644 --- a/packages/client/ui-trajectory/tests/table.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.spec.tsx @@ -2,11 +2,15 @@ /** Trajectory ledger selection, details, status, and fold behavior. */ import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { TrajectoryTable } from '../src/client/TrajectoryTable.tsx' import type { TrajectoryTurnModel } from '../src/client/layout.ts' -afterEach(cleanup) +afterEach(() => { + cleanup() + vi.restoreAllMocks() + Reflect.deleteProperty(HTMLElement.prototype, 'scrollTo') +}) const TURNS: readonly TrajectoryTurnModel[] = [{ turn: 1, @@ -61,6 +65,28 @@ const FOLD_PROPS = { } describe('TrajectoryTable', () => { + it('shows a muted placeholder for an assistant response containing only tool calls', () => { + const turns: readonly TrajectoryTurnModel[] = [{ + turn: 1, + groups: [{ + title: 'Step 1', + cells: [{ + index: 1, + kind: 'message', + text: 'Tool call only', + sourceBlocks: [{ + type: 'tool-call', content: '{}', callId: 'call-1', toolName: 'read', + }], + timeSeconds: 1, + }], + }], + }] + + render() + + expect(screen.getByText('(tool call only)')).toBeTruthy() + }) + it('shows assistant timing facts after keyboard selection', () => { render() fireEvent.keyDown(screen.getByRole('row', { name: /ASSISTANT/ }), { key: 'Enter' }) @@ -210,6 +236,109 @@ describe('TrajectoryTable', () => { expect(tablePane.scrollTop).toBe(20) }) + it('loads one older page at the top and preserves the visible anchor', async () => { + let resolveOlder: ((advanced: boolean) => void) | undefined + const older = new Promise((resolve) => { resolveOlder = resolve }) + const onLoadOlder = vi.fn(() => older) + const view = render( + , + ) + const tablePane = screen.getByRole('table').parentElement as HTMLElement + let scrollHeight = 200 + Object.defineProperties(tablePane, { + clientHeight: { configurable: true, get: () => 100 }, + scrollHeight: { configurable: true, get: () => scrollHeight }, + }) + tablePane.scrollTop = 0 + fireEvent.scroll(tablePane) + fireEvent.scroll(tablePane) + + await waitFor(() => { expect(onLoadOlder).toHaveBeenCalledOnce() }) + expect(screen.getByRole('status').textContent).toContain('Loading earlier history…') + resolveOlder?.(true) + await waitFor(() => { expect(screen.queryByRole('status')).toBeNull() }) + scrollHeight = 260 + view.rerender( + , + ) + + expect(tablePane.scrollTop).toBe(60) + }) + + it('covers the ledger while the initial tail is loading', () => { + const view = render( + , + ) + + expect(screen.getByRole('status').textContent).toContain('Loading trajectory…') + expect(screen.getByRole('table').getAttribute('data-scroll-ready')).toBeNull() + + view.rerender() + + expect(screen.queryByRole('status')).toBeNull() + expect(screen.getByRole('table').getAttribute('data-scroll-ready')).toBe('true') + }) + + it('mounts only the visible window for a long ledger', async () => { + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600) + const scrollTo = vi.fn() + Object.defineProperty(HTMLElement.prototype, 'scrollTo', { + configurable: true, + value: scrollTo, + }) + const cells = Array.from({ length: 500 }, (_, index) => ({ + index: index + 1, + kind: 'context' as const, + text: `Context ${index + 1}`, + timeSeconds: 0, + })) + const turns: readonly TrajectoryTurnModel[] = [{ + turn: 1, + groups: [{ title: 'Context', cells }], + }] + const view = render() + + await waitFor(() => { + expect(view.container.querySelectorAll('tr[data-virtual-position]').length) + .toBeGreaterThan(0) + }) + expect(view.container.querySelectorAll('tr[data-virtual-position]').length) + .toBeLessThan(cells.length) + expect(scrollTo).toHaveBeenCalled() + expect(view.container.querySelector('tr[data-virtual-spacer="bottom"]')).toBeTruthy() + expect(screen.getByText('Context 1')).toBeTruthy() + expect(screen.queryByText('Context 500')).toBeNull() + + const tablePane = screen.getByRole('table').parentElement as HTMLElement + tablePane.scrollTop = 9_000 + fireEvent.scroll(tablePane) + await waitFor(() => { + expect(Number(view.container.querySelector( + 'tr[data-virtual-position]', + )?.getAttribute('data-virtual-position'))).toBeGreaterThan(0) + }) + expect(view.container.querySelector('tr[data-virtual-spacer="top"]')).toBeTruthy() + expect(screen.queryByText('Context 1')).toBeNull() + }) + it('keeps running and failure semantics distinct from record roles', () => { const view = render() expect(view.container.querySelector('tr[data-kind="tool"][data-running="true"]')).toBeTruthy() diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index 0650e28746..5cc017ce65 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -89,11 +89,15 @@ function historySnapshot( function standaloneHistory( snapshot: SessionHistorySnapshot, -): Pick, 'useHistory' | 'loadAllHistory'> { +): Pick< + ComponentProps, + 'useHistory' | 'loadHistoryTail' | 'loadOlderHistory' +> { const store = createSnapshotStore(snapshot) return { useHistory: bindSnapshotSelector(store), - loadAllHistory: () => Promise.resolve(), + loadHistoryTail: () => Promise.resolve(), + loadOlderHistory: () => Promise.resolve(false), } } @@ -145,13 +149,15 @@ function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps { async function bench(snapshot = historySnapshot(NODES)) { const ctx = new Context() const slots = new SlotsService(ctx) - const loadAllHistory = vi.fn((_signal: AbortSignal) => Promise.resolve()) + const loadHistoryTail = vi.fn((_signal: AbortSignal) => Promise.resolve()) + const loadOlderHistory = vi.fn((_signal: AbortSignal) => Promise.resolve(false)) const historyStore = createSnapshotStore(snapshot) const history: SessionHistoryFace = { sessionId: SID, getSnapshot: () => historyStore.getSnapshot(), subscribe: listener => historyStore.subscribe(listener), - loadAll: loadAllHistory, + loadTail: loadHistoryTail, + loadOlder: loadOlderHistory, } // The conversation entry's role: declare the ring, then seed the chat entry. slots.register({ @@ -167,7 +173,7 @@ async function bench(snapshot = historySnapshot(NODES)) { ctx.provide('sessionHistory', { source: () => history }) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() - return { ctx, slots, fiber, loadAllHistory } + return { ctx, slots, fiber, loadHistoryTail, loadOlderHistory } } /** Tab projection twin of apply's viewTabs (the render-side consumption path). */ @@ -201,7 +207,8 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES ? (() => { const trajectory = injected as TrajectoryViewInjected return { - loadAllHistory: trajectory.loadAllHistory, + loadHistoryTail: trajectory.loadHistoryTail, + loadOlderHistory: trajectory.loadOlderHistory, setActualDuration: trajectory.setActualDuration, useHistory: bindSnapshotSelector(trajectory.hooks.history), useDuration: bindSnapshotSelector(trajectory.hooks.duration), @@ -295,9 +302,9 @@ describe('tab switching in ConversationRoot', () => { expect(screen.getByRole('row', { name: /USER/ })).toBeTruthy() expect(screen.queryByTestId('chat-body')).toBeNull() await vi.waitFor(() => { - expect(b.loadAllHistory).toHaveBeenCalledOnce() + expect(b.loadHistoryTail).toHaveBeenCalledOnce() }) - const signal = b.loadAllHistory.mock.calls[0]?.[0] + const signal = b.loadHistoryTail.mock.calls[0]?.[0] expect(signal?.aborted).toBe(false) fireEvent.click(screen.getByRole('tab', { name: 'Chat' })) expect(signal?.aborted).toBe(true) @@ -580,6 +587,45 @@ describe('timeline projection', () => { } }) + it('marks an unloaded history prefix without inventing timeline duration', () => { + const onLoadEarlier = vi.fn(() => new Promise(() => {})) + const view = render( + , + ) + + const boundary = screen.getByLabelText('Load earlier history') + expect(boundary.getAttribute('data-earlier-history')).not.toBeNull() + const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events') + fireEvent.pointerMove(plot, { clientX: 50, pointerId: 1 }) + expect(view.container.querySelector('[data-timeline-hover-line]')).toBeTruthy() + fireEvent.pointerEnter(boundary) + expect(view.container.querySelector('[data-timeline-hover-line]')).toBeNull() + fireEvent.focus(boundary) + expect(screen.getByRole('tooltip').textContent) + .toContain('Click to load earlier history') + fireEvent.click(boundary) + expect(onLoadEarlier).toHaveBeenCalledOnce() + expect(screen.getByLabelText('Loading earlier history')).toBeTruthy() + + view.rerender( + , + ) + expect(screen.queryByLabelText('Load earlier history')).toBeNull() + expect(screen.queryByLabelText('Loading earlier history')).toBeNull() + }) + it('cancels native scrolling across the timeline while zooming', () => { render( { const span = view.container.querySelector('[data-timeline-span]') expect(span?.style.getPropertyValue('--trajectory-span-width')).toBe('10%') expect(span?.style.getPropertyValue('--trajectory-span-gap')) - .toBe('clamp(0.25px, 0.8%, 1px)') + .toBe('min(0.8%, 1px)') + }) + + it('keeps dense sequence spans proportional before applying the pixel floor', () => { + const denseTurns = [{ + turn: 1, + groups: [{ + title: 'Step 1', + cells: Array.from({ length: 400 }, (_, index) => ({ + index, + kind: 'message' as const, + text: `message ${index}`, + timeSeconds: 1, + })), + }], + }] + const view = render( + , + ) + + const span = view.container.querySelector('[data-timeline-span]') + expect(span?.style.getPropertyValue('--trajectory-span-width')).toBe('0.25%') + expect(span?.style.getPropertyValue('--trajectory-span-gap')) + .toBe('min(0.02%, 1px)') }) it('clears the selection without changing zoom on a zoomed right click', () => { @@ -624,15 +698,18 @@ describe('timeline projection', () => { turns={longTurns} mode="sequence" range={{ start: 2, end: 4 }} + hasEarlierRecords onRangeChange={onRangeChange} />, ) const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events') + expect(screen.getByLabelText('Load earlier history')).toBeTruthy() 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 }) + expect(screen.queryByLabelText('Load earlier history')).toBeNull() const domain = view.container.querySelector('[data-timeline-domain]') const domainWidth = domain?.style.getPropertyValue('--trajectory-domain-width') expect(domainWidth).not.toBe('100%') @@ -1065,7 +1142,8 @@ describe('TrajectoryView branches', () => { {...standaloneProps([])} {...standaloneDuration()} useHistory={bindSnapshotSelector(store)} - loadAllHistory={vi.fn(() => Promise.resolve())} + loadHistoryTail={vi.fn(() => Promise.resolve())} + loadOlderHistory={vi.fn(() => Promise.resolve(false))} />, ) @@ -1108,7 +1186,8 @@ describe('TrajectoryView branches', () => { {...standaloneProps([])} {...standaloneDuration()} useHistory={bindSnapshotSelector(store)} - loadAllHistory={vi.fn(() => Promise.resolve())} + loadHistoryTail={vi.fn(() => Promise.resolve())} + loadOlderHistory={vi.fn(() => Promise.resolve(false))} />, ) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5f109f8c62..621d689436 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1949,6 +1949,9 @@ importers: packages/client/ui-trajectory: dependencies: + '@tanstack/react-virtual': + specifier: ^3.14.9 + version: 3.14.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1) diff: specifier: ^9.0.0 version: 9.0.0 @@ -1971,12 +1974,18 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 + '@types/react-dom': + specifier: ~18.3.0 + version: 18.3.7(@types/react@18.3.31) cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) packages/client/ui-workspace: dependencies: @@ -8866,6 +8875,15 @@ packages: peerDependencies: eslint: ^9.0.0 || ^10.0.0 + '@tanstack/react-virtual@3.14.9': + resolution: {integrity: sha512-qZyr0FZDP8rDC4WBhsryIZmAd9bveJvFGUJJtskWaew6/0dTRS6wZxnR6VQ5bY2KwL3LjerrHqQLk3a0GKcPXQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/virtual-core@3.17.7': + resolution: {integrity: sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA==} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -13910,6 +13928,14 @@ snapshots: estraverse: 5.3.0 picomatch: 4.0.4 + '@tanstack/react-virtual@3.14.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@tanstack/virtual-core': 3.17.7 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@tanstack/virtual-core@3.17.7': {} + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.7 From d907c8475fd1653c7b97596fd7a2753d9fa57957 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 4 Aug 2026 16:13:52 +0800 Subject: [PATCH 03/21] fix(web): keep the input card in place across view tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer seat is one node laid out against two different edges: in Chat it is a sticky child of the column's scroller and rides its content box, which a space-consuming scrollbar shortens, while a view declaring a composer overlay gets an absolutely positioned seat against the padding box, which no bar reduces. With the transcript scrolling the two tabs disagreed by the bar's width, so the centred input card moved 4px sideways on every switch — and the same 4px inside Chat when a growing transcript began to scroll. The column now reserves its scrollbar gutter unconditionally and states the overlay branch a scroll container on the same axes, so both states measure against the same width. --- ...-composer-tab-gutter-reservation.i18n.yaml | 6 + ...6-08-04-composer-tab-gutter-reservation.md | 50 +++ ...8-04-composer-tab-gutter-reservation.zh.md | 50 +++ apps/web/tests/composer-tab-geometry.e2e.ts | 388 ++++++++++++++++++ .../geometry.expected.md | 37 ++ apps/web/tsconfig.json | 1 + .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../skeleton/ConversationRoot.module.css | 22 +- tsconfig.host.json | 1 + 11 files changed, 558 insertions(+), 5 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.zh.md create mode 100644 apps/web/tests/composer-tab-geometry.e2e.ts create mode 100644 apps/web/tests/snapshots/composer-tab-geometry/geometry.expected.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.i18n.yaml new file mode 100644 index 0000000000..176c649ecd --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.i18n.yaml @@ -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/bug-fix/2026-08-04-composer-tab-gutter-reservation.md +2026-08-04-composer-tab-gutter-reservation.md: f9a0c346c0c023fe2602486376b9794781f824db +2026-08-04-composer-tab-gutter-reservation.zh.md: 67a284ec0a12e464ad6f1dc17e954bdebd28e80d diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md new file mode 100644 index 0000000000..f9a0c346c0 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md @@ -0,0 +1,50 @@ +# Agent Note: The conversation column reserves one scrollbar gutter for every view + +Status: implemented + +English | [中文](2026-08-04-composer-tab-gutter-reservation.zh.md) + +## Problem + +The composer seat is one node in one place in the tree, and it was laid out against a different edge depending on which view tab was shown. + +In Chat it is a sticky CHILD of the column's scroller (`[data-conversation-scroll]`), so it rides that scroller's content box — the box a space-consuming scrollbar shortens by the bar's width. A view that declares `data-conversation-composer-overlay`, which Trajectory does, moves the column's scrolling into the view itself: the branch keyed on that attribute left the scroller `overflow: hidden` and positioned the seat absolutely, against the padding box, which no scrollbar reduces. + +So for as long as the transcript overflowed — the ordinary state of any session with history — the two tabs disagreed by exactly the bar's width. The input card is centred, so switching tabs moved it 4px sideways on an 8px bar, and its right-hand clearance changed by the full 8. The same displacement appeared inside Chat alone at the moment a growing transcript started to scroll, and again between the hero phase and the first scrolling turn. + +## Decision + +`.scrollBody` declares `scrollbar-gutter: stable` unconditionally, and the overlay branch declares the same box a scroll container on both axes — `overflow-x: hidden; overflow-y: auto` — instead of `overflow: hidden`. + +The two halves are one change. The reservation is what makes both states measure against the same width; declaring the overlay branch a scroll container is what makes the reservation reach it. `stable` rather than `auto` because `auto` reserves only while the box actually overflows, and the difference between overflowing and not is precisely the difference between the two tabs — an `auto` gutter would state the bug rather than fix it. + +The overlay state is a scroll container that nothing scrolls: the view fills it (`flex: 1 1 0` with its own clip) and the seat is out of flow, so no gesture and no clipping behavior changes. What changes is which declarations the engine honours. WebKit applies `scrollbar-gutter` to an `overflow-y: auto` box and ignores it on a hidden one — measured on this app's own composer layers and recorded in [the composer glyph-layer note](2026-07-31-composer-glyph-layer-tracks-the-textarea.md) — so a reservation left on a hidden box would hold in Chromium and silently not in Safari. + +The horizontal axis is declared rather than left to compute: a box that scrolls on one axis computes `visible` on the other to `auto`, and would grow a horizontal scrollbar of its own the first time a view's content reached past the column. + +The reservation is worth what it costs only because the bar takes layout space here at all, which is not the browser's default behavior but this client's: `::-webkit-scrollbar` carries a width in ui-theme's sheet ([themed scrollbars](2026-07-28-themed-scrollbars-and-reserved-gutter.md)), and the sidebar's session list already reserves its own gutter for the same reason. + +## Alternatives considered + +**Inset the overlay seat by the bar's width.** The narrow reading of the bug — the two states differ by 8px, so subtract 8px from one. Rejected because the number is the engine's, not ours: the WebKit path draws the sheet's 8px bar, the Firefox path draws whatever `scrollbar-width: thin` resolves to, and a hardcoded inset would line the two states up in Chromium while drifting everywhere else. The gutter asks the engine to reserve its own bar's width, whatever that is. + +**Keep `overflow: hidden` and add `scrollbar-gutter: stable` alone.** The one-line version. It fixes the visible symptom on the engine the browser lane runs, and leaves it in place on Safari, with no test failing anywhere — the failure mode the second half of the change exists to prevent. + +**Move the composer seat out of the scroller in Chat too, making the overlay geometry the only geometry.** This deletes the difference at its root rather than reconciling it, and gives up a deliberate property: the sticky seat sits inside the scroll flow, so a wheel over the composer moves the transcript ([sticky composer](2026-07-29-sticky-composer-conversation-scroll.md)), and the fade mask above it is painted by the seat's own background. Both are owned behavior with their own coverage; rebuilding them to remove 8px of asymmetry is the larger change, not the smaller one. + +**Pad the column by the bar's width instead of reserving a gutter.** Padding applies whether or not a bar is present, so it costs the width unconditionally in every state, and it pins a value in the stylesheet that the engine picks at layout time. Rejected for the same reason the sidebar list rejected it. + +## Consequences + +- Chat's content column is permanently 8px narrower — in the hero phase and while the transcript is short as well, where no bar is drawn. That is the trade: one card position at every content height, instead of the widest possible column. +- The fix covers three transitions with one declaration, because all three are the same difference: Chat ↔ Trajectory, short ↔ scrolling transcript within Chat, and hero ↔ first scrolling turn. +- The overlay state is now a scroll container. Nothing in it can overflow today; a future view that let its content exceed the column would scroll this box instead of clipping, and would need its own clip the way the Trajectory view already has one. +- The committed golden records the reserved band, so a change to the sheet's `::-webkit-scrollbar` width — the value that decides how wide the reservation is — arrives as a reviewable diff in this scenario as well as in the sidebar's. + +## Testing + +`apps/web/tests/composer-tab-geometry.e2e.ts` measures the input card's rectangle in both tabs, at a viewport where the card sits at its width cap and one where it shrinks with the column, and asserts the two rectangles are the same rectangle. Only a real engine reports this: jsdom gives every element a zero-sized box and no scrollbar, so a unit spec could assert the declarations exist but not that the two states land in the same place. For the same reason no CSS-text spec accompanies it — it would restate the declarations without adding a fact the browser lane does not already establish. + +The scenario launches chromium without Playwright's default `--hide-scrollbars`, which is load-bearing: under that argument a bar consumes no layout width, both tabs agree before this change as much as after it, and every comparison in the file holds vacuously. Measured, the pre-fix cascade leaves both bands at 0 under the argument, and at 8 and 0 with it dropped. + +The pre-fix cascade is then applied in the page — `scrollbar-gutter: auto` on the scroller, `overflow: hidden` on the overlay branch — and the same two tabs measured through it, which is what separates a card that does not move from a tab switch that never reached the layout. It reproduces the reported symptom as a number: 4px on each edge, half the 8px band. The golden records that control beside the fixed state, so the fixture carries the difference the change removes rather than only its absence. diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.zh.md new file mode 100644 index 0000000000..67a284ec0a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.zh.md @@ -0,0 +1,50 @@ +# Agent Note: 会话列为每个视图预留同一条滚动条槽 + +Status: implemented + +[English](2026-08-04-composer-tab-gutter-reservation.md) | 中文 + +## 问题 + +composer 座位在组件树中只有一个节点、一个位置,但它究竟对齐到哪条边,取决于当前展示的是哪个视图标签页。 + +在 Chat 中它是会话列滚动容器(`[data-conversation-scroll]`)的 sticky **子元素**,因而依附于该容器的 content box——而占布局宽度的滚动条会把这个盒子收窄一条滚动条的宽度。声明了 `data-conversation-composer-overlay` 的视图(Trajectory 即是其一)会把会话列的滚动搬进视图自身:以该属性为条件的那条分支把滚动容器留作 `overflow: hidden`,并把座位改为绝对定位——对齐的是 padding box,而滚动条从不收窄这个盒子。 + +于是只要对话记录超出一屏——任何带历史的会话的常态——两个标签页就恰好相差一条滚动条的宽度。输入卡片是居中的,因此在 8px 的滚动条下切换标签页会让它横向移动 4px,而右侧留白整整变化 8px。同一位移也出现在 Chat 内部:对话增长到开始滚动的那一刻,以及从 hero 态进入第一个可滚动轮次时。 + +## 决策 + +`.scrollBody` 无条件声明 `scrollbar-gutter: stable`,overlay 分支则把同一个盒子在两个轴向上都声明为滚动容器——`overflow-x: hidden; overflow-y: auto`——而不再是 `overflow: hidden`。 + +这两半是同一处改动。预留使两种状态依附于同一个宽度;把 overlay 分支声明为滚动容器,才使这条预留真正抵达它。选 `stable` 而非 `auto`,是因为 `auto` 只在盒子确实溢出时才预留,而"溢出与否"恰恰就是两个标签页之间的那点差别——`auto` 的写法只是把缺陷重述一遍,并不能修掉它。 + +overlay 状态是一个没有任何东西会去滚动它的滚动容器:视图把它填满(`flex: 1 1 0`,且自带裁剪),座位不在常规流中,因此没有任何手势与裁剪行为发生变化。变化的是引擎会认哪些声明。WebKit 对 `overflow-y: auto` 的盒子应用 `scrollbar-gutter`,对 hidden 的盒子则忽略它——这是在本应用 composer 自身的图层上实测所得,并记录于 [composer 字形层记录](2026-07-31-composer-glyph-layer-tracks-the-textarea.md)——所以把预留留在一个 hidden 盒子上,会在 Chromium 上成立,在 Safari 上悄无声息地不成立。 + +横向轴是显式声明的,而不是交给推导:单轴滚动的盒子会把另一轴的 `visible` 计算为 `auto`,于是只要某个视图的内容第一次伸出列外,它就会长出自己的横向滚动条。 + +这条预留之所以值回它的代价,前提是滚动条在这里确实占布局空间——这并非浏览器的默认行为,而是本客户端的选择:ui-theme 的样式表给 `::-webkit-scrollbar` 声明了宽度([滚动条主题化](2026-07-28-themed-scrollbars-and-reserved-gutter.md)),侧边栏的会话列表也正是出于同一原因预留了自己的滚动条槽。 + +## 曾考虑的替代方案 + +**把 overlay 座位按滚动条宽度内缩。** 这是对该缺陷最窄的一种解读——两种状态差 8px,那就从一侧减去 8px。之所以否决,是因为这个数字属于引擎而不属于我们:WebKit 路径绘制样式表里的 8px 滚动条,Firefox 路径绘制 `scrollbar-width: thin` 解析出的宽度,硬编码的内缩会让两种状态在 Chromium 上对齐、在别处继续漂移。滚动条槽是请引擎按它自己那条滚动条的宽度去预留,无论那是多少。 + +**保留 `overflow: hidden`,只加 `scrollbar-gutter: stable`。** 单行版本。它能在浏览器车道所用的引擎上修掉可见症状,却把症状原封不动留在 Safari 上,而且任何测试都不会失败——这正是改动的后一半所要防的失效模式。 + +**让 Chat 的 composer 座位也移出滚动容器,使 overlay 的几何成为唯一的几何。** 这是从根上删掉差异,而不是调和它,代价是放弃一项刻意的性质:sticky 座位位于滚动流之内,因此在 composer 上滚轮会带动对话记录([sticky composer](2026-07-29-sticky-composer-conversation-scroll.md)),其上方的渐隐遮罩也由座位自身的背景绘制。两者都是有主、有覆盖的既有行为;为了消除 8px 的不对称而重建它们,是更大的改动而非更小的。 + +**给会话列加上一条滚动条宽度的内边距,而不是预留滚动条槽。** 内边距无论是否存在滚动条都会生效,因此在每种状态下都无条件付出这份宽度,而且它把一个由引擎在布局期决定的值钉死在样式表里。否决理由与侧边栏列表当初否决它时相同。 + +## 后果 + +- Chat 的内容列永久变窄 8px——hero 态与对话记录尚短、根本不绘制滚动条时同样如此。这就是这笔交易:以最宽的列换取卡片在任何内容高度下都只有一个位置。 +- 一条声明覆盖三种切换,因为这三者本就是同一个差异:Chat ↔ Trajectory、Chat 内部的短对话 ↔ 可滚动对话,以及 hero ↔ 第一个可滚动轮次。 +- overlay 状态现在是一个滚动容器。今天其中没有任何内容会溢出;将来若有视图允许自身内容超出会话列,这个盒子会滚动而不是裁剪,那个视图就需要像 Trajectory 视图那样自带裁剪。 +- 提交的 golden 记录了预留的带宽,因此样式表中 `::-webkit-scrollbar` 宽度的变化——决定这条预留有多宽的那个值——会在本场景中与在侧边栏场景中一样,以可评审的 diff 形式出现。 + +## 测试 + +`apps/web/tests/composer-tab-geometry.e2e.ts` 在两个标签页下测量输入卡片的矩形,分别取卡片处于宽度上限的视口与卡片随列收缩的视口,并断言这两个矩形是同一个矩形。只有真实引擎能报告这件事:jsdom 给每个元素的盒子尺寸都是零,也没有滚动条,因此单元测试只能断言那些声明存在,无法断言两种状态落在同一位置。出于同一原因,本次没有附带读取 CSS 文本的单元测试——它只会把声明复述一遍,并不会补上浏览器车道尚未确立的事实。 + +该场景启动 chromium 时去掉了 Playwright 默认的 `--hide-scrollbars`,这一点是承重的:带上该参数时滚动条不占任何布局宽度,两个标签页在改动前后同样一致,文件中的每一处比较都会空洞地通过。实测:带上该参数时,改动前的层叠让两侧带宽都是 0;去掉它则是 8 与 0。 + +随后,改动前的层叠会被注入页面——滚动容器上 `scrollbar-gutter: auto`,overlay 分支上 `overflow: hidden`——并在其下测量同样的两个标签页,这正是把"卡片确实没动"与"标签页切换根本没到达布局"区分开的那一步。它把上报的症状复现为一个数字:每条边 4px,恰是 8px 带宽的一半。golden 把这份对照与修复后的状态并排记录,因此 fixture 承载的是这次改动所消除的那个差值,而不仅仅是它的缺席。 diff --git a/apps/web/tests/composer-tab-geometry.e2e.ts b/apps/web/tests/composer-tab-geometry.e2e.ts new file mode 100644 index 0000000000..a7206e0bbd --- /dev/null +++ b/apps/web/tests/composer-tab-geometry.e2e.ts @@ -0,0 +1,388 @@ +// Web e2e scenario: the input card holds one horizontal position across the +// Chat and Trajectory tabs. +// +// The composer seat is the same node in both tabs, but it measures itself +// against a different edge in each (see +// packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css). +// In Chat it is a sticky CHILD of the column's scroller, so it rides that +// scroller's content box — the box a space-consuming scrollbar shortens. A view +// that opts into a composer overlay (`data-conversation-composer-overlay`, which +// Trajectory declares and which moves the column's own scrolling into the view) +// gets an absolutely positioned seat instead, laid out against the padding box, +// which the scrollbar never reduces. +// +// So the two tabs disagreed by exactly the bar's width for as long as the +// transcript overflowed: the card jumped sideways on every tab switch, and +// inside Chat alone at the moment a growing transcript started to scroll. The +// column now reserves the gutter unconditionally (`scrollbar-gutter: stable`) +// and states the overlay branch as a scroll container on the same axes, so both +// edges are the same edge. +// +// Only a real engine can show this. The seat's geometry is layout: jsdom gives +// every element a zero-sized box and reports no scrollbar at all, so a unit spec +// can assert the declarations exist but not that the two states land in the same +// place. What is asserted here is the user-visible fact — the card does not move +// — measured as the distance between the two tabs' card rectangles. +// +// The browser is launched WITHOUT Playwright's default `--hide-scrollbars`, +// which is load-bearing rather than incidental. Under that argument a scroll +// container's bar consumes no layout width at all, so the two tabs agree before +// this change as much as after it and every comparison below holds vacuously — +// measured: the pre-fix cascade leaves both tabs' bands at 0 there, against 8 +// and 0 with the argument dropped. Dropping it is also the faithful +// configuration: ui-theme's scrollbar.css gives `::-webkit-scrollbar` a width, +// and a bar that occupies layout space is what the product actually draws. +// +// The scenario runs that pre-fix cascade in the page — `scrollbar-gutter: auto` +// on the scroller, `overflow: hidden` on the overlay branch — and measures the +// same two tabs through it, which is what keeps the equal rectangles above from +// being explained by a tab switch that never reached the layout. It is the +// reported symptom as a number: the card moves 4px, half the 8px band, on each +// edge. +// +// Zero model calls: a seeded cold session renders from its log, and switching +// tabs asks the host for nothing. A stray stream would fail loud with NO_ADAPTER. +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 { createChatScrollFixture } from './chat-scroll-fixture.ts' +import { + assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, + webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/composer-tab-geometry', import.meta.url)) +/** + * Committed golden of where the input card sits in each tab, at a wide viewport + * (card at its width cap) and a narrow one (card shrinking with the column). + * + * Absolute coordinates are deliberately absent: they depend on the sidebar's + * laid-out width and on font metrics, so committing them would produce a fixture + * that has to be re-recorded per platform. What is recorded is the distance + * between the two tabs' rectangles, which is zero when the reservation holds and + * the bar's width when it does not — including under the control, so the golden + * carries the difference the fix removes rather than only its absence. + */ +const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md') +const MODE = webSnapshotMode() + +/** Long enough that the transcript overflows the lane's 1000px viewport; the scenario asserts the overflow rather than trusting it. */ +const FIXTURE = createChatScrollFixture({ + markerPrefix: 'TAB_GEOMETRY', + title: 'COMPOSER_TAB_GEOMETRY long session', + turns: 24, +}) +const SEED_ID = 'composer-tab-geometry-web-e2e' + +/** Viewport widths the scenario measures at: the card capped, and the card shrinking with the column. */ +const WIDE_VIEWPORT = { width: 1680, height: 1000 } +const NARROW_VIEWPORT = { width: 900, height: 1000 } + +/** + * The pre-fix cascade, injected into the page: the reservation dropped and the + * overlay branch back to a hidden box. `!important` beats the module rules + * without a rebuild, and the id lets the control be lifted again in the same + * session. + */ +const CONTROL_STYLE_ID = 'composer-tab-geometry-control' +const CONTROL_CSS = ` +[data-conversation-scroll] { scrollbar-gutter: auto !important; } +[data-conversation-scroll]:has([data-conversation-composer-overlay]) { overflow: hidden !important; } +` + +/** The column scroller and the input card as the browser lays them out, in one tab. */ +interface TabMetrics { + /** Resolved `scrollbar-gutter` on the column's scroller. */ + gutter: string + /** Resolved `overflow-x`: `hidden` in both states, so neither grows a horizontal bar. */ + overflowX: string + /** Resolved `overflow-y`: `auto` in both states, which is the form WebKit honours the gutter on. */ + overflowY: string + /** Border-box width minus client width: the space the scrollbar takes out of the content area. */ + band: number + /** True when the column's scroller actually scrolls — only Chat does. */ + scrolls: boolean + /** Left edge of the input card in viewport coordinates. */ + cardLeft: number + /** Right edge of the input card. */ + cardRight: number + /** Width of the input card, capped at the composer card max width. */ + cardWidth: number +} + +/** One tab's metrics beside the other's, plus the distances between them. */ +interface TabComparison { + chat: TabMetrics + trajectory: TabMetrics + /** Distance between the two tabs' card left edges: 0 when the card holds its position. */ + leftShift: number + /** Distance between the two tabs' card right edges. */ + rightShift: number + /** Difference between the two tabs' card widths. */ + widthShift: number +} + +/** + * Measure the column scroller and the input card in the tab currently shown. + * @param page - the page under test. + * @returns the scroller's resolved overflow style and the card's rectangle. + */ +function measureTab(page: Page): Promise { + return page.evaluate(() => { + const host = document.querySelector('[data-conversation-scroll]') + if (host === null) throw new Error('conversation column scroller not in the DOM') + const card = host.querySelector('[data-composer-seat] [data-composer-card]') + if (card === null) throw new Error('no input card inside the composer seat') + const style = getComputedStyle(host) + const hostRect = host.getBoundingClientRect() + const cardRect = card.getBoundingClientRect() + return { + gutter: style.scrollbarGutter, + overflowX: style.overflowX, + overflowY: style.overflowY, + band: hostRect.width - host.clientWidth, + scrolls: host.scrollHeight > host.clientHeight, + cardLeft: cardRect.left, + cardRight: cardRect.right, + cardWidth: cardRect.width, + } + }) +} + +/** + * Show one tab and wait for the view that owns it to be laid out. + * @param page - the page under test. + * @param tab - the tab to show. + */ +async function showTab(page: Page, tab: 'Chat' | 'Trajectory'): Promise { + await page.getByRole('tab', { name: tab, exact: true }).click() + if (tab === 'Trajectory') await page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 }) + else await page.locator('[data-conversation-scroll] [data-chat-anchor-key]').first().waitFor({ timeout: 30_000 }) + // Both measurements are taken after a paint, so a rectangle read mid-transition + // cannot be reported as a shift the cascade did not cause. + await page.evaluate(() => new Promise((settle) => { + requestAnimationFrame(() => { requestAnimationFrame(() => { settle() }) }) + })) +} + +/** + * Measure both tabs and the distances between them, leaving Chat shown. + * @param page - the page under test. + * @returns each tab's metrics and the card's displacement between them. + */ +async function compareTabs(page: Page): Promise { + await showTab(page, 'Chat') + const chat = await measureTab(page) + await showTab(page, 'Trajectory') + const trajectory = await measureTab(page) + await showTab(page, 'Chat') + return { + chat, + trajectory, + leftShift: Math.abs(trajectory.cardLeft - chat.cardLeft), + rightShift: Math.abs(trajectory.cardRight - chat.cardRight), + widthShift: Math.abs(trajectory.cardWidth - chat.cardWidth), + } +} + +/** + * Run the pre-fix cascade in the page for one measurement, then lift it. + * @param page - the page under test. + * @returns the comparison as the column laid out before this change. + */ +async function compareTabsWithoutReservation(page: Page): Promise { + await page.evaluate(({ id, css }) => { + const style = document.createElement('style') + style.id = id + style.textContent = css + document.head.append(style) + }, { id: CONTROL_STYLE_ID, css: CONTROL_CSS }) + try { + return await compareTabs(page) + } finally { + await page.evaluate((id) => { document.getElementById(id)?.remove() }, CONTROL_STYLE_ID) + } +} + +/** + * Open the seeded session from the sidebar search. + * + * Cold summaries carry the temp workspace's basename, so the persisted first + * message is the stable identity to search for, and the query itself drives the + * lazy content-index reconciliation. Hand-rolled polling because `expect.poll` + * is test-scoped and this runs in `beforeAll`. + * @param page - the page under test. + */ +async function openSeededSession(page: Page): Promise { + const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true }) + await search.fill(FIXTURE.markers.user(1)) + const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem') + const deadline = Date.now() + 60_000 + for (;;) { + if (await results.count() === 1) break + if (Date.now() > deadline) throw new Error('seeded session never appeared in the sidebar search results') + await page.waitForTimeout(200) + } + await results.click() +} + +/** + * Render the golden body. + * @param wide - comparison at the viewport where the card sits at its width cap. + * @param narrow - comparison at the viewport where the card shrinks with the column. + * @param control - comparison at the wide viewport with the reservation removed. + * @returns the golden body, without a trailing newline. + */ +function renderGeometry(wide: TabComparison, narrow: TabComparison, control: TabComparison): string { + const section = (name: string, comparison: TabComparison): string[] => [ + `## ${name}`, + '', + `- Chat: scrollbar-gutter ${comparison.chat.gutter}, overflow ${comparison.chat.overflowX}/${comparison.chat.overflowY}`, + `- Chat scroller scrolls: ${String(comparison.chat.scrolls)}`, + `- Chat reserved band: ${String(comparison.chat.band)}px`, + `- Trajectory: scrollbar-gutter ${comparison.trajectory.gutter}, overflow ${comparison.trajectory.overflowX}/${comparison.trajectory.overflowY}`, + `- Trajectory scroller scrolls: ${String(comparison.trajectory.scrolls)}`, + `- Trajectory reserved band: ${String(comparison.trajectory.band)}px`, + `- input card left edge moves between tabs: ${String(comparison.leftShift)}px`, + `- input card right edge moves between tabs: ${String(comparison.rightShift)}px`, + `- input card width changes between tabs: ${String(comparison.widthShift)}px`, + '', + ] + return [ + '# Input card position across the Chat and Trajectory tabs', + '', + ...section(`Wide viewport (${String(WIDE_VIEWPORT.width)}px, card at its cap)`, wide), + ...section(`Narrow viewport (${String(NARROW_VIEWPORT.width)}px, card shrinking with the column)`, narrow), + ...section('Wide viewport, reservation removed in the page (control)', control), + ].join('\n').trimEnd() +} + +describe('web e2e: input card position across view tabs', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, FIXTURE.log, SEED_ID) + // Scrollbars must take layout space here or the scenario proves nothing; + // see the file header for the measurement behind dropping this argument. + browser = await chromium.launch({ ignoreDefaultArgs: ['--hide-scrollbars'] }) + page = await newEnglishPage(browser, WIDE_VIEWPORT.height) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await openSeededSession(page) + await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ timeout: 30_000 }) + await page.getByText(FIXTURE.markers.assistant(FIXTURE.turns), { exact: false }).last() + .waitFor({ timeout: 30_000 }) + }, 180_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('reserves the same gutter in both tabs while the transcript scrolls', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-band')) + await page.setViewportSize(WIDE_VIEWPORT) + // Vacuity guard, in two parts. A transcript that does not overflow gives + // Chat no scrollbar, and a hidden or overlaid bar gives it no width; either + // would make the tabs agree without the reservation doing anything. + await expect.poll(async () => (await measureTab(page)).scrolls, { timeout: 10_000 }).toBe(true) + const comparison = await compareTabs(page) + expect(comparison.chat.band).toBeGreaterThan(0) + // The reservation reaches both states, which is the whole change: the same + // band, on a box that scrolls and on one that only holds a view. + expect(comparison.chat.gutter).toBe('stable') + expect(comparison.trajectory.gutter).toBe('stable') + expect(comparison.trajectory.band).toBe(comparison.chat.band) + // Declared as a scroll container on both axes rather than left to compute: + // `overflow: hidden` would drop the reservation in WebKit, and a `visible` + // horizontal axis computes to `auto` beside a scrolling one. + expect(comparison.trajectory.overflowY).toBe('auto') + expect(comparison.trajectory.overflowX).toBe('hidden') + // Only Chat scrolls this box; the Trajectory view owns its own scrollers. + expect(comparison.trajectory.scrolls).toBe(false) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('holds the input card in place when the tab changes', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-wide')) + await page.setViewportSize(WIDE_VIEWPORT) + const comparison = await compareTabs(page) + // The reported symptom as a number. At this viewport the card sits at its + // width cap, so the pre-fix shift showed up as a centring difference — half + // the band on each edge — rather than as a width change. + expect(comparison.leftShift).toBe(0) + expect(comparison.rightShift).toBe(0) + expect(comparison.widthShift).toBe(0) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('holds the input card in place at a viewport where it shrinks with the column', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-narrow')) + await page.setViewportSize(WIDE_VIEWPORT) + const capped = await measureTab(page) + await page.setViewportSize(NARROW_VIEWPORT) + const comparison = await compareTabs(page) + // The other geometry, and a different failure: below the cap the card takes + // the column's width, so an unreserved gutter changed its WIDTH by the whole + // band instead of shifting it by half. Asserted against the capped + // measurement rather than against the cap's pixel value, which belongs to + // the stylesheet. + expect(comparison.chat.cardWidth).toBeLessThan(capped.cardWidth) + expect(comparison.leftShift).toBe(0) + expect(comparison.rightShift).toBe(0) + expect(comparison.widthShift).toBe(0) + await page.setViewportSize(WIDE_VIEWPORT) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('moves the card again once the reservation is removed in the page', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-control')) + await page.setViewportSize(WIDE_VIEWPORT) + // The control: without it, equal rectangles could also mean the tab switch + // never reached the layout. Under the pre-fix cascade the Chat scroller keeps + // its bar and the Trajectory branch goes back to a hidden box with none, and + // the card moves by half the band on each edge. + const comparison = await compareTabsWithoutReservation(page) + expect(comparison.chat.gutter).toBe('auto') + expect(comparison.chat.band).toBeGreaterThan(0) + expect(comparison.trajectory.band).toBe(0) + expect(comparison.leftShift).toBe(comparison.chat.band / 2) + expect(comparison.rightShift).toBe(comparison.chat.band / 2) + // Restoring the sheet restores the fix, so the control cannot leak into the + // remaining measurements. + const restored = await compareTabs(page) + expect(restored.leftShift).toBe(0) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('matches the committed tab geometry golden', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-golden')) + await page.setViewportSize(WIDE_VIEWPORT) + const wide = await compareTabs(page) + await page.setViewportSize(NARROW_VIEWPORT) + const narrow = await compareTabs(page) + await page.setViewportSize(WIDE_VIEWPORT) + const control = await compareTabsWithoutReservation(page) + await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(wide, narrow, control), MODE) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('commits exactly the fixtures it reads', async () => { + // The seeded session is generated in-process, so the geometry golden is the + // whole inventory. + await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md']) + }) + + it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', () => { + expect(tripwire.warnings).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + }) +}) diff --git a/apps/web/tests/snapshots/composer-tab-geometry/geometry.expected.md b/apps/web/tests/snapshots/composer-tab-geometry/geometry.expected.md new file mode 100644 index 0000000000..a4f4319c93 --- /dev/null +++ b/apps/web/tests/snapshots/composer-tab-geometry/geometry.expected.md @@ -0,0 +1,37 @@ +# Input card position across the Chat and Trajectory tabs + +## Wide viewport (1680px, card at its cap) + +- Chat: scrollbar-gutter stable, overflow auto/auto +- Chat scroller scrolls: true +- Chat reserved band: 8px +- Trajectory: scrollbar-gutter stable, overflow hidden/auto +- Trajectory scroller scrolls: false +- Trajectory reserved band: 8px +- input card left edge moves between tabs: 0px +- input card right edge moves between tabs: 0px +- input card width changes between tabs: 0px + +## Narrow viewport (900px, card shrinking with the column) + +- Chat: scrollbar-gutter stable, overflow auto/auto +- Chat scroller scrolls: true +- Chat reserved band: 8px +- Trajectory: scrollbar-gutter stable, overflow hidden/auto +- Trajectory scroller scrolls: false +- Trajectory reserved band: 8px +- input card left edge moves between tabs: 0px +- input card right edge moves between tabs: 0px +- input card width changes between tabs: 0px + +## Wide viewport, reservation removed in the page (control) + +- Chat: scrollbar-gutter auto, overflow auto/auto +- Chat scroller scrolls: true +- Chat reserved band: 8px +- Trajectory: scrollbar-gutter auto, overflow hidden/hidden +- Trajectory scroller scrolls: false +- Trajectory reserved band: 0px +- input card left edge moves between tabs: 4px +- input card right edge moves between tabs: 4px +- input card width changes between tabs: 0px diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index ecb4f0db6c..112731204b 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -61,6 +61,7 @@ "tests/chat-scroll-contract.e2e.ts", "tests/chat-long-interactions.e2e.ts", "tests/chat-continuous-conversation.e2e.ts", + "tests/composer-tab-geometry.e2e.ts", "tests/complex-history.perf.ts" ], "references": [ diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index bf1e21a541..654eac8ede 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -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-conversation/README.md -README.md: 7d3a4b5fe07cc8858c2f2059e6f65b6e27602b4d -README.zh.md: d93af91381157bb4e8e4b6a14ad00edecd505246 +README.md: ce728fb8b35d9813050a00cf9fa6a4f268820fea +README.zh.md: 7e73a2b4a4d19d81ea62701dd71ed68f32492c48 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 7d3a4b5fe0..ce728fb8b3 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -6,7 +6,7 @@ Conversation domain: skeleton (header/tabs/composer/empty state), chat view (gro Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. The disclosure renders the checkpoint's `compact/summary` provenance; when that event is outside the loaded window, the row remains visible but non-expandable. The framed checkpoint payload is model-facing and never renders. -The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it a scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. +The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it a scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: `), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index d93af91381..7e73a2b4a4 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -6,7 +6,7 @@ 压缩(compaction)在检查点自身的消息流位置渲染为一行折叠标记,不替换其上方的 transcript(文本记录)。展开内容来自检查点溯源的 `compact/summary`;该事件位于已加载窗口之外时,标记仍然可见但不可展开。面向模型的带框检查点载荷绝不渲染。 -常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 +常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。该滚动容器无条件预留自己的滚动条槽,选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md))。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 视图环本身就是 slot:会话注册声明 `'conversation.view'` 列表 slot(Session scope),并将其列在 `children` 表中;ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: `);视图标签页从环账本的注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包(package)自身的环配置项;其他插件(ui-trajectory)通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView`/`ViewEntry`/`ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。 diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index 1a83efb551..b2ab6d558a 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -182,6 +182,17 @@ flex-direction: column; min-height: 0; overflow-y: auto; + /* One reserved gutter for every view, because the composer seat takes its + horizontal geometry from two different edges: in Chat it is a sticky CHILD + of this scroller and so rides the content box, which the scrollbar + shortens, while under a view's composer overlay it is positioned against + the padding box, which the scrollbar never reduces. `auto` would reserve + only while this box overflows, which is precisely the difference between + the two states, so the input card would move sideways by the bar's width + on every tab switch — and inside Chat alone the moment a growing + transcript starts to scroll. `stable` makes both edges the same edge at + every content height. */ + scrollbar-gutter: stable; } .root[data-phase='active'] .viewArea { @@ -212,7 +223,16 @@ ownership of the seat geometry and its active-phase precedence. */ .scrollBody:has([data-conversation-composer-overlay]) { position: relative; - overflow: hidden; + /* Still a box nothing scrolls out of — the view fills it and the seat is out + of flow — but declared as a scroll container on the same axes as the Chat + state instead of `overflow: hidden`, so the reservation above reaches this + state too: WebKit honours `scrollbar-gutter` on an `overflow-y: auto` box + and ignores it on a hidden one (measured for the composer's own layers, + see InputBar.module.css). The horizontal axis is declared rather than left + to compute, because a box that scrolls on one axis computes `visible` on + the other to `auto` and would grow a horizontal bar of its own. */ + overflow-x: hidden; + overflow-y: auto; } .scrollBody:has([data-conversation-composer-overlay]) > .viewArea { diff --git a/tsconfig.host.json b/tsconfig.host.json index bfc4f898e8..8205019f89 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -48,6 +48,7 @@ "apps/web/tests/chat-scroll-contract.e2e.ts", "apps/web/tests/chat-long-interactions.e2e.ts", "apps/web/tests/chat-continuous-conversation.e2e.ts", + "apps/web/tests/composer-tab-geometry.e2e.ts", "apps/web/tests/complex-history.perf.ts", "apps/web/stress-tests/reasoning-chunks.stress.ts", "apps/cli/tests/**/*.ts", From 1098dc983611a88e445e4511da019b0bc2fd815a Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 4 Aug 2026 16:21:55 +0800 Subject: [PATCH 04/21] docs(web): cite the surviving composer scrollport note for the WebKit gutter measurement --- .../2026-08-04-composer-tab-gutter-reservation.i18n.yaml | 4 ++-- .../bug-fix/2026-08-04-composer-tab-gutter-reservation.md | 2 +- .../bug-fix/2026-08-04-composer-tab-gutter-reservation.zh.md | 2 +- .../src/client/skeleton/ConversationRoot.module.css | 5 +++-- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.i18n.yaml index 176c649ecd..c4a8fd6268 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.i18n.yaml @@ -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 .agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md -2026-08-04-composer-tab-gutter-reservation.md: f9a0c346c0c023fe2602486376b9794781f824db -2026-08-04-composer-tab-gutter-reservation.zh.md: 67a284ec0a12e464ad6f1dc17e954bdebd28e80d +2026-08-04-composer-tab-gutter-reservation.md: 3b28c35c1f11676e41cabde76d1b0d16c688f034 +2026-08-04-composer-tab-gutter-reservation.zh.md: 26e8b6bff73a01e6518f3918d201330c1d029876 diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md index f9a0c346c0..3b28c35c1f 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md +++ b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md @@ -18,7 +18,7 @@ So for as long as the transcript overflowed — the ordinary state of any sessio The two halves are one change. The reservation is what makes both states measure against the same width; declaring the overlay branch a scroll container is what makes the reservation reach it. `stable` rather than `auto` because `auto` reserves only while the box actually overflows, and the difference between overflowing and not is precisely the difference between the two tabs — an `auto` gutter would state the bug rather than fix it. -The overlay state is a scroll container that nothing scrolls: the view fills it (`flex: 1 1 0` with its own clip) and the seat is out of flow, so no gesture and no clipping behavior changes. What changes is which declarations the engine honours. WebKit applies `scrollbar-gutter` to an `overflow-y: auto` box and ignores it on a hidden one — measured on this app's own composer layers and recorded in [the composer glyph-layer note](2026-07-31-composer-glyph-layer-tracks-the-textarea.md) — so a reservation left on a hidden box would hold in Chromium and silently not in Safari. +The overlay state is a scroll container that nothing scrolls: the view fills it (`flex: 1 1 0` with its own clip) and the seat is out of flow, so no gesture and no clipping behavior changes. What changes is which declarations the engine honours. WebKit applies `scrollbar-gutter` to an `overflow-y: auto` box and ignores it on a hidden one — measured on this app's own composer layers and recorded in [the composer scrollport note](2026-07-31-composer-text-layers-share-one-scrollport.md) — so a reservation left on a hidden box would hold in Chromium and silently not in Safari. The horizontal axis is declared rather than left to compute: a box that scrolls on one axis computes `visible` on the other to `auto`, and would grow a horizontal scrollbar of its own the first time a view's content reached past the column. diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.zh.md index 67a284ec0a..26e8b6bff7 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.zh.md @@ -18,7 +18,7 @@ composer 座位在组件树中只有一个节点、一个位置,但它究竟 这两半是同一处改动。预留使两种状态依附于同一个宽度;把 overlay 分支声明为滚动容器,才使这条预留真正抵达它。选 `stable` 而非 `auto`,是因为 `auto` 只在盒子确实溢出时才预留,而"溢出与否"恰恰就是两个标签页之间的那点差别——`auto` 的写法只是把缺陷重述一遍,并不能修掉它。 -overlay 状态是一个没有任何东西会去滚动它的滚动容器:视图把它填满(`flex: 1 1 0`,且自带裁剪),座位不在常规流中,因此没有任何手势与裁剪行为发生变化。变化的是引擎会认哪些声明。WebKit 对 `overflow-y: auto` 的盒子应用 `scrollbar-gutter`,对 hidden 的盒子则忽略它——这是在本应用 composer 自身的图层上实测所得,并记录于 [composer 字形层记录](2026-07-31-composer-glyph-layer-tracks-the-textarea.md)——所以把预留留在一个 hidden 盒子上,会在 Chromium 上成立,在 Safari 上悄无声息地不成立。 +overlay 状态是一个没有任何东西会去滚动它的滚动容器:视图把它填满(`flex: 1 1 0`,且自带裁剪),座位不在常规流中,因此没有任何手势与裁剪行为发生变化。变化的是引擎会认哪些声明。WebKit 对 `overflow-y: auto` 的盒子应用 `scrollbar-gutter`,对 hidden 的盒子则忽略它——这是在本应用 composer 自身的图层上实测所得,并记录于 [composer 滚动容器记录](2026-07-31-composer-text-layers-share-one-scrollport.md)——所以把预留留在一个 hidden 盒子上,会在 Chromium 上成立,在 Safari 上悄无声息地不成立。 横向轴是显式声明的,而不是交给推导:单轴滚动的盒子会把另一轴的 `visible` 计算为 `auto`,于是只要某个视图的内容第一次伸出列外,它就会长出自己的横向滚动条。 diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index b2ab6d558a..eb2b412972 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -227,8 +227,9 @@ of flow — but declared as a scroll container on the same axes as the Chat state instead of `overflow: hidden`, so the reservation above reaches this state too: WebKit honours `scrollbar-gutter` on an `overflow-y: auto` box - and ignores it on a hidden one (measured for the composer's own layers, - see InputBar.module.css). The horizontal axis is declared rather than left + and ignores it on a hidden one (measured for the composer's own layers — + .agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md + cites the measurement). The horizontal axis is declared rather than left to compute, because a box that scrolls on one axis computes `visible` on the other to `auto` and would grow a horizontal bar of its own. */ overflow-x: hidden; From 60e4509c1d4239828b1a513d623f83842597dcff Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 4 Aug 2026 16:30:45 +0800 Subject: [PATCH 05/21] docs(web): trim the column's gutter comments to contract plus note link --- .../skeleton/ConversationRoot.module.css | 28 ++++++------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index eb2b412972..733e6b6ee0 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -182,16 +182,10 @@ flex-direction: column; min-height: 0; overflow-y: auto; - /* One reserved gutter for every view, because the composer seat takes its - horizontal geometry from two different edges: in Chat it is a sticky CHILD - of this scroller and so rides the content box, which the scrollbar - shortens, while under a view's composer overlay it is positioned against - the padding box, which the scrollbar never reduces. `auto` would reserve - only while this box overflows, which is precisely the difference between - the two states, so the input card would move sideways by the bar's width - on every tab switch — and inside Chat alone the moment a growing - transcript starts to scroll. `stable` makes both edges the same edge at - every content height. */ + /* Reserved unconditionally: the composer seat rides this box's content box in + Chat and its padding box under a view's composer overlay, so an `auto` + gutter moves the input card sideways by the bar's width whenever the two + differ ([decision](../../../../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). */ scrollbar-gutter: stable; } @@ -223,15 +217,11 @@ ownership of the seat geometry and its active-phase precedence. */ .scrollBody:has([data-conversation-composer-overlay]) { position: relative; - /* Still a box nothing scrolls out of — the view fills it and the seat is out - of flow — but declared as a scroll container on the same axes as the Chat - state instead of `overflow: hidden`, so the reservation above reaches this - state too: WebKit honours `scrollbar-gutter` on an `overflow-y: auto` box - and ignores it on a hidden one (measured for the composer's own layers — - .agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md - cites the measurement). The horizontal axis is declared rather than left - to compute, because a box that scrolls on one axis computes `visible` on - the other to `auto` and would grow a horizontal bar of its own. */ + /* A clipping box nothing scrolls out of, stated as a scroll container on both + axes rather than `overflow: hidden`: WebKit honours the reservation above + only in the `overflow-y: auto` form, and a single-axis scroller computes + the other axis to `auto` + ([decision](../../../../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). */ overflow-x: hidden; overflow-y: auto; } From 13fed3721f2b64ed00dd89ad20231742876aecee Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 4 Aug 2026 16:53:52 +0800 Subject: [PATCH 06/21] fix(trajectory): preserve state across history prepends --- ...-27-trajectory-inspection-ledger.i18n.yaml | 4 +- ...2026-07-27-trajectory-inspection-ledger.md | 6 +- ...6-07-27-trajectory-inspection-ledger.zh.md | 6 +- docs/core-data-structures/session.i18n.yaml | 4 +- docs/core-data-structures/session.md | 2 + docs/core-data-structures/session.zh.md | 2 + packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- .../src/client/contract/session-history.ts | 2 + .../src/client/session-history/source.ts | 8 +- .../tests/session-history-source.spec.ts | 2 + .../src/client/TrajectoryTable.tsx | 114 +++++++++------ .../src/client/TrajectoryTimeline.module.css | 3 +- .../src/client/TrajectoryView.tsx | 57 ++++++-- .../src/client/context-branches.ts | 7 + .../client/ui-trajectory/src/client/layout.ts | 9 +- .../src/client/trajectory-record.ts | 14 ++ .../tests/context-branches.spec.ts | 12 ++ .../ui-trajectory/tests/layout.spec.tsx | 28 ++++ .../client/ui-trajectory/tests/table.spec.tsx | 138 +++++++++++++++++- .../client/ui-trajectory/tests/views.spec.tsx | 69 +++++++++ packages/core/session/tests/surface.spec.ts | 14 ++ 23 files changed, 425 insertions(+), 84 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml index c0bf4a0d51..7c9368b5c4 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md -2026-07-27-trajectory-inspection-ledger.md: 6447baaa7a0e949ba7cb357b3741a3b5c11851e6 -2026-07-27-trajectory-inspection-ledger.zh.md: 3c17b5f3bddeac3d27e7ef07b4aa54cea05fa3d3 +2026-07-27-trajectory-inspection-ledger.md: 0bab35eaba2c0c741c2340532fd0e37f8befd0b1 +2026-07-27-trajectory-inspection-ledger.zh.md: 4bcbe873d8166756cbdd87a4f7a28cc6bf3b46b1 diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md index 6447baaa7a..0bab35eaba 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md @@ -21,10 +21,10 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested - Call schemas come from the active recorded Request header. Keyless snapshot fixtures deliberately replace that catalog with the non-array `{{tools}}` token, which the durable inspection boundary treats as unavailable instead of attempting to project or fabricate schemas. - Selecting a record or Request opens an inspector inside Trajectory. Tabs and Summary sections follow the selected entity: Markdown messages expose rendered, source, provenance, and hierarchy views; tools add JSON payload/result and schema views; Requests add options, usage, timing, and result navigation. Images render as media rather than serialized data. - Turn folding removes all rows after its first record and replaces them with a compact step/tool-call count; Assistant folding applies the same interaction to its tool-call descendants. Global controls fold or expand both levels. -- A long ledger initially positions the loaded tail at the bottom and mounts only the viewport's row window plus bounded overscan. Fixed row estimates and virtual spacer rows preserve the loaded scroll range, while selection, timeline focus, folding, search, and bottom following address records by their position in the projection rather than requiring their DOM rows to exist. An explicit loading row covers records until initial positioning finishes and while an older page is pending. Prepending that page restores the prior visible anchor instead of jumping to the new top. +- A long ledger initially positions the loaded tail at the bottom and mounts only the viewport's row window plus bounded overscan. Fixed row estimates and virtual spacer rows preserve the loaded scroll range, while selection, timeline focus, folding, search, and bottom following address records by stable event or tool-call identity rather than requiring their DOM rows to exist. An explicit loading row covers records until initial positioning finishes and while an older page is pending. Prepending that page restores the prior visible anchor instead of jumping to the new top; the raw window base sequence detects the prepend even when a page adds no surface-visible node. - The separate Waterfall tab is removed. A fixed Overview above the ledger projects every loaded record with known `startedAt` onto three semantic timing lanes using its own duration. While an older prefix remains unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control covers the truncated edge and loads one earlier page without assigning unknown history a fabricated duration; hovering that control suppresses the ordinary timeline cursor. Finalized Assistant spans divide the recorded interval at the first non-empty token delta, so distinct TTFT and decoding colors retain their actual ratio; incomplete timing falls back to one Assistant color. Hovering for 500 ms exposes exact start/end, total duration, TTFT, and decoding time without relying on the browser's native tooltip delay. Dragging left or right commits an inclusive interval filter: any record whose active interval overlaps either boundary remains visible, records without known timing leave the focused ledger, and clearing the selection restores the full branch. Wheel gestures zoom the time domain. A right-button click clears the interval selection; dragging instead pans an already zoomed viewport without mutating it. The Overview keeps the full time domain while focused so the selection can be resized or cleared without losing orientation. - Live history updates retain the ledger's bottom position only while the user is already following its tail. Scrolling upward clears that follow state, so streamed chunks and newly appended records do not interrupt inspection of earlier rows. -- Token streaming reuses the finalized history inspection, layout, Request numbering, and Overview projection. A frame appends only the current partial Assistant cells; text and reasoning deltas do not re-fold the loaded prefix, while message completion, tool lifecycle, compaction, rewrites, and other structural events rebuild the affected projections. +- Token streaming reuses the finalized history inspection, layout, Request numbering, Overview projection, and search results. A frame appends only the current partial Assistant cells and searches that partial when a query is active; text and reasoning deltas do not re-fold or rescan the loaded prefix, while message completion, tool lifecycle, compaction, rewrites, and other structural events rebuild the affected projections. - History folding passes the loaded window's absolute starting sequence into the canonical surface manager. Structural events therefore rebuild only the entries that are present instead of materializing synthetic events for every unloaded sequence before the window. - Trajectory opts into a conversation-owned composer overlay through `data-conversation-composer-overlay`. `ConversationRoot` positions the composer seat and publishes its live height; Trajectory keeps the ledger at full height and reserves that height plus 16 px inside its vertical table and inspector scrollers. Those panes adapt to the available width instead of exposing horizontal scrollbars beneath the overlay. - This local inspector remains independent from the conversation-wide Chat details column. At narrow widths it overlays the ledger and remains dismissible by keyboard or pointer. @@ -53,4 +53,4 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested ## Consequences -Trajectory shows more useful records per viewport while retaining Turn and Request orientation. Context rewrites and compactions remain inline with their surrounding history, while a rewind begins a successor branch that inherits only the retained prefix. The floating composer leaves the ledger visible to the viewport edge without covering its final rows or hiding horizontal controls. The main ledger omits token usage and duration so content receives the available width; the local inspector exposes those facts together with full payloads, provenance, schemas, and request timing. The Overview uses recorded start/duration and token-boundary facts without fabricating live elapsed time, and its inclusive focus behavior matches the interaction users already know from Chrome DevTools Network. Tail-first paging bounds initial transport and projection work, virtualization bounds mounted row elements, and incremental partial projection removes loaded-history length from ordinary token-frame work; structural rebuilds remain linear in the loaded window rather than its absolute tail sequence. Focused component tests pin tail-first paging, prepend anchoring, the virtual window, tail following, streaming structural sharing, high-sequence window folding, timing projection, delayed detail disclosure, folding, record and interval selection, entity-specific tabs, and running/error semantics; the assembled Web snapshot pins the ledger, Overview timing details, composer overlay geometry, and inspector through the real client composition. +Trajectory shows more useful records per viewport while retaining Turn and Request orientation. Context rewrites and compactions remain inline with their surrounding history, while a rewind begins a successor branch that inherits only the retained prefix. The floating composer leaves the ledger visible to the viewport edge without covering its final rows or hiding horizontal controls. The main ledger omits token usage and duration so content receives the available width; the local inspector exposes those facts together with full payloads, provenance, schemas, and request timing. The Overview uses recorded start/duration and token-boundary facts without fabricating live elapsed time, and its inclusive focus behavior matches the interaction users already know from Chrome DevTools Network. Tail-first paging bounds initial transport and projection work, virtualization bounds mounted row elements, and incremental partial projection removes loaded-history length from ordinary token-frame work; structural rebuilds remain linear in the loaded window rather than its absolute tail sequence. Focused component tests pin tail-first paging, prepend anchoring and identity retention, the virtual window, tail following, streaming structural sharing, high-sequence window folding, timing projection, delayed detail disclosure, folding, record and interval selection, entity-specific tabs, and running/error semantics; the assembled Web snapshot pins the ledger, Overview timing details, composer overlay geometry, and inspector through the real client composition. diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md index 3c17b5f3bd..4bcbe873d8 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md @@ -21,10 +21,10 @@ Status: implemented - 调用 schema 来自当前生效且已记录的请求头。无密钥快照 fixture(测试前置数据)有意将该目录替换为非数组 token `{{tools}}`,持久化检查边界会将其视为不可用,而不是尝试投影或虚构 schema。 - 选择记录或请求后,轨迹视图内部会打开检查器,其标签页和概览区域随实体类型变化:Markdown 消息提供渲染、源码、来源和层级视图;工具提供 JSON 载荷/结果和 schema 视图;请求提供选项、用量、计时和结果跳转。图片以媒体形式渲染,而不是显示为序列化数据。 - 折叠轮次时保留其第一条记录,将后续行替换为紧凑的步骤和工具调用数量;折叠助手时对其工具调用后代应用相同交互。全局控件可以分别折叠或展开这两个层级。 -- 长记录表初始时将已加载尾部置于底部,只挂载视口对应的行窗口及有界的额外缓冲行。固定的行高估算与虚拟占位行保留已加载内容的完整滚动范围;选择、时间线聚焦、折叠、搜索和末尾跟随均按记录在投影中的位置定位,不要求对应 DOM 行已存在。初始定位完成前以及更早页面仍在等待时,明确的加载行会遮住真实记录。该页面补入后,会恢复此前的可见锚点,而不是跳到新的顶部。 +- 长记录表初始时将已加载尾部置于底部,只挂载视口对应的行窗口及有界的额外缓冲行。固定的行高估算与虚拟占位行保留已加载内容的完整滚动范围;选择、时间线聚焦、折叠、搜索和末尾跟随均按稳定的事件或工具调用标识定位,不要求对应 DOM 行已存在。初始定位完成前以及更早页面仍在等待时,明确的加载行会遮住真实记录。该页面补入后,会恢复此前的可见锚点,而不是跳到新的顶部;原始窗口的基准序号即使在一页未增加任何 surface 可见节点时,也能检测到这次向前补页。 - 移除独立的 waterfall(瀑布式事件)标签页。固定在记录表上方的 Overview 区域将所有 `startedAt` 已知的已加载记录按各自耗时投影到三条语义计时轨道。仍有更早前缀尚未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会遮住截断边缘并加载一页更早历史,而不会为未知历史虚构耗时;悬停在该控件上会隐藏普通的时间线光标。已完成的助手时间条以首个非空 token 增量为分界,用不同颜色按真实比例表示 TTFT 与解码时间;计时不完整时退化为单一助手色。悬停 500 ms 后会显示精确起止时刻、总耗时、TTFT 和解码时间,而不依赖浏览器原生 tooltip 的延迟。向左或向右拖动会提交包含边界的区间筛选:任何活动区间与所选区间相交的记录都会保留,计时未知的记录会从聚焦后的记录表中移除,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除区间选择;右键拖动则只会平移已放大的 viewport,不会改变该选区。聚焦后,Overview 区域仍保留完整时间范围,以便在不失去方位的情况下调整或清除选择。 - 实时历史更新仅在用户已经跟随记录表末尾时保留底部位置。向上滚动会清除跟随状态,因此流式分块和新追加的记录不会打断对旧记录的检查。 -- token 流式输出会复用已完成历史的检查结果、布局、请求编号和 Overview 投影。每个帧只追加当前未完成助手的单元格;文本与推理(reasoning)增量不会重新折叠已加载前缀,而消息完成、工具生命周期、压缩、`rewrite` 及其他结构事件会重建受影响的投影。 +- token 流式输出会复用已完成历史的检查结果、布局、请求编号、Overview 投影和搜索结果。每个帧只追加当前未完成助手的单元格,并在查询处于激活状态时搜索这部分内容;文本与推理(reasoning)增量不会重新折叠或扫描已加载前缀,而消息完成、工具生命周期、压缩、`rewrite` 及其他结构事件会重建受影响的投影。 - 历史折叠会把已加载窗口的绝对起始序号传给规范 surface manager。因此,结构事件只重建实际存在的条目,而不会为窗口之前每个尚未加载的序号实体化合成事件。 - Trajectory 通过 `data-conversation-composer-overlay` 启用由会话持有的 composer 浮层模式。`ConversationRoot` 负责定位 composer seat 并发布其实时高度;Trajectory 让记录表保持全高,并在记录表与检查器的纵向滚动容器内预留该高度加 16 px。这两个窗格会根据可用宽度自适应,而不会在浮层下方暴露横向滚动条。 - 此局部检查器与会话级 Chat 详情栏相互独立。在窄屏下,检查器会覆盖记录表,并且仍可通过键盘或指针关闭。 @@ -53,4 +53,4 @@ Status: implemented ## 后果 -轨迹视图在保留轮次与请求定位的同时,每个视口可以显示更多有效记录。上下文 `rewrite` 与压缩保持在周边历史中的原始位置,`rewind` 则建立仅继承保留前缀的后继分支。浮动 composer 让记录表一直显示到视口边缘,同时不会遮住最后几行,也不会隐藏横向控件。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器展示这些数据以及完整载荷、来源、schema 和请求计时。Overview 区域使用记录的开始时间、耗时与 token 边界数据,而不虚构实时流逝时间,其包含边界的聚焦行为与用户熟悉的 Chrome DevTools Network 交互一致。尾部优先分页限制初始传输和投影工作量,虚拟化限制已挂载的行元素数量,未完成部分的增量投影则让普通 token 帧的工作量不再随已加载历史长度增长;结构重建的复杂度仍与已加载窗口线性相关,而非与尾部的绝对序号线性相关。针对性组件测试锁定尾部优先分页、向前补页锚定、虚拟窗口、末尾跟随、流式输出的结构共享、高序号窗口折叠、计时投影、延迟展示详情、折叠、记录与区间选择、实体特定标签页和运行/错误语义;组装后的 Web 快照则通过真实客户端组合锁定记录表、Overview 计时详情、composer 浮层几何形状与检查器。 +轨迹视图在保留轮次与请求定位的同时,每个视口可以显示更多有效记录。上下文 `rewrite` 与压缩保持在周边历史中的原始位置,`rewind` 则建立仅继承保留前缀的后继分支。浮动 composer 让记录表一直显示到视口边缘,同时不会遮住最后几行,也不会隐藏横向控件。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器展示这些数据以及完整载荷、来源、schema 和请求计时。Overview 区域使用记录的开始时间、耗时与 token 边界数据,而不虚构实时流逝时间,其包含边界的聚焦行为与用户熟悉的 Chrome DevTools Network 交互一致。尾部优先分页限制初始传输和投影工作量,虚拟化限制已挂载的行元素数量,未完成部分的增量投影则让普通 token 帧的工作量不再随已加载历史长度增长;结构重建的复杂度仍与已加载窗口线性相关,而非与尾部的绝对序号线性相关。针对性组件测试锁定尾部优先分页、向前补页锚定与标识保持、虚拟窗口、末尾跟随、流式输出的结构共享、高序号窗口折叠、计时投影、延迟展示详情、折叠、记录与区间选择、实体特定标签页和运行/错误语义;组装后的 Web 快照则通过真实客户端组合锁定记录表、Overview 计时详情、composer 浮层几何形状与检查器。 diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml index 773df07254..5b259cf9d5 100644 --- a/docs/core-data-structures/session.i18n.yaml +++ b/docs/core-data-structures/session.i18n.yaml @@ -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 docs/core-data-structures/session.md -session.md: 6369c956df03c0d786db696c208b000300d5bfbc -session.zh.md: c39382b7e6c9b14f91c311cc80526a6fd8898e4c +session.md: b2d4e093a019df5c43f705808b964b9cd7606834 +session.zh.md: ce37052244c69ea6c10a479ca9e92ed012e93a72 diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 6369c956df..b2d4e093a0 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -311,6 +311,8 @@ The same provenance distinction applies here: only `assistant/message` may carry `Session.surface` returns the session's stable `SessionSurface` view. The same incremental manager validates append candidates before commit and advances this projection from committed events; callers can observe membership and replacement generation but cannot invoke validation. +`SurfaceManager(log, baseSeq?)` can instead fold a contiguous loaded window whose first event has the absolute sequence `baseSeq`. Every event remains contiguous in that absolute sequence space, and a replacement that crosses the window head fails because its declared range is absent. + ```ts type-equiv /** Readonly live projection of the message-producing session events. */ interface SessionSurface { diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md index c39382b7e6..ce37052244 100644 --- a/docs/core-data-structures/session.zh.md +++ b/docs/core-data-structures/session.zh.md @@ -313,6 +313,8 @@ interface SurfaceIntent { `Session.surface` 返回会话稳定的 `SessionSurface` 视图。同一个增量管理器在提交前校验追加候选事件,并根据已提交事件推进该投影;调用方可以观察成员关系和替换代次,但不能调用校验。 +`SurfaceManager(log, baseSeq?)` 也可以折叠一个连续的已加载窗口,其第一个事件的绝对序号为 `baseSeq`。每个事件在该绝对序号空间中仍保持连续;如果替换跨过窗口头部,由于其声明的范围并不存在,该替换会失败。 + ```ts type-equiv /** Readonly live projection of the message-producing session events. */ interface SessionSurface { diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index dce2eb641b..cbdb7e5006 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -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/runtime/README.md -README.md: 3e9bcdd587764ee21326c27e0cd642914cf84f8f -README.zh.md: 8e13bc3d09ae220f7a64640b21892c03a37bae29 +README.md: f5dd94bc1c937b69d74f450f11bf875b50af4c62 +README.zh.md: 270d2d41e266fe198f91a939eba946e2f86844d9 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 3e9bcdd587..f5dd94bc1c 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers, loading the current tail first and prepending one older page only when its consumer requests it; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions. +Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers, loading the current tail first and prepending one older page only when its consumer requests it. Each history snapshot exposes the raw window's absolute base sequence so a consumer detects a prepend even when the page adds no surface-visible node. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions. ## Workspace and Session lists diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 8e13bc3d09..270d2d41e2 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本,先加载当前尾部,并仅在消费方请求时向前补入一页更早历史;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。 +客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本,先加载当前尾部,并仅在消费方请求时向前补入一页更早历史。每份历史快照都会公开原始窗口的绝对基准序号,因此即使该页没有新增任何 surface 可见节点,消费方仍能检测到向前补页。WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。 ## Workspace 与 Session 列表 diff --git a/packages/client/runtime/src/client/contract/session-history.ts b/packages/client/runtime/src/client/contract/session-history.ts index ab8e38847d..a48e89585e 100644 --- a/packages/client/runtime/src/client/contract/session-history.ts +++ b/packages/client/runtime/src/client/contract/session-history.ts @@ -9,6 +9,8 @@ export interface SessionHistorySnapshot { state: 'cold' | 'loading' | 'ready' | 'error' error: RpcError | null hasMore: boolean + /** Absolute sequence of the first loaded raw event, or zero for an empty window. */ + baseSeq: number inspection: SessionHistoryInspection } diff --git a/packages/client/runtime/src/client/session-history/source.ts b/packages/client/runtime/src/client/session-history/source.ts index 321cab2ac7..124353dfb7 100644 --- a/packages/client/runtime/src/client/session-history/source.ts +++ b/packages/client/runtime/src/client/session-history/source.ts @@ -97,11 +97,6 @@ export class SessionHistorySource implements SessionHistoryFace { return this.baseSeq !== previousBaseSeq } - /** Rebuild the tail for whichever mounted consumers survive a reconnect. */ - private async loadForConsumers(): Promise { - await this.open() - } - /** * Route a relevant mux frame without involving the Chat session. * @param frame - Session-addressed frame. @@ -145,7 +140,7 @@ export class SessionHistorySource implements SessionHistoryFace { this.state = 'cold' this.error = null this.publishDirtyNow() - void this.loadForConsumers() + void this.open() } /** Stop future refresh work after the host removes the session. */ @@ -408,6 +403,7 @@ export class SessionHistorySource implements SessionHistoryFace { state: this.state, error: this.error, hasMore: this.hasMore, + baseSeq: this.baseSeq, inspection: this.currentInspection(), } } diff --git a/packages/client/runtime/tests/session-history-source.spec.ts b/packages/client/runtime/tests/session-history-source.spec.ts index 5458d870c8..2bc0aa87af 100644 --- a/packages/client/runtime/tests/session-history-source.spec.ts +++ b/packages/client/runtime/tests/session-history-source.spec.ts @@ -34,6 +34,7 @@ describe('SessionHistorySource', () => { expect(api.callsOf('session.history')).toHaveLength(1) expect(source.getSnapshot().hasMore).toBe(true) + expect(source.getSnapshot().baseSeq).toBe(12) expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq)) .toEqual([13, 15]) @@ -43,6 +44,7 @@ describe('SessionHistorySource', () => { expect(api.callsOf('session.history')).toHaveLength(3) expect(source.getSnapshot().hasMore).toBe(false) + expect(source.getSnapshot().baseSeq).toBe(0) expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq)) .toEqual([1, 3, 7, 9, 13, 15]) }) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 8c6ff0bc2e..c024e1b048 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -19,7 +19,7 @@ import type { import type { AssistantMetricDetail, TrajectoryCellKind, TrajectoryCellProps, TrajectorySourceBlock, } from './trajectory-record.ts' -import { formatElapsedSeconds } from './trajectory-record.ts' +import { formatElapsedSeconds, trajectoryRecordId } from './trajectory-record.ts' import { trajectoryPreviewText, type TrajectoryTurnModel } from './layout.ts' import css from './TrajectoryTable.module.css' @@ -27,6 +27,7 @@ const BOTTOM_FOLLOW_THRESHOLD_PX = 2 const OLDER_LOAD_THRESHOLD_PX = 48 const VIRTUALIZATION_THRESHOLD = 100 const VIRTUAL_ROW_HEIGHT_PX = 30 +const COLLAPSED_SUMMARY_HEIGHT_PX = 20 const VIRTUAL_FINAL_REQUEST_HEIGHT_PX = 9 const VIRTUAL_OVERSCAN_ROWS = 12 const VIRTUAL_INITIAL_VIEWPORT_HEIGHT_PX = 600 @@ -157,9 +158,8 @@ interface ToolCallTextParts { interface SelectedRequest { turn: number | null - section: number - number: number group: string + seq?: number } interface DetailsResizeDrag { @@ -333,7 +333,7 @@ export interface TrajectoryTableProps { recordFocus?: { readonly index: number } | null /** Whether the initial history tail is still loading. */ historyLoading?: boolean - /** First loaded history node, used to preserve scroll position after prepending a page. */ + /** First loaded raw event, used to preserve scroll position after prepending a page. */ historyStartSeq?: number | undefined /** Whether one older history page can be requested. */ hasOlderRecords?: boolean @@ -345,10 +345,10 @@ export interface TrajectoryTableProps { collapsedTurns: ReadonlySet /** Toggle one turn between folded and expanded. */ onToggleTurn: (turn: number) => void - /** Assistant record indexes whose tool calls are folded. */ - collapsedAssistants: ReadonlySet + /** Stable Assistant record ids whose tool calls are folded. */ + collapsedAssistants: ReadonlySet /** Toggle tool calls under one assistant record. */ - onToggleAssistant: (index: number) => void + onToggleAssistant: (id: string) => void /** One-shot cross-view inspect: open and scroll to this call's record. */ inspectCallId?: string | null /** Acknowledge a consumed (or unresolvable) inspect request. */ @@ -427,6 +427,7 @@ function flattenRecords(turns: readonly TrajectoryTurnModel[]): TableRecord[] { } function virtualRecordHeight(record: TableRecord, final: boolean): number { + if (record.collapsedSummary !== undefined) return COLLAPSED_SUMMARY_HEIGHT_PX if (record.cell.requestOnly !== true) return VIRTUAL_ROW_HEIGHT_PX return final ? VIRTUAL_FINAL_REQUEST_HEIGHT_PX : 0 } @@ -581,14 +582,17 @@ function summarizeAssistantTools(records: readonly TableRecord[]): string { function collapseAssistantRecords( records: readonly TableRecord[], - collapsedAssistants: ReadonlySet, + collapsedAssistants: ReadonlySet, ): TableRecord[] { const out: TableRecord[] = [] for (let i = 0; i < records.length; i++) { const record = records[i] if (record === undefined) continue out.push(record) - if (record.cell.kind !== 'message' || !collapsedAssistants.has(record.cell.index)) continue + if ( + record.cell.kind !== 'message' + || !collapsedAssistants.has(trajectoryRecordId(record.cell)) + ) continue const calls: TableRecord[] = [] for (let j = i + 1; j < records.length; j++) { const candidate = records[j] @@ -1581,7 +1585,7 @@ export function TrajectoryTable({ inspectCallId = null, onInspectApplied, }: TrajectoryTableProps) { - const [selectedIndex, setSelectedIndex] = useState(null) + const [selectedRecordId, setSelectedRecordId] = useState(null) const [selectedRequest, setSelectedRequest] = useState(null) const [activeTab, setActiveTab] = useState('overview') const [thinkingExpanded, setThinkingExpanded] = useState(false) @@ -1596,14 +1600,18 @@ export function TrajectoryTable({ const followsTableTail = useRef(false) const tableScrollInitialized = useRef(false) const [tableScrollReady, setTableScrollReady] = useState(false) - const pendingScrollIndex = useRef(null) + const pendingScrollRecordId = useRef(null) const loadingOlder = useRef(false) const [olderLoading, setOlderLoading] = useState(false) const olderLoadAnchor = useRef(null) + const allRecords = useMemo(() => flattenRecords(turns), [turns]) + const selected = selectedRecordId === null + ? undefined + : allRecords.find(record => trajectoryRecordId(record.cell) === selectedRecordId) + const selectedIndex = selected?.cell.index ?? null useEffect(() => { onSelectedIndexChange?.(selectedIndex) }, [onSelectedIndexChange, selectedIndex]) - const allRecords = useMemo(() => flattenRecords(turns), [turns]) const requestNumbers = useMemo( () => indexRequestNumbers(allRecords, sessionRequestNumbers), [allRecords, sessionRequestNumbers], @@ -1631,7 +1639,7 @@ export function TrajectoryTable({ const record = records[index] return record === undefined ? index - : `${record.cell.index}:${record.collapsedSummaryKind ?? 'record'}` + : `${trajectoryRecordId(record.cell)}:${record.collapsedSummaryKind ?? 'record'}` }, getScrollElement: () => tablePaneRef.current, initialRect: { width: 0, height: VIRTUAL_INITIAL_VIEWPORT_HEIGHT_PX }, @@ -1649,7 +1657,6 @@ export function TrajectoryTable({ }) : records.map((record, position) => ({ record, position })) const requestBoundaryRuns = indexRequestBoundaryRuns(records) - const selected = allRecords.find(record => record.cell.index === selectedIndex) const selectedPrompt = selected?.cell.kind === 'system' ? selected.cell.promptDetail : undefined @@ -1662,16 +1669,20 @@ export function TrajectoryTable({ ? [] : allRecords.filter(record => record.turn === selectedRequest.turn - && record.section === selectedRequest.section && record.group === selectedRequest.group, ) const selectedRequestAssistant = selectedRequestRecords.find( record => record.cell.kind === 'message', ) const selectedRequestAnchor = selectedRequestAssistant ?? selectedRequestRecords[0] + const selectedRequestNumber = selectedRequest === null + ? undefined + : requestNumbers.get(requestKey(selectedRequest.turn, selectedRequest.group)) const selectedRequestInfo = selectedRequest === null ? undefined - : sessionRequestNumbers?.find(request => request.number === selectedRequest.number) + : sessionRequestNumbers?.find(request => selectedRequest.seq === undefined + ? request.turn === selectedRequest.turn && request.group === selectedRequest.group + : request.seq === selectedRequest.seq) const selectedRequestState: RecordState | undefined = selectedRequest === null ? undefined : selectedRequestInfo?.status @@ -1715,7 +1726,9 @@ export function TrajectoryTable({ selectedRequestInfo?.cumulativeUsage ?? selectedRequestUsage const selectedRequestOptions = selectedRequestInfo?.requestConfig const activeTurn = selectedRequest === null ? selected?.turn : selectedRequest.turn - const activeSection = selectedRequest === null ? selected?.section : selectedRequest.section + const activeSection = selectedRequest === null + ? selected?.section + : selectedRequestRecords[0]?.section const selectedTabs = selectedRequest !== null ? REQUEST_TABS.filter(tab => tab.id !== 'options' || selectedRequestOptions !== undefined) : selected === undefined ? [] : detailTabs(selected) @@ -1727,13 +1740,17 @@ export function TrajectoryTable({ const selectedAssistantRequest = selected?.cell.kind === 'message' ? requestNumbers.get(requestKey(selected.turn, selected.group)) : undefined + const selectedAssistantRequestInfo = selectedAssistantRequest === undefined + ? undefined + : sessionRequestNumbers?.find(request => request.number === selectedAssistantRequest) const selectedAssistantRequestTarget: SelectedRequest | undefined = selected !== undefined && selectedAssistantRequest !== undefined ? { turn: selected.turn, - section: selected.section, - number: selectedAssistantRequest, group: selected.group, + ...(selectedAssistantRequestInfo?.seq === undefined + ? {} + : { seq: selectedAssistantRequestInfo.seq }), } : undefined const hasSelectedHierarchy = selectedAssistantRequestTarget !== undefined @@ -1752,7 +1769,7 @@ export function TrajectoryTable({ } const clearInspectorSelection = () => { - setSelectedIndex(null) + setSelectedRecordId(null) setSelectedRequest(null) } @@ -1765,7 +1782,7 @@ export function TrajectoryTable({ const record = allRecords.find(candidate => candidate.cell.index === index) onRecordSelect?.(index) setSelectedRequest(null) - setSelectedIndex(index) + setSelectedRecordId(record === undefined ? null : trajectoryRecordId(record.cell)) if (record === undefined) return const tabs = detailTabs(record) const available = new Set(tabs.map(tab => tab.id)) @@ -1779,19 +1796,25 @@ export function TrajectoryTable({ ) return appliedRecordSelection.current = recordSelection selectRecord(recordSelection.index) - pendingScrollIndex.current = recordSelection.index - }, [recordSelection, selectRecord]) + const record = allRecords.find(candidate => candidate.cell.index === recordSelection.index) + pendingScrollRecordId.current = record === undefined + ? null + : trajectoryRecordId(record.cell) + }, [allRecords, recordSelection, selectRecord]) useEffect(() => { if (recordFocus === null || appliedRecordFocus.current === recordFocus) return appliedRecordFocus.current = recordFocus - pendingScrollIndex.current = recordFocus.index - }, [recordFocus]) + const record = allRecords.find(candidate => candidate.cell.index === recordFocus.index) + pendingScrollRecordId.current = record === undefined + ? null + : trajectoryRecordId(record.cell) + }, [allRecords, recordFocus]) const selectRequest = ( request: SelectedRequest, tab: 'overview' | 'timing' = 'overview', ) => { - setSelectedIndex(null) + setSelectedRecordId(null) setSelectedRequest(request) activateTab(tab) } @@ -1804,12 +1827,13 @@ export function TrajectoryTable({ const candidate = allRecords[i] if (candidate === undefined || candidate.turn !== target.turn) break if (candidate.cell.kind !== 'message') continue - if (collapsedAssistants.has(candidate.cell.index)) onToggleAssistant(candidate.cell.index) + const assistantId = trajectoryRecordId(candidate.cell) + if (collapsedAssistants.has(assistantId)) onToggleAssistant(assistantId) break } } setSelectedRequest(null) - setSelectedIndex(target.cell.index) + setSelectedRecordId(trajectoryRecordId(target.cell)) activateTab('overview') } @@ -1829,22 +1853,24 @@ export function TrajectoryTable({ const target = flattenRecords(turns).find(record => record.cell.callId === inspectCallId) if (target === undefined) return openRecordSummaryRef.current(target) - pendingScrollIndex.current = target.cell.index + pendingScrollRecordId.current = trajectoryRecordId(target.cell) onInspectApplied?.() }, [inspectCallId, turns, onInspectApplied]) useEffect(() => { - const index = pendingScrollIndex.current - if (index === null) return + const id = pendingScrollRecordId.current + if (id === null) return const position = records.findIndex(record => - record.cell.index === index && record.collapsedSummary === undefined) + trajectoryRecordId(record.cell) === id && record.collapsedSummary === undefined) if (position === -1) return - pendingScrollIndex.current = null + pendingScrollRecordId.current = null if (virtualizationEnabled) { rowVirtualizer.scrollToIndex(position, { behavior: 'smooth', align: 'center' }) return } - const row = rootRef.current - ?.querySelector(`tr[data-record-index="${index}"]`) + const recordIndex = records[position]?.cell.index + const row = recordIndex === undefined + ? null + : rootRef.current?.querySelector(`tr[data-record-index="${recordIndex}"]`) /* v8 ignore next -- jsdom lacks scrollIntoView; browsers always have it. */ if (row !== undefined && row !== null && typeof row.scrollIntoView === 'function') { row.scrollIntoView({ behavior: 'smooth', block: 'center' }) @@ -2027,14 +2053,13 @@ export function TrajectoryTable({ : `Request #${request}${requestInfo?.purpose === 'compaction' ? ' · Compaction' : ''}` const requestSelected = request !== undefined && selectedRequest?.turn === record.turn - && selectedRequest.section === record.section - && selectedRequest.number === request + && selectedRequest.group === record.group const sectionActive = record.turn === null ? activeSection === record.section : activeTurn === record.turn return ( { if (record.collapsedSummaryKind === 'turn' && record.turn !== null) { onToggleTurn(record.turn) - } else onToggleAssistant(record.cell.index) + } else onToggleAssistant(trajectoryRecordId(record.cell)) } : () => { selectRecord(record.cell.index) }} onDoubleClick={(event) => { @@ -2079,7 +2104,7 @@ export function TrajectoryTable({ && assistantToolCalls(allRecords, record.cell.index).length > 0 ) { event.preventDefault() - onToggleAssistant(record.cell.index) + onToggleAssistant(trajectoryRecordId(record.cell)) return } if (!record.turnStart) return @@ -2098,7 +2123,7 @@ export function TrajectoryTable({ if (isCollapsedSummary) { if (record.collapsedSummaryKind === 'turn' && record.turn !== null) { onToggleTurn(record.turn) - } else onToggleAssistant(record.cell.index) + } else onToggleAssistant(trajectoryRecordId(record.cell)) return } selectRecord(record.cell.index) @@ -2121,9 +2146,8 @@ export function TrajectoryTable({ event.stopPropagation() selectRequest({ turn: record.turn, - section: record.section, - number: request, group: record.group, + ...(requestInfo?.seq === undefined ? {} : { seq: requestInfo.seq }), }) }} onDoubleClick={(event) => { event.stopPropagation() }} @@ -2355,7 +2379,7 @@ export function TrajectoryTable({ <>