Merge pull request #1032 from deepseek-harness/xtr/trajectory-timeline-polish
fix(trajectory): polish timeline timing and layering
This commit is contained in:
@@ -147,14 +147,21 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
|||||||
await expect.poll(() => page.locator('tr[data-turn-start="true"]').count(), { timeout: 15_000 }).toBe(2)
|
await expect.poll(() => page.locator('tr[data-turn-start="true"]').count(), { timeout: 15_000 }).toBe(2)
|
||||||
await expect.poll(() => page.getByRole('columnheader').count(), { timeout: 10_000 }).toBe(0)
|
await expect.poll(() => page.getByRole('columnheader').count(), { timeout: 10_000 }).toBe(0)
|
||||||
await page.locator('tr[data-kind="tool"]').first().click()
|
await page.locator('tr[data-kind="tool"]').first().click()
|
||||||
await expect.poll(() => page.getByRole('complementary', { name: 'Event details' }).count(), { timeout: 10_000 }).toBe(1)
|
const details = page.getByRole('complementary', { name: 'Event details' })
|
||||||
|
await expect.poll(() => details.count(), { timeout: 10_000 }).toBe(1)
|
||||||
|
await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') })
|
||||||
|
const darkSummarySurfaces = await details.getByRole('heading', { name: 'Payload' }).evaluate(heading => ({
|
||||||
|
heading: getComputedStyle(heading).backgroundColor,
|
||||||
|
panel: getComputedStyle(heading.closest('[aria-label="Event details"]')!).backgroundColor,
|
||||||
|
}))
|
||||||
|
expect(darkSummarySurfaces.heading).toBe(darkSummarySurfaces.panel)
|
||||||
|
await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') })
|
||||||
await page.getByRole('tab', { name: 'Result' }).click()
|
await page.getByRole('tab', { name: 'Result' }).click()
|
||||||
await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
|
await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
|
||||||
const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd))
|
const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd))
|
||||||
.split(SEED_ID).join('{{seededId}}')
|
.split(SEED_ID).join('{{seededId}}')
|
||||||
await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE)
|
await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE)
|
||||||
await page.getByRole('complementary', { name: 'Event details' })
|
await details.getByRole('button', { name: 'Close details' }).click()
|
||||||
.getByRole('button', { name: 'Close details' }).click()
|
|
||||||
}, 60_000)
|
}, 60_000)
|
||||||
|
|
||||||
it.skipIf(MODE === 'record')('focuses the ledger by dragging an overview interval', async () => {
|
it.skipIf(MODE === 'record')('focuses the ledger by dragging an overview interval', async () => {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export default defineConfig({
|
|||||||
{ find: /^@deepseek-ai\/dsh-client-web-react$/, replacement: src('../../packages/client/web-react/src/index.ts') },
|
{ find: /^@deepseek-ai\/dsh-client-web-react$/, replacement: src('../../packages/client/web-react/src/index.ts') },
|
||||||
{ find: /^@deepseek-ai\/dsh-client-ui-slots$/, replacement: src('../../packages/client/ui-slots/src/index.ts') },
|
{ find: /^@deepseek-ai\/dsh-client-ui-slots$/, replacement: src('../../packages/client/ui-slots/src/index.ts') },
|
||||||
{ find: /^@deepseek-ai\/dsh-client-ui-primitives$/, replacement: src('../../packages/client/ui-primitives/src/index.ts') },
|
{ find: /^@deepseek-ai\/dsh-client-ui-primitives$/, replacement: src('../../packages/client/ui-primitives/src/index.ts') },
|
||||||
|
{ find: /^@deepseek-ai\/dsh-client-schema-form$/, replacement: src('../../packages/client/schema-form/src/index.ts') },
|
||||||
{ find: /^@deepseek-ai\/dsh-client-modules\/client$/, replacement: src('../../packages/client/modules/src/client/index.ts') },
|
{ find: /^@deepseek-ai\/dsh-client-modules\/client$/, replacement: src('../../packages/client/modules/src/client/index.ts') },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -316,6 +316,7 @@ flowchart TD
|
|||||||
pkg_client_ui_settings --> pkg_client_ui_primitives
|
pkg_client_ui_settings --> pkg_client_ui_primitives
|
||||||
pkg_client_ui_settings --> pkg_client_ui_slots
|
pkg_client_ui_settings --> pkg_client_ui_slots
|
||||||
pkg_client_ui_settings --> pkg_invariants
|
pkg_client_ui_settings --> pkg_invariants
|
||||||
|
pkg_client_ui_trajectory --> pkg_client_runtime
|
||||||
pkg_client_ui_trajectory --> pkg_client_ui_primitives
|
pkg_client_ui_trajectory --> pkg_client_ui_primitives
|
||||||
pkg_client_ui_trajectory --> pkg_invariants
|
pkg_client_ui_trajectory --> pkg_invariants
|
||||||
pkg_credentials --> pkg_brand
|
pkg_credentials --> pkg_brand
|
||||||
@@ -1082,7 +1083,7 @@ flowchart TD
|
|||||||
| [`client-locale`](../packages/client/locale) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
| [`client-locale`](../packages/client/locale) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||||
| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) |
|
| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) |
|
||||||
| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||||
| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) |
|
| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) |
|
||||||
| [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) |
|
| [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) |
|
||||||
| [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) |
|
| [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) |
|
||||||
| [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) |
|
| [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) |
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
},
|
},
|
||||||
"dshClient": {
|
"dshClient": {
|
||||||
"inject": [
|
"inject": [
|
||||||
|
"@deepseek-ai/dsh-client-runtime",
|
||||||
"@deepseek-ai/dsh-client-ui-conversation"
|
"@deepseek-ai/dsh-client-ui-conversation"
|
||||||
],
|
],
|
||||||
"platform": "web"
|
"platform": "web"
|
||||||
@@ -37,6 +38,7 @@
|
|||||||
"diff": "^9.0.0"
|
"diff": "^9.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
||||||
"cordis": "^4.0.0-rc.7",
|
"cordis": "^4.0.0-rc.7",
|
||||||
|
|||||||
@@ -321,6 +321,13 @@
|
|||||||
width: 76px;
|
width: 76px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.kindSlot :global([role='tooltip']) {
|
||||||
|
border: 1px solid var(--dsw-alias-border-l2);
|
||||||
|
background: var(--dsw-alias-bg-layer-2);
|
||||||
|
box-shadow: var(--dsw-shadow-lv2);
|
||||||
|
color: var(--dsw-alias-label-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
padding-left: 4px !important;
|
padding-left: 4px !important;
|
||||||
color: var(--dsw-alias-label-primary);
|
color: var(--dsw-alias-label-primary);
|
||||||
@@ -828,6 +835,7 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detailBodySummary {
|
.detailBodySummary {
|
||||||
@@ -989,7 +997,7 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0 0 3px 14px;
|
padding: 0 0 3px 14px;
|
||||||
color: var(--dsw-alias-label-secondary);
|
color: var(--dsw-alias-label-secondary);
|
||||||
background: var(--dsw-alias-bg-base);
|
background: var(--dsw-alias-bg-layer-1);
|
||||||
font: var(--dsw-font-xs-strong-13);
|
font: var(--dsw-font-xs-strong-13);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1906,7 +1906,10 @@ export function TrajectoryTable({
|
|||||||
<span
|
<span
|
||||||
className={css.kindSlot}
|
className={css.kindSlot}
|
||||||
>
|
>
|
||||||
<Tooltip label={KIND_LABEL[record.cell.kind]} side="bottom">
|
<Tooltip
|
||||||
|
label={KIND_LABEL[record.cell.kind]}
|
||||||
|
side="right"
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
className={`${css.kindTag} ${
|
className={`${css.kindTag} ${
|
||||||
record.cell.kind === 'system'
|
record.cell.kind === 'system'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
.root {
|
.root {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 0;
|
z-index: 1;
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
flex: none;
|
flex: none;
|
||||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
memo, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent,
|
memo, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent,
|
||||||
type PointerEvent, type WheelEvent,
|
type PointerEvent,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import type { TrajectoryTurnModel } from './layout.ts'
|
import type { TrajectoryTurnModel } from './layout.ts'
|
||||||
import {
|
import {
|
||||||
@@ -70,11 +70,17 @@ function rangeFraction(
|
|||||||
range: TrajectoryTimeRange,
|
range: TrajectoryTimeRange,
|
||||||
start: number,
|
start: number,
|
||||||
duration: number,
|
duration: number,
|
||||||
|
minimum: number,
|
||||||
|
maximum: number,
|
||||||
): FractionRange {
|
): FractionRange {
|
||||||
return orderedRange(
|
const bounded = orderedRange(
|
||||||
clampFraction((range.start - start) / duration),
|
Math.min(maximum, Math.max(minimum, range.start)),
|
||||||
clampFraction((range.end - start) / duration),
|
Math.min(maximum, Math.max(minimum, range.end)),
|
||||||
)
|
)
|
||||||
|
return {
|
||||||
|
start: (bounded.start - start) / duration,
|
||||||
|
end: (bounded.end - start) / duration,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function LaneLabels() {
|
function LaneLabels() {
|
||||||
@@ -117,6 +123,8 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
|||||||
anchorClientX: number
|
anchorClientX: number
|
||||||
recordIndex: number | null
|
recordIndex: number | null
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
const rootRef = useRef<HTMLElement | null>(null)
|
||||||
|
const trackRef = useRef<HTMLDivElement | null>(null)
|
||||||
const [draft, setDraft] = useState<TrajectoryTimeRange | null>(null)
|
const [draft, setDraft] = useState<TrajectoryTimeRange | null>(null)
|
||||||
const [hover, setHover] = useState<HoverPoint | null>(null)
|
const [hover, setHover] = useState<HoverPoint | null>(null)
|
||||||
const [viewport, setViewport] = useState<TrajectoryTimeRange | null>(null)
|
const [viewport, setViewport] = useState<TrajectoryTimeRange | null>(null)
|
||||||
@@ -183,16 +191,48 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
|||||||
} as CSSProperties
|
} as CSSProperties
|
||||||
const committed = model === null || range === null
|
const committed = model === null || range === null
|
||||||
? null
|
? null
|
||||||
: rangeFraction(range, domainStart, domainDuration)
|
: rangeFraction(range, domainStart, domainDuration, model.start, model.end)
|
||||||
const draftFraction = model === null || draft === null
|
const draftFraction = model === null || draft === null
|
||||||
? null
|
? null
|
||||||
: rangeFraction(draft, domainStart, domainDuration)
|
: rangeFraction(draft, domainStart, domainDuration, model.start, model.end)
|
||||||
const visibleRange = draftFraction ?? committed
|
const visibleRange = draftFraction ?? committed
|
||||||
const activeRange = draft ?? range
|
const activeRange = draft ?? range
|
||||||
|
useEffect(() => {
|
||||||
|
const root = rootRef.current
|
||||||
|
if (root === null) return
|
||||||
|
const onWheel = (event: globalThis.WheelEvent): void => {
|
||||||
|
event.preventDefault()
|
||||||
|
const track = trackRef.current
|
||||||
|
if (track === null || model === null) return
|
||||||
|
setAnimateViewport(false)
|
||||||
|
const rect = track.getBoundingClientRect()
|
||||||
|
const anchorFraction =
|
||||||
|
clampFraction((event.clientX - rect.left) / Math.max(1, rect.width))
|
||||||
|
const nextDuration = Math.min(
|
||||||
|
fullDuration,
|
||||||
|
Math.max(
|
||||||
|
Math.min(mode === 'sequence' ? MINIMUM_ZOOM_OPERATIONS : 20, fullDuration),
|
||||||
|
domainDuration * Math.exp(event.deltaY * 0.0015),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (nextDuration >= fullDuration * 0.999) {
|
||||||
|
setViewport(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const anchorTime = domainStart + anchorFraction * domainDuration
|
||||||
|
const nextStart = Math.min(
|
||||||
|
Math.max(anchorTime - anchorFraction * nextDuration, model.start),
|
||||||
|
model.end - nextDuration,
|
||||||
|
)
|
||||||
|
setViewport({ start: nextStart, end: nextStart + nextDuration })
|
||||||
|
}
|
||||||
|
root.addEventListener('wheel', onWheel, { passive: false })
|
||||||
|
return () => { root.removeEventListener('wheel', onWheel) }
|
||||||
|
}, [domainDuration, domainStart, fullDuration, mode, model])
|
||||||
|
|
||||||
if (model === null) {
|
if (model === null) {
|
||||||
return (
|
return (
|
||||||
<section className={css.root} aria-label="Trajectory timeline">
|
<section ref={rootRef} className={css.root} aria-label="Trajectory timeline">
|
||||||
<div className={css.plot}>
|
<div className={css.plot}>
|
||||||
<LaneLabels />
|
<LaneLabels />
|
||||||
<div className={css.track}>
|
<div className={css.track}>
|
||||||
@@ -339,36 +379,12 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
|||||||
setHover(null)
|
setHover(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onWheel = (event: WheelEvent<HTMLDivElement>) => {
|
|
||||||
event.preventDefault()
|
|
||||||
setAnimateViewport(false)
|
|
||||||
const rect = event.currentTarget.getBoundingClientRect()
|
|
||||||
const anchorFraction =
|
|
||||||
clampFraction((event.clientX - rect.left) / Math.max(1, rect.width))
|
|
||||||
const nextDuration = Math.min(
|
|
||||||
fullDuration,
|
|
||||||
Math.max(
|
|
||||||
Math.min(mode === 'sequence' ? MINIMUM_ZOOM_OPERATIONS : 20, fullDuration),
|
|
||||||
domainDuration * Math.exp(event.deltaY * 0.0015),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (nextDuration >= fullDuration * 0.999) {
|
|
||||||
setViewport(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const anchorTime = domainStart + anchorFraction * domainDuration
|
|
||||||
const nextStart = Math.min(
|
|
||||||
Math.max(anchorTime - anchorFraction * nextDuration, model.start),
|
|
||||||
model.end - nextDuration,
|
|
||||||
)
|
|
||||||
setViewport({ start: nextStart, end: nextStart + nextDuration })
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={css.root} aria-label="Trajectory timeline">
|
<section ref={rootRef} className={css.root} aria-label="Trajectory timeline">
|
||||||
<div className={css.plot}>
|
<div className={css.plot}>
|
||||||
<LaneLabels />
|
<LaneLabels />
|
||||||
<div
|
<div
|
||||||
|
ref={trackRef}
|
||||||
className={css.track}
|
className={css.track}
|
||||||
aria-label="Timeline overview; drag horizontally to focus events"
|
aria-label="Timeline overview; drag horizontally to focus events"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
@@ -384,7 +400,6 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
onRangeChange(null)
|
onRangeChange(null)
|
||||||
}}
|
}}
|
||||||
onWheel={onWheel}
|
|
||||||
onContextMenu={(event) => {
|
onContextMenu={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setAnimateViewport(false)
|
setAnimateViewport(false)
|
||||||
|
|||||||
@@ -149,7 +149,7 @@
|
|||||||
font: var(--dsw-font-xxs-12);
|
font: var(--dsw-font-xxs-12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.action:hover:not(:disabled) {
|
.action:hover {
|
||||||
color: var(--dsw-alias-label-primary);
|
color: var(--dsw-alias-label-primary);
|
||||||
background: var(--dsw-alias-interactive-bg-hover);
|
background: var(--dsw-alias-interactive-bg-hover);
|
||||||
}
|
}
|
||||||
@@ -159,11 +159,6 @@
|
|||||||
outline-offset: 1px;
|
outline-offset: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action:disabled {
|
|
||||||
color: var(--dsw-alias-label-dimmed);
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actionIcon {
|
.actionIcon {
|
||||||
color: var(--dsw-alias-label-tertiary);
|
color: var(--dsw-alias-label-tertiary);
|
||||||
font: 14px/14px var(--ds-font-family-code);
|
font: 14px/14px var(--ds-font-family-code);
|
||||||
|
|||||||
@@ -8,18 +8,14 @@ export interface TrajectoryToolbarProps {
|
|||||||
actualDuration: boolean
|
actualDuration: boolean
|
||||||
/** Select recorded-duration or equal-width blocks. */
|
/** Select recorded-duration or equal-width blocks. */
|
||||||
onActualDurationChange: (actualDuration: boolean) => void
|
onActualDurationChange: (actualDuration: boolean) => void
|
||||||
/** Whether recorded timing retains idle gaps between user turns. */
|
/** Whether recorded timing retains idle gaps between operations. */
|
||||||
actualTime: boolean
|
actualTime: boolean
|
||||||
/** Select complete wall-clock timing or idle-compressed timing. */
|
/** Select complete wall-clock timing or idle-compressed timing. */
|
||||||
onActualTimeChange: (actualTime: boolean) => void
|
onActualTimeChange: (actualTime: boolean) => void
|
||||||
/** Number of turns containing more than one row. */
|
|
||||||
collapsibleTurns: number
|
|
||||||
/** Whether every collapsible turn is currently folded. */
|
/** Whether every collapsible turn is currently folded. */
|
||||||
allTurnsCollapsed: boolean
|
allTurnsCollapsed: boolean
|
||||||
/** Fold or expand every collapsible turn. */
|
/** Fold or expand every collapsible turn. */
|
||||||
onToggleAllTurns: () => void
|
onToggleAllTurns: () => void
|
||||||
/** Number of assistant messages followed by tool calls. */
|
|
||||||
collapsibleAssistants: number
|
|
||||||
/** Whether every collapsible assistant's tool calls are currently folded. */
|
/** Whether every collapsible assistant's tool calls are currently folded. */
|
||||||
allAssistantsCollapsed: boolean
|
allAssistantsCollapsed: boolean
|
||||||
/** Fold or expand tool calls under every collapsible assistant. */
|
/** Fold or expand tool calls under every collapsible assistant. */
|
||||||
@@ -40,10 +36,8 @@ export function TrajectoryToolbar({
|
|||||||
onActualDurationChange,
|
onActualDurationChange,
|
||||||
actualTime,
|
actualTime,
|
||||||
onActualTimeChange,
|
onActualTimeChange,
|
||||||
collapsibleTurns,
|
|
||||||
allTurnsCollapsed,
|
allTurnsCollapsed,
|
||||||
onToggleAllTurns,
|
onToggleAllTurns,
|
||||||
collapsibleAssistants,
|
|
||||||
allAssistantsCollapsed,
|
allAssistantsCollapsed,
|
||||||
onToggleAllAssistants,
|
onToggleAllAssistants,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
@@ -91,7 +85,6 @@ export function TrajectoryToolbar({
|
|||||||
aria-label={allTurnsCollapsed ? 'Expand turns' : 'Collapse turns'}
|
aria-label={allTurnsCollapsed ? 'Expand turns' : 'Collapse turns'}
|
||||||
aria-pressed={allTurnsCollapsed}
|
aria-pressed={allTurnsCollapsed}
|
||||||
title={allTurnsCollapsed ? 'Expand turns' : 'Collapse turns'}
|
title={allTurnsCollapsed ? 'Expand turns' : 'Collapse turns'}
|
||||||
disabled={collapsibleTurns === 0}
|
|
||||||
onClick={onToggleAllTurns}
|
onClick={onToggleAllTurns}
|
||||||
>
|
>
|
||||||
<span className={css.actionIcon} aria-hidden="true">
|
<span className={css.actionIcon} aria-hidden="true">
|
||||||
@@ -105,7 +98,6 @@ export function TrajectoryToolbar({
|
|||||||
aria-label={allAssistantsCollapsed ? 'Expand calls' : 'Collapse calls'}
|
aria-label={allAssistantsCollapsed ? 'Expand calls' : 'Collapse calls'}
|
||||||
aria-pressed={allAssistantsCollapsed}
|
aria-pressed={allAssistantsCollapsed}
|
||||||
title={allAssistantsCollapsed ? 'Expand calls' : 'Collapse calls'}
|
title={allAssistantsCollapsed ? 'Expand calls' : 'Collapse calls'}
|
||||||
disabled={collapsibleAssistants === 0}
|
|
||||||
onClick={onToggleAllAssistants}
|
onClick={onToggleAllAssistants}
|
||||||
>
|
>
|
||||||
<span className={css.actionIcon} aria-hidden="true">
|
<span className={css.actionIcon} aria-hidden="true">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/clie
|
|||||||
import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots'
|
import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots'
|
||||||
import type {
|
import type {
|
||||||
AssistantMessageNode, ConversationContext,
|
AssistantMessageNode, ConversationContext,
|
||||||
SessionHistoryFace,
|
SessionHistoryFace, SnapshotStore,
|
||||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
import {
|
import {
|
||||||
deriveTrajectoryContextBranches, trajectoryBranchContainsRequest,
|
deriveTrajectoryContextBranches, trajectoryBranchContainsRequest,
|
||||||
@@ -29,8 +29,12 @@ const EMPTY_IDS: ReadonlySet<number> = new Set()
|
|||||||
|
|
||||||
/** Session-history paging needed by the event-complete trajectory view. */
|
/** Session-history paging needed by the event-complete trajectory view. */
|
||||||
export interface TrajectoryViewInjected {
|
export interface TrajectoryViewInjected {
|
||||||
hooks: { history: SessionHistoryFace }
|
hooks: {
|
||||||
|
history: SessionHistoryFace
|
||||||
|
duration: SnapshotStore<boolean>
|
||||||
|
}
|
||||||
loadAllHistory: (signal: AbortSignal) => Promise<void>
|
loadAllHistory: (signal: AbortSignal) => Promise<void>
|
||||||
|
setActualDuration: (actualDuration: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UsageLike {
|
interface UsageLike {
|
||||||
@@ -134,7 +138,7 @@ function searchMatches(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TrajectoryView({
|
export function TrajectoryView({
|
||||||
useHistory, loadAllHistory, inspect, onInspectDone,
|
useHistory, useDuration, loadAllHistory, setActualDuration, inspect, onInspectDone,
|
||||||
}: ConvViewProps & InjectFace<TrajectoryViewInjected>) {
|
}: ConvViewProps & InjectFace<TrajectoryViewInjected>) {
|
||||||
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_IDS)
|
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_IDS)
|
||||||
const [collapsedAssistants, setCollapsedAssistants] =
|
const [collapsedAssistants, setCollapsedAssistants] =
|
||||||
@@ -143,7 +147,7 @@ export function TrajectoryView({
|
|||||||
branchId: number
|
branchId: number
|
||||||
range: TrajectoryTimeRange
|
range: TrajectoryTimeRange
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
const [actualDuration, setActualDuration] = useState(false)
|
const actualDuration = useDuration(value => value)
|
||||||
const [actualTime, setActualTime] = useState(false)
|
const [actualTime, setActualTime] = useState(false)
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [selectedTimelineIndex, setSelectedTimelineIndex] = useState<number | null>(null)
|
const [selectedTimelineIndex, setSelectedTimelineIndex] = useState<number | null>(null)
|
||||||
@@ -467,10 +471,8 @@ export function TrajectoryView({
|
|||||||
setActualTime(nextActualTime)
|
setActualTime(nextActualTime)
|
||||||
setTimelineSelection(null)
|
setTimelineSelection(null)
|
||||||
}}
|
}}
|
||||||
collapsibleTurns={collapsibleTurnIds.length}
|
|
||||||
allTurnsCollapsed={allTurnsCollapsed}
|
allTurnsCollapsed={allTurnsCollapsed}
|
||||||
onToggleAllTurns={toggleAllTurns}
|
onToggleAllTurns={toggleAllTurns}
|
||||||
collapsibleAssistants={collapsibleAssistantIds.length}
|
|
||||||
allAssistantsCollapsed={allAssistantsCollapsed}
|
allAssistantsCollapsed={allAssistantsCollapsed}
|
||||||
onToggleAllAssistants={toggleAllAssistants}
|
onToggleAllAssistants={toggleAllAssistants}
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
|
|||||||
13
packages/client/ui-trajectory/src/client/duration-store.ts
Normal file
13
packages/client/ui-trajectory/src/client/duration-store.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import {
|
||||||
|
createSnapshotStore, type SnapshotStore,
|
||||||
|
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the browser-wide trajectory duration preference source.
|
||||||
|
* @returns a persisted source shared by every session view in one plugin lifecycle.
|
||||||
|
*/
|
||||||
|
export function createTrajectoryDurationStore(): SnapshotStore<boolean> {
|
||||||
|
return createSnapshotStore(false, {
|
||||||
|
persist: { name: 'dsh.trajectory.duration' },
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
|||||||
// Type-only: the 'conversation.view' SlotMap row (declared by the slot's
|
// Type-only: the 'conversation.view' SlotMap row (declared by the slot's
|
||||||
// owning package) must be in the program for the register calls to type.
|
// owning package) must be in the program for the register calls to type.
|
||||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||||
|
import { createTrajectoryDurationStore } from './duration-store.ts'
|
||||||
import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx'
|
import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,6 +25,7 @@ export const inject = ['slots', 'conversation', 'sessionHistory']
|
|||||||
* @param ctx - client root context.
|
* @param ctx - client root context.
|
||||||
*/
|
*/
|
||||||
export function apply(ctx: Context): void {
|
export function apply(ctx: Context): void {
|
||||||
|
const duration = createTrajectoryDurationStore()
|
||||||
ctx.slots.register({
|
ctx.slots.register({
|
||||||
name: 'conversation.view',
|
name: 'conversation.view',
|
||||||
id: 'trajectory',
|
id: 'trajectory',
|
||||||
@@ -32,8 +34,9 @@ export function apply(ctx: Context): void {
|
|||||||
inject: (sessionId: SessionId): TrajectoryViewInjected => {
|
inject: (sessionId: SessionId): TrajectoryViewInjected => {
|
||||||
const history = ctx.sessionHistory.source(sessionId)
|
const history = ctx.sessionHistory.source(sessionId)
|
||||||
return {
|
return {
|
||||||
hooks: { history },
|
hooks: { history, duration },
|
||||||
loadAllHistory: signal => history.loadAll(signal),
|
loadAllHistory: signal => history.loadAll(signal),
|
||||||
|
setActualDuration: (value) => { duration.set(value) },
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}, TrajectoryView)
|
}, TrajectoryView)
|
||||||
|
|||||||
@@ -116,14 +116,9 @@ export function deriveTrajectoryTimeline(
|
|||||||
function deriveTimedTimeline(
|
function deriveTimedTimeline(
|
||||||
turns: readonly TrajectoryTurnModel[],
|
turns: readonly TrajectoryTurnModel[],
|
||||||
actualDuration: boolean,
|
actualDuration: boolean,
|
||||||
removeUserIdle: boolean,
|
compressIdle: boolean,
|
||||||
): TrajectoryTimelineModel | null {
|
): TrajectoryTimelineModel | null {
|
||||||
const spans: TrajectoryTimelineSpan[] = []
|
const timedTurns = turns.flatMap((turn) => {
|
||||||
const turnBoundaries: TrajectoryTimelineTurnBoundary[] = []
|
|
||||||
let removedUserIdle = 0
|
|
||||||
let previousTurnEnd: number | null = null
|
|
||||||
|
|
||||||
for (const turn of turns) {
|
|
||||||
const rawSpans = turn.groups.flatMap(group =>
|
const rawSpans = turn.groups.flatMap(group =>
|
||||||
group.cells.flatMap((cell): TrajectoryTimelineSpan[] => {
|
group.cells.flatMap((cell): TrajectoryTimelineSpan[] => {
|
||||||
if (cell.requestOnly === true) return []
|
if (cell.requestOnly === true) return []
|
||||||
@@ -140,30 +135,43 @@ function deriveTimedTimeline(
|
|||||||
}]
|
}]
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
if (rawSpans.length === 0) continue
|
return rawSpans.length === 0 ? [] : [{ turn: turn.turn, rawSpans }]
|
||||||
|
})
|
||||||
|
const rawSpans = timedTurns.flatMap(turn => turn.rawSpans)
|
||||||
|
if (rawSpans.length === 0) return null
|
||||||
|
|
||||||
const turnStart = Math.min(...rawSpans.map(span => span.start))
|
const removedIdleBySpan = new Map<TrajectoryTimelineSpan, number>()
|
||||||
const turnEnd = Math.max(...rawSpans.map(span => span.end))
|
let removedIdle = 0
|
||||||
if (removeUserIdle && previousTurnEnd !== null) {
|
let coveredUntil: number | null = null
|
||||||
removedUserIdle += Math.max(0, turnStart - previousTurnEnd)
|
for (const span of [...rawSpans].sort((left, right) =>
|
||||||
|
left.start - right.start || left.end - right.end)) {
|
||||||
|
if (compressIdle && coveredUntil !== null && span.start > coveredUntil) {
|
||||||
|
removedIdle += span.start - coveredUntil
|
||||||
}
|
}
|
||||||
spans.push(...rawSpans.map(span => ({
|
removedIdleBySpan.set(span, removedIdle)
|
||||||
...span,
|
coveredUntil = coveredUntil === null ? span.end : Math.max(coveredUntil, span.end)
|
||||||
start: span.start - removedUserIdle,
|
}
|
||||||
end: (actualDuration ? span.end : span.start) - removedUserIdle,
|
|
||||||
})))
|
const spans: TrajectoryTimelineSpan[] = []
|
||||||
|
const turnBoundaries: TrajectoryTimelineTurnBoundary[] = []
|
||||||
|
for (const turn of timedTurns) {
|
||||||
|
const projected = turn.rawSpans.map((span): TrajectoryTimelineSpan => {
|
||||||
|
const offset = removedIdleBySpan.get(span) ?? 0
|
||||||
|
return {
|
||||||
|
...span,
|
||||||
|
start: span.start - offset,
|
||||||
|
end: (actualDuration ? span.end : span.start) - offset,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
spans.push(...projected)
|
||||||
if (turn.turn !== null) {
|
if (turn.turn !== null) {
|
||||||
turnBoundaries.push({
|
turnBoundaries.push({
|
||||||
turn: turn.turn,
|
turn: turn.turn,
|
||||||
time: turnStart - removedUserIdle,
|
time: Math.min(...projected.map(span => span.start)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
previousTurnEnd = previousTurnEnd === null
|
|
||||||
? turnEnd
|
|
||||||
: Math.max(previousTurnEnd, turnEnd)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (spans.length === 0) return null
|
|
||||||
return {
|
return {
|
||||||
start: Math.min(...spans.map(span => span.start)),
|
start: Math.min(...spans.map(span => span.start)),
|
||||||
end: Math.max(...spans.map(span => span.end)),
|
end: Math.max(...spans.map(span => span.end)),
|
||||||
|
|||||||
@@ -27,6 +27,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ledger {
|
.ledger {
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
isolation: isolate;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ describe('tsdown client artifact', () => {
|
|||||||
const modules = new Map<string, unknown>([
|
const modules = new Map<string, unknown>([
|
||||||
['react', await import('react')],
|
['react', await import('react')],
|
||||||
['react/jsx-runtime', await import('react/jsx-runtime')],
|
['react/jsx-runtime', await import('react/jsx-runtime')],
|
||||||
|
['@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')],
|
['@deepseek-ai/dsh-client-ui-primitives', await import('@deepseek-ai/dsh-client-ui-primitives')],
|
||||||
])
|
])
|
||||||
const surface = handoff!.factory((spec) => {
|
const surface = handoff!.factory((spec) => {
|
||||||
|
|||||||
@@ -184,7 +184,9 @@ describe('TrajectoryTable', () => {
|
|||||||
expect(toolTag?.querySelector('[data-role-icon="wrench"]')).toBeTruthy()
|
expect(toolTag?.querySelector('[data-role-icon="wrench"]')).toBeTruthy()
|
||||||
|
|
||||||
fireEvent.mouseEnter(toolTag as HTMLElement)
|
fireEvent.mouseEnter(toolTag as HTMLElement)
|
||||||
expect(screen.getByRole('tooltip').textContent).toBe('TOOL')
|
const tooltip = screen.getByRole('tooltip')
|
||||||
|
expect(tooltip.textContent).toBe('TOOL')
|
||||||
|
expect(tooltip.getAttribute('data-side')).toBe('right')
|
||||||
fireEvent.mouseLeave(toolTag as HTMLElement)
|
fireEvent.mouseLeave(toolTag as HTMLElement)
|
||||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { TrajectoryTimeline } from '../src/client/TrajectoryTimeline.tsx'
|
|||||||
import {
|
import {
|
||||||
TrajectoryView, type TrajectoryViewInjected,
|
TrajectoryView, type TrajectoryViewInjected,
|
||||||
} from '../src/client/TrajectoryView.tsx'
|
} from '../src/client/TrajectoryView.tsx'
|
||||||
|
import { createTrajectoryDurationStore } from '../src/client/duration-store.ts'
|
||||||
import { deriveTrajectoryTimeline } from '../src/client/timeline.ts'
|
import { deriveTrajectoryTimeline } from '../src/client/timeline.ts'
|
||||||
|
|
||||||
const SID = 's1' as SessionId
|
const SID = 's1' as SessionId
|
||||||
@@ -96,6 +97,16 @@ function standaloneHistory(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function standaloneDuration(): Pick<
|
||||||
|
ComponentProps<typeof TrajectoryView>, 'useDuration' | 'setActualDuration'
|
||||||
|
> {
|
||||||
|
const duration = createSnapshotStore(false)
|
||||||
|
return {
|
||||||
|
useDuration: bindSnapshotSelector(duration),
|
||||||
|
setActualDuration: (value) => { duration.set(value) },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function fakeSession(nodes: ConversationSnapshot['nodes']) {
|
function fakeSession(nodes: ConversationSnapshot['nodes']) {
|
||||||
const store = createSnapshotStore({
|
const store = createSnapshotStore({
|
||||||
nodes, pending: [], partial: null,
|
nodes, pending: [], partial: null,
|
||||||
@@ -187,12 +198,15 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
|
|||||||
? {}
|
? {}
|
||||||
: injectEntry(SID)
|
: injectEntry(SID)
|
||||||
const injectedProps = 'hooks' in injected
|
const injectedProps = 'hooks' in injected
|
||||||
? {
|
? (() => {
|
||||||
loadAllHistory: (injected as TrajectoryViewInjected).loadAllHistory,
|
const trajectory = injected as TrajectoryViewInjected
|
||||||
useHistory: bindSnapshotSelector(
|
return {
|
||||||
(injected as TrajectoryViewInjected).hooks.history,
|
loadAllHistory: trajectory.loadAllHistory,
|
||||||
),
|
setActualDuration: trajectory.setActualDuration,
|
||||||
}
|
useHistory: bindSnapshotSelector(trajectory.hooks.history),
|
||||||
|
useDuration: bindSnapshotSelector(trajectory.hooks.duration),
|
||||||
|
}
|
||||||
|
})()
|
||||||
: injected
|
: injected
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -241,6 +255,24 @@ describe('plugin registration', () => {
|
|||||||
await b.fiber.dispose()
|
await b.fiber.dispose()
|
||||||
expect(tabsOf(b.slots).map(v => v.id)).toEqual(['chat'])
|
expect(tabsOf(b.slots).map(v => v.id)).toEqual(['chat'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shares one browser-wide duration preference across session injections', async () => {
|
||||||
|
const b = await bench()
|
||||||
|
const entry = b.slots.entries('conversation.view')
|
||||||
|
.find(candidate => candidate.options.id === 'trajectory')
|
||||||
|
expect(entry).toBeDefined()
|
||||||
|
const injectEntry = entry!.inject as unknown as (
|
||||||
|
sessionId: SessionId,
|
||||||
|
) => TrajectoryViewInjected
|
||||||
|
const first = injectEntry(SID)
|
||||||
|
const second = injectEntry('s2' as SessionId)
|
||||||
|
|
||||||
|
expect(second.hooks.duration).toBe(first.hooks.duration)
|
||||||
|
first.setActualDuration(true)
|
||||||
|
expect(second.hooks.duration.getSnapshot()).toBe(true)
|
||||||
|
expect(localStorage.getItem('dsh.trajectory.duration')).toBe('true')
|
||||||
|
expect(localStorage.getItem(`dsh.trajectory.duration.${SID}`)).toBeNull()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('tab switching in ConversationRoot', () => {
|
describe('tab switching in ConversationRoot', () => {
|
||||||
@@ -460,6 +492,12 @@ describe('tab switching in ConversationRoot', () => {
|
|||||||
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||||
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
|
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
|
||||||
expect(screen.getByText('No timing data')).toBeTruthy()
|
expect(screen.getByText('No timing data')).toBeTruthy()
|
||||||
|
expect(screen.getByRole<HTMLButtonElement>('button', {
|
||||||
|
name: 'Collapse turns',
|
||||||
|
}).disabled).toBe(false)
|
||||||
|
expect(screen.getByRole<HTMLButtonElement>('button', {
|
||||||
|
name: 'Collapse calls',
|
||||||
|
}).disabled).toBe(false)
|
||||||
expect(screen.queryByRole('row')).toBeNull()
|
expect(screen.queryByRole('row')).toBeNull()
|
||||||
expect(screen.queryByText(/turns ·/)).toBeNull()
|
expect(screen.queryByText(/turns ·/)).toBeNull()
|
||||||
})
|
})
|
||||||
@@ -490,6 +528,28 @@ describe('timeline projection', () => {
|
|||||||
}],
|
}],
|
||||||
}] satisfies readonly TrajectoryTurnModel[]
|
}] satisfies readonly TrajectoryTurnModel[]
|
||||||
|
|
||||||
|
it('cancels native scrolling across the timeline while zooming', () => {
|
||||||
|
render(
|
||||||
|
<TrajectoryTimeline
|
||||||
|
turns={longTurns}
|
||||||
|
mode="sequence"
|
||||||
|
range={null}
|
||||||
|
onRangeChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
|
||||||
|
vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
|
||||||
|
x: 44, y: 0, left: 44, top: 0, right: 144, bottom: 50, width: 100, height: 50,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(fireEvent.wheel(plot, { clientX: 94, deltaY: -100 })).toBe(false)
|
||||||
|
expect(fireEvent.wheel(screen.getByText('Input'), {
|
||||||
|
clientX: 20,
|
||||||
|
deltaY: -100,
|
||||||
|
})).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('pans the zoomed viewport only far enough to reveal a newly selected record', async () => {
|
it('pans the zoomed viewport only far enough to reveal a newly selected record', async () => {
|
||||||
const onRangeChange = vi.fn()
|
const onRangeChange = vi.fn()
|
||||||
const view = render(
|
const view = render(
|
||||||
@@ -542,7 +602,7 @@ describe('timeline projection', () => {
|
|||||||
|
|
||||||
it('auto-pans a zoomed viewport while a range drag pushes against an edge', () => {
|
it('auto-pans a zoomed viewport while a range drag pushes against an edge', () => {
|
||||||
const onRangeChange = vi.fn()
|
const onRangeChange = vi.fn()
|
||||||
render(
|
const view = render(
|
||||||
<TrajectoryTimeline
|
<TrajectoryTimeline
|
||||||
turns={longTurns}
|
turns={longTurns}
|
||||||
mode="sequence"
|
mode="sequence"
|
||||||
@@ -560,13 +620,26 @@ describe('timeline projection', () => {
|
|||||||
for (let index = 0; index < 24; index++) {
|
for (let index = 0; index < 24; index++) {
|
||||||
fireEvent.pointerMove(plot, { clientX: 99, pointerId: 1 })
|
fireEvent.pointerMove(plot, { clientX: 99, pointerId: 1 })
|
||||||
}
|
}
|
||||||
|
const draftSelection = view.container.querySelectorAll<HTMLElement>(
|
||||||
|
'[data-dragging="true"]',
|
||||||
|
)
|
||||||
|
expect(draftSelection).toHaveLength(2)
|
||||||
|
for (const overlay of draftSelection) {
|
||||||
|
expect(Number.parseFloat(
|
||||||
|
overlay.style.getPropertyValue('--trajectory-selection-left'),
|
||||||
|
)).toBeLessThan(0)
|
||||||
|
}
|
||||||
fireEvent.pointerUp(plot, { clientX: 99, pointerId: 1 })
|
fireEvent.pointerUp(plot, { clientX: 99, pointerId: 1 })
|
||||||
|
|
||||||
const selectedRange = onRangeChange.mock.calls.at(-1)?.[0] as
|
const selectedRange = onRangeChange.mock.calls.at(-1)?.[0] as
|
||||||
| { start: number; end: number }
|
| { start: number; end: number }
|
||||||
| undefined
|
| undefined
|
||||||
|
const fullRange = deriveTrajectoryTimeline(longTurns)
|
||||||
expect(selectedRange).toBeDefined()
|
expect(selectedRange).toBeDefined()
|
||||||
|
expect(fullRange).not.toBeNull()
|
||||||
expect((selectedRange?.end ?? 0) - (selectedRange?.start ?? 0)).toBeGreaterThan(4)
|
expect((selectedRange?.end ?? 0) - (selectedRange?.start ?? 0)).toBeGreaterThan(4)
|
||||||
|
expect(selectedRange?.start).toBeGreaterThanOrEqual(fullRange?.start ?? 0)
|
||||||
|
expect(selectedRange?.end).toBeLessThanOrEqual(fullRange?.end ?? 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses equal-width operation slots and stable semantic lanes', () => {
|
it('uses equal-width operation slots and stable semantic lanes', () => {
|
||||||
@@ -657,6 +730,53 @@ describe('timeline projection', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('compresses every idle gap in duration mode while actual mode retains wall time', () => {
|
||||||
|
const separatedTurns = [
|
||||||
|
{
|
||||||
|
turn: 1,
|
||||||
|
groups: [{
|
||||||
|
title: 'Step 1',
|
||||||
|
cells: [
|
||||||
|
{ index: 1, kind: 'message', text: 'first', startedAt: 1_000, timeSeconds: 1 },
|
||||||
|
{ index: 2, kind: 'tool', text: 'within-turn gap', startedAt: 4_000, timeSeconds: 1 },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
turn: 2,
|
||||||
|
groups: [{
|
||||||
|
title: 'Step 1',
|
||||||
|
cells: [
|
||||||
|
{ index: 3, kind: 'message', text: 'after user idle', startedAt: 40_000, timeSeconds: 1 },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
] satisfies readonly TrajectoryTurnModel[]
|
||||||
|
|
||||||
|
expect(deriveTrajectoryTimeline(separatedTurns, 'duration')).toMatchObject({
|
||||||
|
start: 1_000,
|
||||||
|
end: 4_000,
|
||||||
|
spans: [
|
||||||
|
{ index: 1, start: 1_000, end: 2_000 },
|
||||||
|
{ index: 2, start: 2_000, end: 3_000 },
|
||||||
|
{ index: 3, start: 3_000, end: 4_000 },
|
||||||
|
],
|
||||||
|
turnBoundaries: [
|
||||||
|
{ turn: 1, time: 1_000 },
|
||||||
|
{ turn: 2, time: 3_000 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
expect(deriveTrajectoryTimeline(separatedTurns, 'actual')).toMatchObject({
|
||||||
|
start: 1_000,
|
||||||
|
end: 41_000,
|
||||||
|
spans: [
|
||||||
|
{ index: 1, start: 1_000, end: 2_000 },
|
||||||
|
{ index: 2, start: 4_000, end: 5_000 },
|
||||||
|
{ index: 3, start: 40_000, end: 41_000 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('projects between-turn compaction without inventing a turn boundary', () => {
|
it('projects between-turn compaction without inventing a turn boundary', () => {
|
||||||
const withStandaloneCompaction = [
|
const withStandaloneCompaction = [
|
||||||
{
|
{
|
||||||
@@ -702,6 +822,7 @@ describe('timeline projection', () => {
|
|||||||
{
|
{
|
||||||
...standaloneProps([]),
|
...standaloneProps([]),
|
||||||
...standaloneHistory(historySnapshot([])),
|
...standaloneHistory(historySnapshot([])),
|
||||||
|
...standaloneDuration(),
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
|
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
|
||||||
@@ -710,6 +831,38 @@ describe('timeline projection', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('TrajectoryView branches', () => {
|
describe('TrajectoryView branches', () => {
|
||||||
|
it('persists the duration preference through the runtime snapshot-store seam', () => {
|
||||||
|
const firstDuration = createTrajectoryDurationStore()
|
||||||
|
const commonProps = {
|
||||||
|
...standaloneProps(NODES),
|
||||||
|
...standaloneHistory(historySnapshot(NODES)),
|
||||||
|
}
|
||||||
|
const first = render(
|
||||||
|
<TrajectoryView
|
||||||
|
{...commonProps}
|
||||||
|
useDuration={bindSnapshotSelector(firstDuration)}
|
||||||
|
setActualDuration={(value) => { firstDuration.set(value) }}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
const duration = screen.getByRole('button', { name: 'Use actual duration' })
|
||||||
|
|
||||||
|
expect(duration.getAttribute('aria-pressed')).toBe('false')
|
||||||
|
fireEvent.click(duration)
|
||||||
|
expect(localStorage.getItem('dsh.trajectory.duration')).toBe('true')
|
||||||
|
first.unmount()
|
||||||
|
|
||||||
|
const restoredDuration = createTrajectoryDurationStore()
|
||||||
|
render(
|
||||||
|
<TrajectoryView
|
||||||
|
{...commonProps}
|
||||||
|
useDuration={bindSnapshotSelector(restoredDuration)}
|
||||||
|
setActualDuration={(value) => { restoredDuration.set(value) }}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.getByRole('button', { name: 'Use actual duration' }).getAttribute('aria-pressed'))
|
||||||
|
.toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
it('renders only the selected rewind branch while retaining session-global requests', () => {
|
it('renders only the selected rewind branch while retaining session-global requests', () => {
|
||||||
const retained = {
|
const retained = {
|
||||||
kind: 'user',
|
kind: 'user',
|
||||||
@@ -765,6 +918,7 @@ describe('TrajectoryView branches', () => {
|
|||||||
const view = render(
|
const view = render(
|
||||||
<TrajectoryView
|
<TrajectoryView
|
||||||
{...standaloneProps([])}
|
{...standaloneProps([])}
|
||||||
|
{...standaloneDuration()}
|
||||||
useHistory={bindSnapshotSelector(store)}
|
useHistory={bindSnapshotSelector(store)}
|
||||||
loadAllHistory={vi.fn(() => Promise.resolve())}
|
loadAllHistory={vi.fn(() => Promise.resolve())}
|
||||||
/>,
|
/>,
|
||||||
@@ -807,6 +961,7 @@ describe('TrajectoryView branches', () => {
|
|||||||
render(
|
render(
|
||||||
<TrajectoryView
|
<TrajectoryView
|
||||||
{...standaloneProps([])}
|
{...standaloneProps([])}
|
||||||
|
{...standaloneDuration()}
|
||||||
useHistory={bindSnapshotSelector(store)}
|
useHistory={bindSnapshotSelector(store)}
|
||||||
loadAllHistory={vi.fn(() => Promise.resolve())}
|
loadAllHistory={vi.fn(() => Promise.resolve())}
|
||||||
/>,
|
/>,
|
||||||
|
|||||||
Reference in New Issue
Block a user