diff --git a/.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.i18n.yaml new file mode 100644 index 0000000000..76604171be --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.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/simplification/2026-07-30-sidebar-resize-without-visible-pill.md +2026-07-30-sidebar-resize-without-visible-pill.md: cc41898990fa23ff2937140186a8324217911d2e +2026-07-30-sidebar-resize-without-visible-pill.zh.md: 9f1f521df2848b15f5015719bfa6f0e0e9b7be0c diff --git a/.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.md b/.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.md new file mode 100644 index 0000000000..cc41898990 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.md @@ -0,0 +1,25 @@ +# Agent Note: Sidebar resize without a visible pill + +Status: implemented + +English | [中文](2026-07-30-sidebar-resize-without-visible-pill.zh.md) + +## Problem + +The AppFrame exposed identical floating pills on both column borders. The left pill added unnecessary visual weight beside primary navigation, but the sidebar's resize interaction remains useful. + +## Decision + +AppFrame keeps the sidebar's 8px resize hit strip, `col-resize` cursor, pointer capture, animation-frame throttling, and width updates, but does not generate the sidebar handle's pill pseudo-element. The details boundary retains both its hit strip and floating pill. + +The layout component test continues to pin sidebar dragging and both handles' collapse lifecycle. A keyless browser scenario reads the generated pseudo-elements from the shipped composition and drags the invisible sidebar boundary to prove the interaction remains live. + +## Alternatives considered + +**Remove the sidebar drag interaction with the pill.** Rejected because the requested change is visual; removing a working geometry control would unnecessarily narrow the interaction. + +**Keep the pill but reduce its emphasis.** A smaller or lower-contrast pill still leaves an unwanted object on the sidebar boundary. + +## Consequences + +The sidebar boundary is visually quiet while pointer resizing remains available from the boundary and retains the resize cursor. Unlike the details control, that interaction has no visible pill. diff --git a/.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.zh.md b/.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.zh.md new file mode 100644 index 0000000000..9f1f521df2 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.zh.md @@ -0,0 +1,25 @@ +# Agent Note: 侧边栏缩放不显示胶囊 + +Status: implemented + +[English](2026-07-30-sidebar-resize-without-visible-pill.md) | 中文 + +## 问题 + +AppFrame 在两个栏位边界都显示相同的浮动胶囊。左侧胶囊在主导航旁增加了不必要的视觉负担,但侧边栏的缩放交互仍有用。 + +## 决策 + +AppFrame 保留侧边栏宽 8px 的缩放命中条带、`col-resize` 光标、指针捕获、动画帧节流和宽度更新,但不再生成侧边栏手柄的胶囊形伪元素。详情栏边界同时保留命中条带和浮动胶囊。 + +布局组件测试继续固定侧边栏拖动行为,以及两个手柄随面板折叠时的生命周期。一个无密钥浏览器场景读取实际交付组合所生成的伪元素,并拖动不可见的侧边栏边界,证明该交互仍然有效。 + +## 曾考虑的替代方案 + +**随胶囊一并移除侧边栏拖动交互。** 不予采纳,因为本次要求只改视觉表现;移除正常工作的几何控制会不必要地缩减交互方式。 + +**保留胶囊,但降低其视觉强调。** 更小或对比度更低的胶囊仍会在侧边栏边界留下一个不需要的物体。 + +## 后果 + +侧边栏边界在视觉上保持简洁,同时仍可在边界处通过指针调整宽度,并保留缩放光标。与详情栏控件不同,该交互没有可见胶囊。 diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts index 23a14ef445..8105519338 100644 --- a/apps/web/tests/details-session-lifecycle.e2e.ts +++ b/apps/web/tests/details-session-lifecycle.e2e.ts @@ -3,15 +3,19 @@ // unselected states, and closes it only when a different Session takes ownership. import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' +import { join } from 'node:path' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { - acknowledgeReloadConnectionLoss, fixtureUserPrompts, launchWebScaffold, seedSession, watchConsole, - webSnapshotMode, type WebScaffold, + acknowledgeReloadConnectionLoss, assertFixtureInventory, compareOrRefreshGolden, + fixtureUserPrompts, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, + type WebScaffold, } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/details-session-lifecycle', import.meta.url)) +const HANDLES_EXPECTED = join(SNAPSHOT_DIR, 'handles.expected.md') const FIXTURE = fileURLToPath(new URL('./snapshots/lifecycle-chrome/session.jsonl', import.meta.url)) const SEED_FIXTURE = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url)) const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.' @@ -25,11 +29,41 @@ async function detailsTrack(page: Page): Promise { }) } +/** First AppFrame grid track in CSS pixels. */ +async function sidebarTrack(page: Page): Promise { + return await appFrame(page).evaluate((element) => { + const tracks = getComputedStyle(element).gridTemplateColumns.split(' ') + return Number.parseFloat(tracks[0] ?? 'NaN') + }) +} + /** AppFrame is the only product element with an inline grid track template. */ function appFrame(page: Page) { return page.locator('[style*="grid-template-columns"]').first() } +/** Render the two boundary affordances without platform-dependent coordinates. */ +async function handleSnapshot(page: Page): Promise { + const handles = await page.locator('[class*="handle"]').evaluateAll(elements => + elements.map(element => ({ + side: element.getAttribute('data-side'), + cursor: getComputedStyle(element).cursor, + pillGenerated: getComputedStyle(element, '::after').content !== 'none', + }))) + return [ + '# AppFrame drag handles', + '', + ...handles.flatMap(handle => [ + `## ${handle.side}`, + '', + '- hit strip present: true', + `- cursor: ${handle.cursor}`, + `- pill generated: ${String(handle.pillGenerated)}`, + '', + ]), + ].join('\n').trimEnd() +} + describe.skipIf(MODE === 'record')('web e2e: details panel follows the current Session lifecycle', () => { let scaffold: WebScaffold let browser: Browser @@ -65,6 +99,18 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0) expect(await page.getByText('详情', { exact: true }).isVisible()).toBe(false) + await compareOrRefreshGolden(HANDLES_EXPECTED, await handleSnapshot(page), MODE) + + const sidebarBefore = await sidebarTrack(page) + const sidebarHandle = page.locator('[data-side="sidebar"]') + const sidebarBox = await sidebarHandle.boundingBox() + expect(sidebarBox).not.toBeNull() + const dragStartX = sidebarBox!.x + sidebarBox!.width / 2 + await page.mouse.move(dragStartX, sidebarBox!.y + 200) + await page.mouse.down() + await page.mouse.move(dragStartX + 70, sidebarBox!.y + 200, { steps: 6 }) + await page.mouse.up() + await expect.poll(() => sidebarTrack(page), { timeout: 5_000 }).toBe(sidebarBefore + 70) const warningStart = tripwire.warnings.length await page.reload({ waitUntil: 'load' }) @@ -101,5 +147,6 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['handles.expected.md']) }, 90_000) }) diff --git a/apps/web/tests/snapshots/details-session-lifecycle/handles.expected.md b/apps/web/tests/snapshots/details-session-lifecycle/handles.expected.md new file mode 100644 index 0000000000..aa865c4ad4 --- /dev/null +++ b/apps/web/tests/snapshots/details-session-lifecycle/handles.expected.md @@ -0,0 +1,7 @@ +# AppFrame drag handles + +## sidebar + +- hit strip present: true +- cursor: col-resize +- pill generated: false diff --git a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md index 0bdf87d67a..788b8a3233 100644 --- a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md +++ b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md @@ -11,7 +11,7 @@ - cell "SYSTEM" - cell "Initial System Prompt" - 'row "USER, NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop."': - - cell "Turn 1 USER" + - cell "Turn 1 USER": USER - 'cell "NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop."' - 'row "Request 1, ASSISTANT, The user wants me to follow a specific navigation scenario. Let me: Run bash to print \"NAVIGATION_OK\" Read nav-a.md and nav-b.md in two read calls in ONE message Reply with \"FIRST_DONE\" Let me start with the bash command and the reads."': - 'cell "Request #1 ASSISTANT"': @@ -33,7 +33,7 @@ - text: ASSISTANT - cell "FIRST_DONE" - 'row "USER, Reply in markdown with: a level-2 heading \"Navigation Summary\", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop."': - - cell "Turn 2 USER" + - cell "Turn 2 USER": USER - 'cell "Reply in markdown with: a level-2 heading \"Navigation Summary\", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop."' - row "Request 3, ASSISTANT, Navigation Summary alpha nav beta nav echo WATERFALL": - 'cell "Request #3 ASSISTANT"': diff --git a/packages/client/runtime/src/client/session-history/source.ts b/packages/client/runtime/src/client/session-history/source.ts index 8de11b9da9..4f3e86be6a 100644 --- a/packages/client/runtime/src/client/session-history/source.ts +++ b/packages/client/runtime/src/client/session-history/source.ts @@ -1,12 +1,14 @@ import type { HistoryEntry, IApiClient, MuxFrame, RpcError, SessionId, } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' import type { SessionHistoryFace, SessionHistorySnapshot, } from '../contract/session-history.ts' import { createHistoryInspection } from '../sessions/history.ts' import { Notifier } from '../sessions/notifier.ts' +import { PartialAccumulator } from '../sessions/partial.ts' const HISTORY_PAGE_MESSAGES = 50 @@ -33,6 +35,9 @@ export class SessionHistorySource implements SessionHistoryFace { entries: readonly HistoryEntry[] value: SessionHistorySnapshot['inspection'] } | null = null + private streamPublishToken: object | null = null + private streamBaseInspection: SessionHistorySnapshot['inspection'] | null = null + private streamPartial: PartialAccumulator | null = null private snapshotCache: SessionHistorySnapshot private readonly notifier = new Notifier(() => { this.snapshotCache = this.buildSnapshot() @@ -125,7 +130,7 @@ export class SessionHistorySource implements SessionHistoryFace { if (this.state !== 'cold') { this.state = 'cold' this.error = null - this.notifier.markDirty() + this.publishDirtyNow() } } @@ -143,7 +148,7 @@ export class SessionHistorySource implements SessionHistoryFace { this.hasMore = false this.state = 'cold' this.error = null - this.notifier.markDirty() + this.publishDirtyNow() void this.loadForConsumers() } @@ -155,6 +160,9 @@ export class SessionHistorySource implements SessionHistoryFace { this.openPromise = null this.olderPromise = null this.liveBuffer = [] + this.streamPublishToken = null + this.streamBaseInspection = null + this.streamPartial = null } private open(): Promise { @@ -188,7 +196,7 @@ export class SessionHistorySource implements SessionHistoryFace { private async doOpen(generation: number): Promise { this.state = 'loading' this.error = null - this.notifier.markDirty() + this.publishDirtyNow() try { let { result } = await this.api.sessions.history({ sessionId: this.sessionId, @@ -222,7 +230,7 @@ export class SessionHistorySource implements SessionHistoryFace { /* v8 ignore next -- transportError always returns the error branch. */ this.error = folded.ok ? null : folded.error } finally { - if (generation === this.generation) this.notifier.markDirty() + if (generation === this.generation) this.publishDirtyNow() } } @@ -261,7 +269,7 @@ export class SessionHistorySource implements SessionHistoryFace { const settled = operation.finally(() => { if (this.olderPromise !== settled) return this.olderPromise = null - this.notifier.markDirty() + this.publishDirtyNow() }) this.olderPromise = settled return settled @@ -286,7 +294,7 @@ export class SessionHistorySource implements SessionHistoryFace { const buffered = this.liveBuffer this.liveBuffer = [] for (const entry of buffered) this.appendLive(entry) - this.notifier.markDirty() + this.publishDirtyNow() } private acceptLive(entry: HistoryEntry): void { @@ -301,8 +309,16 @@ export class SessionHistorySource implements SessionHistoryFace { void this.repairGap() return } + if ( + entry.event.type === 'assistant/chunk' + && entry.event.data.chunk.type !== 'usage' + ) { + if (!this.appendIncrementalChunk(entry, entry.event)) return + this.publishStreamDirty() + return + } this.appendLive(entry) - this.notifier.markDirty() + this.publishDirtyNow() } private appendLive(entry: HistoryEntry): void { @@ -311,6 +327,66 @@ export class SessionHistorySource implements SessionHistoryFace { this.entries = [...this.entries, entry] } + /** Append a chunk against the cached finalized projection; false means no visible publish. */ + private appendIncrementalChunk( + entry: HistoryEntry, + event: SessionEvent<'assistant/chunk'>, + ): boolean { + const { turn, step, chunk } = event.data + if (!isVisibleAssistantChunk(chunk.type)) { + const inspection = this.currentInspection() + this.appendLive(entry) + this.inspectionCache = { entries: this.entries, value: inspection } + return false + } + const base = this.streamBaseInspection ?? this.currentInspection() + this.streamBaseInspection = base + if ( + this.streamPartial === null + || this.streamPartial.turn !== turn + || this.streamPartial.step !== step + ) { + const current = base.partial + this.streamPartial = new PartialAccumulator( + turn, + step, + current?.turn === turn && current.step === step ? current.blocks : [], + ) + } + this.streamPartial.push(chunk) + this.appendLive(entry) + this.inspectionCache = { + entries: this.entries, + value: { ...base, partial: this.streamPartial.toPartial() }, + } + return true + } + + /** Coalesce token-stream projection and rendering work to one publish per browser frame. */ + private publishStreamDirty(): void { + if (this.streamPublishToken !== null) return + const token = {} + this.streamPublishToken = token + const publish = () => { + if (this.streamPublishToken !== token) return + this.streamPublishToken = null + this.notifier.markDirty() + } + if (typeof globalThis.requestAnimationFrame === 'function') { + globalThis.requestAnimationFrame(publish) + } else { + queueMicrotask(publish) + } + } + + /** Publish structural changes immediately and invalidate an older scheduled stream publish. */ + private publishDirtyNow(): void { + this.streamPublishToken = null + this.streamBaseInspection = null + this.streamPartial = null + this.notifier.markDirty() + } + private async repairGap(): Promise { if (this.stitching) return this.stitching = true @@ -335,6 +411,16 @@ export class SessionHistorySource implements SessionHistoryFace { } private buildSnapshot(): SessionHistorySnapshot { + return { + state: this.state, + error: this.error, + hasMore: this.hasMore, + inspection: this.currentInspection(), + } + } + + /** Inspection pinned to the source's current immutable entry array. */ + private currentInspection(): SessionHistorySnapshot['inspection'] { if (this.inspectionCache?.entries !== this.entries) { const entries = this.entries this.inspectionCache = { @@ -342,11 +428,14 @@ export class SessionHistorySource implements SessionHistoryFace { value: createHistoryInspection(() => entries), } } - return { - state: this.state, - error: this.error, - hasMore: this.hasMore, - inspection: this.inspectionCache.value, - } + return this.inspectionCache.value } } + +function isVisibleAssistantChunk(type: string): boolean { + return type === 'block-start' + || type === 'text-delta' + || type === 'reasoning-delta' + || type === 'tool-call-delta' + || type === 'block-end' +} diff --git a/packages/client/runtime/src/client/sessions/partial.ts b/packages/client/runtime/src/client/sessions/partial.ts index 189febf253..242232f3c1 100644 --- a/packages/client/runtime/src/client/sessions/partial.ts +++ b/packages/client/runtime/src/client/sessions/partial.ts @@ -13,8 +13,18 @@ export class PartialAccumulator { private changed = true private snapshot: PartialAssistant - constructor(readonly turn: number, readonly step: number) { - this.snapshot = { turn, step, blocks: [] } + /** + * @param turn - Owning agent turn. + * @param step - Owning model step. + * @param initialBlocks - Materialized prefix when accumulation begins after history replay. + */ + constructor( + readonly turn: number, + readonly step: number, + initialBlocks: readonly AssistantBlock[] = [], + ) { + this.blocks = [...initialBlocks] + this.snapshot = { turn, step, blocks: initialBlocks } } /** diff --git a/packages/client/runtime/tests/partial.spec.ts b/packages/client/runtime/tests/partial.spec.ts index 25681ea4cc..c1df190c33 100644 --- a/packages/client/runtime/tests/partial.spec.ts +++ b/packages/client/runtime/tests/partial.spec.ts @@ -41,6 +41,12 @@ describe('PartialAccumulator', () => { expect(acc.toPartial().blocks).toEqual([{ kind: 'reasoning', text: '思考' }]) }) + it('continues from a materialized history prefix', () => { + const acc = new PartialAccumulator(1, 0, [{ kind: 'text', text: '已有' }]) + acc.push(chunk({ type: 'text-delta', index: 0, text: '增量' })) + expect(acc.toPartial().blocks).toEqual([{ kind: 'text', text: '已有增量' }]) + }) + it('folds tool-call deltas: first id pins callId, late name overrides, argsRaw concatenates', () => { const acc = new PartialAccumulator(1, 0) acc.push(chunk({ type: 'tool-call-delta', index: 0, id: 'c1', argumentsDelta: '{"a"' })) diff --git a/packages/client/runtime/tests/session-history-source.spec.ts b/packages/client/runtime/tests/session-history-source.spec.ts index 1375338e90..bcf25cd933 100644 --- a/packages/client/runtime/tests/session-history-source.spec.ts +++ b/packages/client/runtime/tests/session-history-source.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import { SessionHistorySource } from '../src/client/session-history/source.ts' @@ -7,6 +7,10 @@ import { entries, ev, plainTurn } from './event-script.ts' const SID = 'history-s1' as SessionId +afterEach(() => { + vi.unstubAllGlobals() +}) + function histResponse(events: SessionEvent[], hasMore = false) { return Promise.resolve(ok({ events: entries(events) as never[], hasMore })) } @@ -52,6 +56,71 @@ describe('SessionHistorySource', () => { .toEqual([1, 3, 6]) }) + it('publishes multiple assistant chunks once per browser frame', async () => { + const api = new FakeApiClient() + api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答')) + const source = new SessionHistorySource(SID, api) + await source.loadAll() + const frames: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frames.push(callback) + return frames.length + }) + let notifications = 0 + const unsubscribe = source.subscribe(() => { notifications++ }) + const before = source.getSnapshot().inspection + const finalizedNodes = before.eventNodes + const requests = before.requests + const contexts = before.contexts + + for (const event of [ + ev.chunkStart(6, 1), + ev.chunkText(7, 1, 'stream '), + ev.chunkText(8, 1, 'content'), + ]) { + source.handleMuxFrame({ + type: 'session/event', + sessionId: SID, + event, + }) + } + + expect(frames).toHaveLength(1) + expect(notifications).toBe(0) + frames[0]?.(0) + await Promise.resolve() + + expect(notifications).toBe(1) + const streamed = source.getSnapshot().inspection + expect(streamed.eventNodes).toBe(finalizedNodes) + expect(streamed.requests).toBe(requests) + expect(streamed.contexts).toBe(contexts) + expect(streamed.partial?.blocks).toEqual([ + { kind: 'text', text: 'stream content' }, + ]) + + source.handleMuxFrame({ + type: 'session/event', + sessionId: SID, + event: ev.chunkText(9, 1, ' then final'), + }) + source.handleMuxFrame({ + type: 'session/event', + sessionId: SID, + event: ev.assistant(10, 1, 'stream content then final'), + }) + await Promise.resolve() + + expect(notifications).toBe(2) + const finalized = source.getSnapshot().inspection + expect(finalized.eventNodes).not.toBe(finalizedNodes) + expect(finalized.partial).toBeNull() + frames[1]?.(0) + await Promise.resolve() + expect(notifications).toBe(2) + unsubscribe() + }) + it('stops loading when an older page fails to advance', async () => { const api = new FakeApiClient() api.onHistory = payload => payload.beforeSeq === undefined diff --git a/packages/client/ui-layout/README.i18n.yaml b/packages/client/ui-layout/README.i18n.yaml index 870937c93c..aba4711a24 100644 --- a/packages/client/ui-layout/README.i18n.yaml +++ b/packages/client/ui-layout/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-layout/README.md -README.md: 0e92958c9b088071ab58f7e87e8af68f6c2df68d -README.zh.md: 0fb3b1cd85bbf6e070a2a1dd01b25981e5990df0 +README.md: cb99023e6a9e3364c6f48190cf4a0cd71da2cbba +README.zh.md: 3681b4517670eb92d8f32be2ac62d5852ac745a3 diff --git a/packages/client/ui-layout/README.md b/packages/client/ui-layout/README.md index 0e92958c9b..cb99023e6a 100644 --- a/packages/client/ui-layout/README.md +++ b/packages/client/ui-layout/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar is fixed-width (only details shrinks, then auto-closes); a closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto the document (`html { color-scheme }` for native UA chrome, `body[data-ds-dark-theme]` from the active color scheme, plus the theme's alias tokens as inline variables on body). +Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar resize boundary is an invisible hit strip, while the details boundary retains its floating pill; only details shrinks during concession and then auto-closes. A closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto the document (`html { color-scheme }` for native UA chrome, `body[data-ds-dark-theme]` from the active color scheme, plus the theme's alias tokens as inline variables on body). AppFrame always mounts the conversation and details columns; a connected Session renders through `SessionProvider`. The transient layout store starts the sidebar at its default width and details closed, and it never reads or writes `localStorage`. Hero and other unselected states also derive a zero rendered details width without changing that stored preference. AppFrame retains the last non-blank Session id across those states: the first Session remains closed, an explicit details action opens the contract default width, returning to the same Session restores its unchanged width, and selecting a different Session closes details before paint. The conversation owner share is empty, while the sidebar owner share contains only `collapsed` and `width`; registrants obtain business data from standard hooks and actions from their own inject faces. diff --git a/packages/client/ui-layout/README.zh.md b/packages/client/ui-layout/README.zh.md index 0fb3b1cd85..3681b45176 100644 --- a/packages/client/ui-layout/README.zh.md +++ b/packages/client/ui-layout/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -外壳插件:三栏 AppFrame(拖动手柄与让步链)加 `ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot,并声明 `sidebar`、`conversation`、`details` 和 `conversation.empty`。侧边栏宽度固定(只会收缩详情栏,然后将其自动关闭);关闭的侧边栏仍保留 56px 控制轨道,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 document(用 `html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为 body 上的内联变量)。 +外壳插件:三栏 AppFrame(拖动手柄与让步链)加 `ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot,并声明 `sidebar`、`conversation`、`details` 和 `conversation.empty`。侧边栏的缩放边界是不可见命中条带,详情栏边界则保留其浮动胶囊;让步期间只有详情栏会收缩并随后自动关闭。关闭的侧边栏仍保留 56px 控制轨道,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 document(用 `html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为 body 上的内联变量)。 AppFrame 始终挂载会话栏和详情栏;已连接 Session 通过 `SessionProvider` 渲染。布局 store 是瞬时状态,侧边栏以默认宽度启动,详情栏则保持关闭,且该 store 从不读写 `localStorage`。hero 和其他未选中状态也会将详情栏的渲染宽度派生为零,但不会改变存储的首选宽度。AppFrame 会跨越这些状态保留最后一个非 blank 会话 id:首个会话保持关闭;显式打开详情栏的操作会使用契约默认宽度;返回同一会话时恢复其未改变的宽度;选择不同会话时,详情栏会在绘制前关闭。会话 owner share 为空,侧边栏 owner share 只包含 `collapsed` 和 `width`;注册方通过标准钩子获取业务数据,并从各自的 inject 表层获取操作。 diff --git a/packages/client/ui-layout/src/client/AppFrame.module.css b/packages/client/ui-layout/src/client/AppFrame.module.css index b805bb178a..ef6c70661d 100644 --- a/packages/client/ui-layout/src/client/AppFrame.module.css +++ b/packages/client/ui-layout/src/client/AppFrame.module.css @@ -49,9 +49,8 @@ } /* Drag handles are frame children (columns clip overflow): an 8px hit strip - centered on the column border via inline left, above column content. The - visible pill (12x32 r10, riding the border at vertical center) is the figma - Handle component; the hit strip stays wider than the pill. */ + centered on the column border via inline left, above column content. Details + adds a visible 12x32 pill at vertical center; sidebar keeps only the hit strip. */ .handle { position: absolute; top: 0; @@ -76,7 +75,7 @@ } } -.handle::after { +.handle[data-side='details']::after { content: ''; position: absolute; top: 50%; @@ -88,23 +87,22 @@ box-sizing: border-box; background: var(--dsw-alias-button-floating-fill); border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); - /* Hover affordance: the pill hides until the pointer is over the owning - column (data-side pairs handle and column), the strip itself, or a drag. */ + /* Hover affordance: the details pill hides until the pointer is over its + column, the strip itself, or a drag. */ opacity: 0; transition: opacity var(--ds-transition-duration-slow) var(--ds-ease-in-out), background var(--ds-transition-duration-slow) var(--ds-ease-in-out); } -.sidebarCol:hover ~ .handle[data-side='sidebar']::after, .detailsCol:hover ~ .handle[data-side='details']::after, -.handle:hover::after, -.handle[data-dragging='true']::after { +.handle[data-side='details']:hover::after, +.handle[data-side='details'][data-dragging='true']::after { opacity: 1; } -.handle:hover::after, -.handle[data-dragging='true']::after { +.handle[data-side='details']:hover::after, +.handle[data-side='details'][data-dragging='true']::after { background: var(--dsw-alias-button-floating-hover); border-color: var(--dsw-alias-border-l3); } diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index 3e0e96b838..c7b7948845 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -16,6 +16,7 @@ flex: 1; min-width: 0; overflow: auto; + container: trajectory-table / inline-size; } .table { @@ -235,6 +236,18 @@ width: 3px; } +.table tbody tr[data-error='true'] .turnRail { + background: color-mix( + in srgb, + var(--dsw-alias-state-error-primary) 22%, + var(--dsw-alias-bg-layer-1) + ); +} + +.table tbody tr[data-error='true'] .selectionRail { + background: var(--dsw-alias-state-error-primary); +} + .table tbody tr[data-turn-start='true'] td { position: relative; overflow: visible; @@ -279,6 +292,10 @@ white-space: nowrap; } +.turnLabelCompact { + display: none; +} + .turnLabelActive { color: color-mix( in srgb, @@ -325,11 +342,66 @@ user-select: none; } +.kindTagIcon { + display: none; + align-items: center; + justify-content: center; + width: 13px; + height: 13px; +} + +.kindTagLabel { + display: inline; +} + .table .kindSlot .message { justify-content: center; width: 100%; } +@container trajectory-table (max-width: 620px) { + .eventColumn { + width: 50px; + } + + .event { + padding-right: 3px !important; + padding-left: 28px !important; + } + + .requestBoundaryControl { + left: 6px; + } + + .kindSlot { + width: 19px; + } + + .kindTag, + .table .kindSlot .message { + justify-content: center; + width: 19px; + padding-right: 0; + padding-left: 0; + } + + .kindTagIcon { + display: inline-flex; + } + + .kindTagLabel { + display: none; + } + + .turnLabelFull { + display: none; + } + + .turnLabelCompact { + display: inline; + } +} + .user { color: var(--dsw-alias-state-business-primary); background: var(--dsw-alias-state-business-tertiary); @@ -576,6 +648,28 @@ color: var(--dsw-alias-state-error-primary); } +.overview dd.error { + color: var(--dsw-alias-state-error-primary); +} + +.details .errorPayload { + color: var(--dsw-alias-state-error-primary); +} + +.details .errorPayload .resultBlockText { + color: inherit; +} + +.details .jsonPayload.errorPayload, +.details .jsonPreview.errorPayload { + --json-tree-property: var(--dsw-alias-state-error-primary); + --json-tree-string: var(--dsw-alias-state-error-primary); + --json-tree-number: var(--dsw-alias-state-error-primary); + --json-tree-keyword: var(--dsw-alias-state-error-primary); + --json-tree-punctuation: var(--dsw-alias-state-error-primary); + --json-tree-icon: var(--dsw-alias-state-error-primary); +} + .details { position: relative; display: flex; diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 09ad60b427..fb369da242 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -1,9 +1,15 @@ /** Turn-aware trajectory event ledger with a local record inspector. */ -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { CSSProperties, ReactNode } from 'react' import { - extractMarkdownPlainText, IconChevronRightOutline14, JsonTree, MarkdownText, + IconChevronRightOutline14, + IconSettingsOutline16, + IconSparkle16, + IconUserOutline16, + JsonTree, + MarkdownText, + Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import { structuredPatch } from 'diff' import type { @@ -13,7 +19,7 @@ import type { AssistantMetricDetail, TrajectoryCellKind, TrajectoryCellProps, TrajectorySourceBlock, } from './trajectory-record.ts' import { formatElapsedSeconds } from './trajectory-record.ts' -import type { TrajectoryTurnModel } from './layout.ts' +import { trajectoryPreviewText, type TrajectoryTurnModel } from './layout.ts' import css from './TrajectoryTable.module.css' const KIND_LABEL: Record = { @@ -26,6 +32,77 @@ const KIND_LABEL: Record = { subtool: 'SUBTOOL', } +function ToolWrenchIcon(): ReactNode { + return ( + + ) +} + +function InformationIcon(): ReactNode { + return ( + + ) +} + +function CompactedIcon(): ReactNode { + return ( + + ) +} + +const KIND_ICON: Record = { + system: , + user: , + context: , + compacted: , + message: , + tool: , + subtool: , +} + interface TableRecord { turn: number group: string @@ -225,6 +302,8 @@ export interface TrajectoryTableProps { onSelectedIndexChange?: (index: number | null) => void /** Report a direct user selection from a ledger row. */ onRecordSelect?: (index: number) => void + /** One externally requested record selection; a new object repeats the request. */ + recordSelection?: { readonly index: number } | null /** Clear selection state owned by the ledger host. */ onClearSelection?: () => void /** Turn ids whose rows after the first are folded into a summary. */ @@ -721,13 +800,13 @@ function detailTabs(record: TableRecord): readonly DetailTabItem[] { function recordDisplayText(cell: TrajectoryCellProps): string { if (isToolCallOnly(cell)) return '' + if (cell.text !== '') return cell.text const markdown = cell.kind === 'user' || cell.kind === 'context' ? cell.inputDetail : cell.kind === 'message' ? cell.outputDetail ?? cell.thinkingDetail : undefined - if (!markdown) return cell.text - return extractMarkdownPlainText(markdown).replace(/\s+/g, ' ').trim() + return markdown === undefined ? '' : trajectoryPreviewText(markdown) } function toolCallTextParts( @@ -1043,13 +1122,20 @@ function SystemPromptDiff({ function ToolOutputBlocks({ blocks, + error, preview, }: { blocks: readonly TrajectorySourceBlock[] + error: boolean preview: boolean }) { return ( -
+
value !== undefined).join(' ')} + > {blocks.map((block, index) => ( block.imageSrc !== undefined ? @@ -1222,6 +1308,9 @@ function RecordPayload({ ? 'No payload captured' : 'No result captured' if (!value) return

{missing}

+ const error = direction === 'output' && record.cell.isError === true + const payloadClass = preview ? css.jsonPreview : css.jsonPayload + const payloadClassName = error ? `${payloadClass} ${css.errorPayload}` : payloadClass const json = parseJsonContainer(value) const singleTextResult = direction === 'output' @@ -1232,7 +1321,7 @@ function RecordPayload({ ) } @@ -1245,6 +1334,7 @@ function RecordPayload({ return ( ) @@ -1258,7 +1348,11 @@ function RecordPayload({ ) if (markdown) { return ( -
+
className !== undefined).join(' ')} + >
) @@ -1268,7 +1362,7 @@ function RecordPayload({ ) } @@ -1276,7 +1370,7 @@ function RecordPayload({
 value !== undefined).join(' ')}
     >
@@ -1397,6 +1491,7 @@ export function TrajectoryTable({
   searchMatchIndexes = null,
   onSelectedIndexChange,
   onRecordSelect,
+  recordSelection = null,
   onClearSelection,
   collapsedTurns,
   onToggleTurn,
@@ -1406,15 +1501,16 @@ export function TrajectoryTable({
   const [selectedIndex, setSelectedIndex] = useState(null)
   const [selectedRequest, setSelectedRequest] = useState(null)
   const [activeTab, setActiveTab] = useState('overview')
-  const [thinkingExpanded, setThinkingExpanded] = useState(true)
+  const [thinkingExpanded, setThinkingExpanded] = useState(false)
   const [detailsWidth, setDetailsWidth] = useState(null)
   const [toolRequestOffset, setToolRequestOffset] = useState(null)
   const detailsResizeDrag = useRef(null)
+  const appliedRecordSelection = useRef(null)
   const tabHistory = useRef>(new Set(['overview']))
   useEffect(() => {
     onSelectedIndexChange?.(selectedIndex)
   }, [onSelectedIndexChange, selectedIndex])
-  const allRecords = flattenRecords(turns)
+  const allRecords = useMemo(() => flattenRecords(turns), [turns])
   const requestNumbers = indexRequestNumbers(allRecords, sessionRequestNumbers)
   const records = searchMatchIndexes === null
     ? collapseAssistantRecords(
@@ -1531,7 +1627,7 @@ export function TrajectoryTable({
     onClearSelection?.()
   }
 
-  const selectRecord = (index: number) => {
+  const selectRecord = useCallback((index: number) => {
     const record = allRecords.find(candidate => candidate.cell.index === index)
     onRecordSelect?.(index)
     setSelectedRequest(null)
@@ -1541,7 +1637,15 @@ export function TrajectoryTable({
     const available = new Set(tabs.map(tab => tab.id))
     const recent = [...tabHistory.current].reverse().find(tab => available.has(tab))
     setActiveTab(recent ?? tabs[0]?.id ?? 'overview')
-  }
+  }, [allRecords, onRecordSelect])
+  useEffect(() => {
+    if (
+      recordSelection === null
+      || appliedRecordSelection.current === recordSelection
+    ) return
+    appliedRecordSelection.current = recordSelection
+    selectRecord(recordSelection.index)
+  }, [recordSelection, selectRecord])
 
   const selectRequest = (
     request: SelectedRequest,
@@ -1714,8 +1818,14 @@ export function TrajectoryTable({
                         className={activeTurn === record.turn
                           ? `${css.turnLabel} ${css.turnLabelActive}`
                           : css.turnLabel}
+                        aria-label={`Turn ${record.turn}`}
                       >
-                        Turn {record.turn}
+                        
+                        
                       
                     )}
                     
@@ -1723,24 +1833,33 @@ export function TrajectoryTable({ - - {KIND_LABEL[record.cell.kind]} - + + + + + {KIND_LABEL[record.cell.kind]} + + + )}
@@ -1973,7 +2092,9 @@ export function TrajectoryTable({
Status
-
{statusLabel(selectedRequestState)}
+
+ {statusLabel(selectedRequestState)} +
{selectedRequestInfo?.purpose === 'compaction' && (
@@ -2014,7 +2135,7 @@ export function TrajectoryTable({ {selectedRequestInfo?.error !== undefined && (
Error
-
{selectedRequestInfo.error}
+
{selectedRequestInfo.error}
)} {selectedRequestInfo?.retry !== undefined && ( @@ -2122,7 +2243,9 @@ export function TrajectoryTable({
Status
-
{statusLabel(selectedState)}
+
+ {statusLabel(selectedState)} +
Duration
@@ -2225,7 +2348,9 @@ export function TrajectoryTable({ )}
Status
-
{statusLabel(selectedState)}
+
+ {statusLabel(selectedState)} +
{selected.cell.kind === 'message' && ( diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css index 734b2f3326..402387da48 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css @@ -70,16 +70,29 @@ .lanes { position: absolute; z-index: 2; - inset: 7px 0; + top: 7px; + bottom: 7px; + left: var(--trajectory-domain-left); + width: var(--trajectory-domain-width); } .turnBoundaries { position: absolute; z-index: 3; - inset: 0; + top: 0; + bottom: 0; + left: var(--trajectory-domain-left); + width: var(--trajectory-domain-width); pointer-events: none; } +@media (prefers-reduced-motion: no-preference) { + .lanes[data-animate-viewport='true'], + .turnBoundaries[data-animate-viewport='true'] { + transition: left 180ms ease-out; + } +} + .turnBoundary { position: absolute; top: 0; @@ -133,6 +146,10 @@ ); } +.span[data-error='true'] { + background: var(--dsw-alias-state-error-primary); +} + .span[data-equal-duration='true'] { width: 8px; min-width: 8px; @@ -142,6 +159,18 @@ opacity: 0.2; } +.span[data-hovered='true']:not([data-current='true']) { + z-index: 1; + opacity: 0.78; + box-shadow: + 0 0 0 1px var(--dsw-alias-bg-layer-2), + 0 0 0 2px color-mix( + in srgb, + var(--dsw-alias-state-business-primary) 80%, + transparent + ); +} + .span[data-current='true'] { z-index: 1; opacity: 1; diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx index 8ad1afefc1..89e3b8bb2b 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx @@ -15,12 +15,20 @@ import css from './TrajectoryTimeline.module.css' const MINIMUM_DRAG_PX = 3 const MINIMUM_ZOOM_OPERATIONS = 4 +const EDGE_PAN_ZONE_FRACTION = 0.08 +const EDGE_PAN_STEP_FRACTION = 0.025 +const MAXIMUM_EDGE_PAN_PX = 32 interface FractionRange { start: number end: number } +interface HoverPoint { + fraction: number + recordIndex: number | null +} + /** Props for the fixed full-domain overview above the trajectory ledger. */ export interface TrajectoryTimelineProps { turns: readonly TrajectoryTurnModel[] @@ -30,6 +38,9 @@ export interface TrajectoryTimelineProps { /** Record indexes matching the active ledger search, or null without a query. */ searchMatchIndexes?: ReadonlySet | null onRangeChange: (range: TrajectoryTimeRange | null) => void + /** Select a directly clicked timeline block. */ + onRecordSelect?: (index: number) => void + /** Bring the nearest record into view after clicking timeline whitespace. */ onRecordFocus?: (index: number) => void } @@ -41,11 +52,16 @@ function clampFraction(value: number): number { return Math.min(1, Math.max(0, value)) } -function centeredRange(center: number, width: number): FractionRange { - const clampedWidth = Math.min(1, Math.max(0, width)) +function centeredRange( + center: number, + width: number, + minimum: number, + maximum: number, +): FractionRange { + const clampedWidth = Math.min(maximum - minimum, Math.max(0, width)) const start = Math.min( - Math.max(center - clampedWidth / 2, 0), - 1 - clampedWidth, + Math.max(center - clampedWidth / 2, minimum), + maximum - clampedWidth, ) return { start, end: start + clampedWidth } } @@ -79,6 +95,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ selectedIndex = null, searchMatchIndexes = null, onRangeChange, + onRecordSelect, onRecordFocus, }: TrajectoryTimelineProps) { const model = useMemo(() => deriveTrajectoryTimeline(turns, mode), [mode, turns]) @@ -94,10 +111,16 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ )), [turns], ) - const dragRef = useRef<{ pointerId: number; anchor: number; width: number } | null>(null) - const [draft, setDraft] = useState(null) - const [hover, setHover] = useState(null) + const dragRef = useRef<{ + pointerId: number + anchorTime: number + anchorClientX: number + recordIndex: number | null + } | null>(null) + const [draft, setDraft] = useState(null) + const [hover, setHover] = useState(null) const [viewport, setViewport] = useState(null) + const [animateViewport, setAnimateViewport] = useState(false) useEffect(() => { if ( model !== null @@ -109,11 +132,35 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ }, [model, onRangeChange, range]) useEffect(() => { if (model === null) return + setAnimateViewport(false) setViewport(current => current !== null && (current.end < model.start || current.start > model.end) ? null : current) }, [model]) + useEffect(() => { + if (model === null || selectedIndex === null) return + const selectedSpan = model.spans.find(span => span.index === selectedIndex) + if (selectedSpan === undefined) return + setAnimateViewport(true) + setViewport((current) => { + if (current === null) return current + if ( + selectedSpan.end > current.start + && selectedSpan.start < current.end + ) return current + const duration = Math.max(1, current.end - current.start) + const desiredStart = selectedSpan.end <= current.start + ? selectedSpan.start + : selectedSpan.end - duration + const nextStart = Math.min( + Math.max(desiredStart, model.start), + Math.max(model.start, model.end - duration), + ) + if (nextStart === current.start) return current + return { start: nextStart, end: nextStart + duration } + }) + }, [model, selectedIndex]) const fullDuration = Math.max(1, (model?.end ?? 0) - (model?.start ?? 0)) const viewportDuration = Math.min( fullDuration, @@ -127,16 +174,21 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ ) const domainDuration = viewport === null ? fullDuration : viewportDuration const domainStart = viewport === null ? model?.start ?? 0 : viewportStart + const projectedDomainStyle = model === null + ? undefined + : { + '--trajectory-domain-left': + `${-(domainStart - model.start) / domainDuration * 100}%`, + '--trajectory-domain-width': `${fullDuration / domainDuration * 100}%`, + } as CSSProperties const committed = model === null || range === null ? null : rangeFraction(range, domainStart, domainDuration) - const visibleRange = draft ?? committed - const activeRange = draft === null - ? range - : { - start: domainStart + draft.start * domainDuration, - end: domainStart + draft.end * domainDuration, - } + const draftFraction = model === null || draft === null + ? null + : rangeFraction(draft, domainStart, domainDuration) + const visibleRange = draftFraction ?? committed + const activeRange = draft ?? range if (model === null) { return ( @@ -151,9 +203,9 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ ) } - const minimumSelectionFraction = Math.min( - 1, - fullDuration / domainDuration / model.spans.length, + const minimumSelectionDuration = Math.min( + domainDuration, + fullDuration / model.spans.length, ) const fractionAt = (event: PointerEvent): number => { @@ -161,51 +213,107 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ return clampFraction((event.clientX - rect.left) / Math.max(1, rect.width)) } - const commit = (fraction: FractionRange) => { - onRangeChange({ - start: domainStart + fraction.start * domainDuration, - end: domainStart + fraction.end * domainDuration, - }) + const recordIndexAt = (event: PointerEvent): number | null => { + const target = event.target instanceof HTMLElement ? event.target : null + const value = target?.closest('[data-timeline-record-index]') + ?.dataset.timelineRecordIndex + if (value === undefined) return null + const index = Number(value) + return Number.isFinite(index) ? index : null + } + + const commit = (nextRange: TrajectoryTimeRange) => { + onRangeChange(nextRange) } const onPointerDown = (event: PointerEvent) => { if (event.button !== 0) return - const rect = event.currentTarget.getBoundingClientRect() const anchor = fractionAt(event) - setHover(anchor) - dragRef.current = { pointerId: event.pointerId, anchor, width: Math.max(1, rect.width) } + const anchorTime = domainStart + anchor * domainDuration + const recordIndex = recordIndexAt(event) + setHover({ fraction: anchor, recordIndex }) + dragRef.current = { + pointerId: event.pointerId, + anchorTime, + anchorClientX: event.clientX, + recordIndex, + } if (typeof event.currentTarget.setPointerCapture === 'function') { event.currentTarget.setPointerCapture(event.pointerId) } - setDraft({ start: anchor, end: anchor }) + setDraft({ start: anchorTime, end: anchorTime }) } const onPointerMove = (event: PointerEvent) => { const drag = dragRef.current + const rect = event.currentTarget.getBoundingClientRect() const fraction = fractionAt(event) - setHover(fraction) + setHover({ fraction, recordIndex: recordIndexAt(event) }) if (drag === null || drag.pointerId !== event.pointerId) return - setDraft(orderedRange(drag.anchor, fraction)) + let nextDomainStart = domainStart + if (viewport !== null) { + const localX = event.clientX - rect.left + const edgeWidth = Math.min( + MAXIMUM_EDGE_PAN_PX, + Math.max(1, rect.width * EDGE_PAN_ZONE_FRACTION), + ) + const direction = localX < edgeWidth + ? -1 + : localX > rect.width - edgeWidth ? 1 : 0 + if (direction !== 0) { + const edgeDistance = direction < 0 + ? edgeWidth - localX + : localX - (rect.width - edgeWidth) + const strength = clampFraction(edgeDistance / edgeWidth) + const desiredStart = domainStart + + direction * domainDuration * EDGE_PAN_STEP_FRACTION + * Math.max(0.2, strength) + nextDomainStart = Math.min( + Math.max(desiredStart, model.start), + model.end - domainDuration, + ) + if (nextDomainStart !== domainStart) { + setAnimateViewport(false) + setViewport({ + start: nextDomainStart, + end: nextDomainStart + domainDuration, + }) + } + } + } + const pointTime = nextDomainStart + fraction * domainDuration + setDraft(orderedRange(drag.anchorTime, pointTime)) } const onPointerEnd = (event: PointerEvent) => { const drag = dragRef.current if (drag === null || drag.pointerId !== event.pointerId) return - const point = fractionAt(event) - const selected = orderedRange(drag.anchor, point) - setHover(point) + const pointFraction = fractionAt(event) + const pointTime = domainStart + pointFraction * domainDuration + const selected = orderedRange(drag.anchorTime, pointTime) + setHover({ fraction: pointFraction, recordIndex: recordIndexAt(event) }) dragRef.current = null setDraft(null) - const click = (selected.end - selected.start) * drag.width < MINIMUM_DRAG_PX - const committedRange = selected.end - selected.start < minimumSelectionFraction + const click = Math.abs(event.clientX - drag.anchorClientX) < MINIMUM_DRAG_PX + const clickedSpan = click && drag.recordIndex !== null + ? model.spans.find(span => span.index === drag.recordIndex) + : undefined + if (clickedSpan !== undefined) { + onRangeChange(null) + onRecordSelect?.(clickedSpan.index) + return + } + const committedRange = selected.end - selected.start < minimumSelectionDuration ? centeredRange( click ? selected.start : (selected.start + selected.end) / 2, - minimumSelectionFraction, + minimumSelectionDuration, + model.start, + model.end, ) : selected commit(committedRange) if (click) { - const timelinePoint = domainStart + selected.start * domainDuration + const timelinePoint = selected.start const nearest = model.spans.reduce((candidate, span) => { const candidateDistance = timelinePoint < candidate.start ? candidate.start - timelinePoint @@ -233,6 +341,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ const onWheel = (event: WheelEvent) => { event.preventDefault() + setAnimateViewport(false) const rect = event.currentTarget.getBoundingClientRect() const anchorFraction = clampFraction((event.clientX - rect.left) / Math.max(1, rect.width)) @@ -278,16 +387,18 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ onWheel={onWheel} onContextMenu={(event) => { event.preventDefault() + setAnimateViewport(false) onRangeChange(null) setViewport(null) }} > - {hover !== null && draft === null && ( + {hover !== null && hover.recordIndex === null && draft === null && (