{ element.removeEventListener('focus', focus, true) }
}, [visible, floating, floats, pane.id, state.activePaneId, callbacks])
const lifted = floating && floats.preview?.paneId === pane.id ? floats.preview.rect : undefined
- const rect = floating ? lifted ?? floatRect(pane) : undefined
+ const rect = floating ? floats.rectOf(pane.id, floatRect(pane)) : undefined
const depth = lifted === undefined ? state.floats.indexOf(pane.id) + 1 : state.floats.length + 1
return (
{ if (floating) floats.raise(pane.id) }}
onClick={() => { if (!floating && state.activePaneId !== pane.id) callbacks.onFocusPane(pane.id) }}>
@@ -101,7 +100,7 @@ function TabHost({ state, callbacks, intents, tab, pane, column, floats, focusRe
: )}
- {visited && (retained || (active && selected)) ? callbacks.renderTab(tab) : null}
+ {visited && (retained || (active && selected)) ?
: null}
{!floating &&
}
{floating &&
{active === undefined
?
{callbacks.labels.emptyPane}
- : callbacks.renderTab(active)}
+ :
}
diff --git a/packages/client/ui-dockkit/src/components/dockkit.module.css b/packages/client/ui-dockkit/src/components/dockkit.module.css
index 9aec2d8faa..09a4691069 100644
--- a/packages/client/ui-dockkit/src/components/dockkit.module.css
+++ b/packages/client/ui-dockkit/src/components/dockkit.module.css
@@ -785,6 +785,15 @@
.float {
position: fixed;
+ /* A resting panel publishes its rectangle in these variables (`floatStyle`).
+ The clamp keeps its position and size inside the viewport and below the
+ caption floor even when the gesture hook read a stale viewport; `dvh`
+ follows `window.innerHeight` as the hook does. A gesture or return writes
+ left, top and the size inline instead. */
+ left: max(0px, min(var(--dsh-dockkit-float-x), 100vw - var(--dsh-dockkit-float-width)));
+ top: max(var(--dsh-dockkit-float-top, 0px), min(var(--dsh-dockkit-float-y), 100dvh - var(--dsh-dockkit-float-height)));
+ width: min(var(--dsh-dockkit-float-width), 100vw);
+ height: min(var(--dsh-dockkit-float-height), 100dvh - var(--dsh-dockkit-float-top, 0px));
display: flex;
flex-direction: column;
/* The body inside is unpadded and reaches the edges, so the frame clips it
diff --git a/packages/client/ui-dockkit/src/contract/adapter.ts b/packages/client/ui-dockkit/src/contract/adapter.ts
index 57001514e9..599eb01eab 100644
--- a/packages/client/ui-dockkit/src/contract/adapter.ts
+++ b/packages/client/ui-dockkit/src/contract/adapter.ts
@@ -45,7 +45,11 @@ export interface DockLabels {
/**
* Renders one tab's body. The embedder dispatches on `tab.kind`, which is the
- * only place that string carries meaning.
+ * only place that string carries meaning. As `renderTab`, the kit calls it
+ * again only when the renderer or the tab record is a new object and reuses the
+ * previous body across its own re-renders, such as a gesture's frames: the body
+ * must follow from `tab` and the values the renderer captures, and any other
+ * input reaches it through a new renderer or the body's own hooks and context.
*/
export type TabRenderer = (tab: TabRecord) => ReactNode
diff --git a/packages/client/ui-dockkit/src/engine/geometry.ts b/packages/client/ui-dockkit/src/engine/geometry.ts
index 973dd47be3..be6b7cbc75 100644
--- a/packages/client/ui-dockkit/src/engine/geometry.ts
+++ b/packages/client/ui-dockkit/src/engine/geometry.ts
@@ -206,6 +206,102 @@ export function resizedRect(rect: FloatRect, dx: number, dy: number, min: Size):
}
}
+/**
+ * The viewport region floating panels stay inside: `[0, width]` horizontally and
+ * `[top, height]` vertically, where `top` keeps the header clear of window chrome.
+ */
+export interface FloatBounds {
+ readonly top: number
+ readonly width: number
+ readonly height: number
+}
+
+/** How far past an edge the resistance lets a dragged panel travel, at most, in CSS pixels. */
+export const FLOAT_RESISTANCE = 120
+
+/** One axis clamped into `[min, max]`; an empty span pins it at `min`. */
+function clampAxis(value: number, min: number, max: number): number {
+ return Math.max(min, Math.min(value, max))
+}
+
+/**
+ * The nearest rectangle that lies inside the bounds, per axis. A panel larger
+ * than the bounds on an axis takes the bounds' size on it, so its header
+ * controls and resize corner stay reachable.
+ * @param rect - any rectangle, possibly outside the bounds.
+ * @param bounds - the viewport region.
+ * @returns the rectangle moved inside the bounds, its size capped by them.
+ */
+export function legalFloatRect(rect: FloatRect, bounds: FloatBounds): FloatRect {
+ const width = Math.min(rect.width, bounds.width)
+ const height = Math.min(rect.height, bounds.height - bounds.top)
+ return {
+ x: clampAxis(rect.x, 0, bounds.width - width),
+ y: clampAxis(rect.y, bounds.top, bounds.height - height),
+ width,
+ height,
+ }
+}
+
+/** Displacement past an edge for a pointer overflow: slope 1 at the edge, approaching `FLOAT_RESISTANCE`. */
+function resist(overflow: number): number {
+ return Math.sign(overflow) * FLOAT_RESISTANCE * Math.abs(overflow) / (FLOAT_RESISTANCE + Math.abs(overflow))
+}
+
+/**
+ * Where a dragged panel draws when the pointer has taken it past an edge: the
+ * legal rectangle plus a resisted share of the overflow on each axis.
+ * @param desired - the rectangle the pointer travel asks for.
+ * @param bounds - the viewport region.
+ * @returns the drawn rectangle at the legal size; equal to `desired` while it is legal.
+ */
+export function resistedRect(desired: FloatRect, bounds: FloatBounds): FloatRect {
+ const legal = legalFloatRect(desired, bounds)
+ return { ...legal, x: legal.x + resist(desired.x - legal.x), y: legal.y + resist(desired.y - legal.y) }
+}
+
+/** The largest displacement `unresistedRect` inverts; `resist` only approaches `FLOAT_RESISTANCE`. */
+const MAX_INVERTIBLE = FLOAT_RESISTANCE * 0.99
+
+/**
+ * Pointer overflow that `resist` maps to a displacement, the displacement capped
+ * at `MAX_INVERTIBLE`. A panel drawn further out than that is picked up at the
+ * cap, which moves it inward by less than 1.2 px.
+ */
+function unresist(displacement: number): number {
+ const shown = Math.min(Math.abs(displacement), MAX_INVERTIBLE)
+ return Math.sign(displacement) * FLOAT_RESISTANCE * shown / (FLOAT_RESISTANCE - shown)
+}
+
+/**
+ * The rectangle a drag must start from so that zero pointer travel draws
+ * `drawn`, the inverse of `resistedRect`: a press on a panel still outside the
+ * bounds picks it up where it is.
+ * @param drawn - where the panel is drawn now.
+ * @param bounds - the viewport region.
+ * @returns the start rectangle at the legal size; equal to `drawn` while it is legal.
+ */
+export function unresistedRect(drawn: FloatRect, bounds: FloatBounds): FloatRect {
+ const legal = legalFloatRect(drawn, bounds)
+ return { ...legal, x: legal.x + unresist(drawn.x - legal.x), y: legal.y + unresist(drawn.y - legal.y) }
+}
+
+/**
+ * A resized rectangle whose bottom-right corner stays inside the bounds, never
+ * below the minimum size.
+ * @param rect - the resized rectangle; its origin may lie outside the bounds.
+ * @param bounds - the viewport region.
+ * @param min - smallest size the panel may take.
+ * @returns the rectangle with its size capped by the bounds.
+ */
+export function boundedResize(rect: FloatRect, bounds: FloatBounds, min: Size): FloatRect {
+ return {
+ ...rect,
+ width: Math.max(min.width, Math.min(rect.width, bounds.width - rect.x)),
+ height: Math.max(min.height, Math.min(rect.height, bounds.height - rect.y)),
+ }
+}
+
/**
* Where a panel should appear when a tab is dropped outside the docked area.
* @param x - drop point x.
diff --git a/packages/client/ui-dockkit/src/engine/spring.ts b/packages/client/ui-dockkit/src/engine/spring.ts
new file mode 100644
index 0000000000..94f72dd70d
--- /dev/null
+++ b/packages/client/ui-dockkit/src/engine/spring.ts
@@ -0,0 +1,78 @@
+/**
+ * The spring a floating panel follows back inside the viewport after a release
+ * past an edge. Pure and clock-free: the caller supplies each frame's elapsed
+ * time, so trajectories are deterministic for a given sequence of frames.
+ */
+
+/** One axis of a spring: where it is and how fast it moves, in CSS pixels and pixels per second. */
+export interface SpringAxis {
+ readonly position: number
+ readonly velocity: number
+}
+
+/** Seconds the spring takes for one undamped period; sets its stiffness. */
+const RESPONSE = 0.32
+/** Below 1, so the return overshoots slightly inward (about 3.5%), which always stays legal. */
+const DAMPING_RATIO = 0.72
+const OMEGA = 2 * Math.PI / RESPONSE
+/** Fixed integration step, so the motion does not depend on the frame rate. */
+const SUBSTEP = 1 / 480
+/** Position and speed below which an axis counts as at rest. */
+const REST_DISTANCE = 1
+const REST_SPEED = 60
+
+/** Fastest release velocity a return starts with, in pixels per second. */
+export const SPRING_MAX_VELOCITY = 1500
+/** A return ends at its target after this long even if it is still moving. */
+export const SPRING_DEADLINE_MS = 900
+/** A frame longer than this is clamped, so a slow frame cannot inject a jump. */
+export const SPRING_MAX_FRAME_MS = 64
+/** A gap longer than this (sleep, a stalled tab) ends the return at its target instead of animating catch-up. */
+export const SPRING_STALL_MS = 250
+
+/**
+ * Advance one axis by an elapsed time, in equal substeps no longer than `SUBSTEP`
+ * (semi-implicit Euler).
+ * @param axis - the axis state.
+ * @param target - the position the spring pulls toward.
+ * @param dtSeconds - elapsed time, already clamped by the caller.
+ * @returns the advanced state.
+ */
+export function stepSpring(axis: SpringAxis, target: number, dtSeconds: number): SpringAxis {
+ const steps = Math.max(1, Math.ceil(dtSeconds / SUBSTEP))
+ const h = dtSeconds / steps
+ let { position, velocity } = axis
+ for (let step = 0; step < steps; step++) {
+ velocity += (-OMEGA * OMEGA * (position - target) - 2 * DAMPING_RATIO * OMEGA * velocity) * h
+ position += velocity * h
+ }
+ return { position, velocity }
+}
+
+/**
+ * The velocity an axis starts its return with. An axis already at its target
+ * keeps still. Outward speed is capped at `omega0 * (reach - |offset|)`: the
+ * spring carries a start offset `d` and speed `v` at most `d + 0.46 * v / omega0`
+ * from its target, so the whole return stays within `reach`.
+ * @param position - where the axis is drawn at release.
+ * @param target - the position the spring pulls toward.
+ * @param velocity - the drawn release velocity, in pixels per second.
+ * @param reach - the farthest the return may be from the target.
+ * @returns the starting velocity.
+ */
+export function launchVelocity(position: number, target: number, velocity: number, reach: number): number {
+ const offset = position - target
+ if (offset === 0) return 0
+ if (Math.sign(velocity) !== Math.sign(offset)) return velocity
+ return Math.sign(offset) * Math.min(Math.abs(velocity), OMEGA * Math.max(0, reach - Math.abs(offset)))
+}
+
+/**
+ * Whether an axis has come to rest at its target.
+ * @param axis - the axis state.
+ * @param target - the spring's target.
+ * @returns true within `REST_DISTANCE` of the target and slower than `REST_SPEED`.
+ */
+export function springSettled(axis: SpringAxis, target: number): boolean {
+ return Math.abs(axis.position - target) <= REST_DISTANCE && Math.abs(axis.velocity) <= REST_SPEED
+}
diff --git a/packages/client/ui-dockkit/tests/app-region-styles.client.spec.ts b/packages/client/ui-dockkit/tests/app-region-styles.client.spec.ts
index f0e1428afc..dbf89e9614 100644
--- a/packages/client/ui-dockkit/tests/app-region-styles.client.spec.ts
+++ b/packages/client/ui-dockkit/tests/app-region-styles.client.spec.ts
@@ -31,6 +31,16 @@ it('keeps Windows floating headers 20px below the caption, including restored po
expect(css).toMatch(/:global\(html\[data-windows-titlebar\]\[data-fullscreen\]\) \.float\s*\{\s*--dsh-dockkit-float-top: 20px;/)
})
+it('clamps a resting floating panel\'s position and size into the viewport and below the caption floor', () => {
+ const floatRule = /(?:^|\})\s*\.float\s*\{(?[^}]*)\}/.exec(css)?.groups?.['body']
+ expect(floatRule).toContain('left: max(0px, min(var(--dsh-dockkit-float-x), 100vw - var(--dsh-dockkit-float-width)));')
+ expect(floatRule).toContain(
+ 'top: max(var(--dsh-dockkit-float-top, 0px), min(var(--dsh-dockkit-float-y), 100dvh - var(--dsh-dockkit-float-height)));',
+ )
+ expect(floatRule).toContain('width: min(var(--dsh-dockkit-float-width), 100vw);')
+ expect(floatRule).toContain('height: min(var(--dsh-dockkit-float-height), 100dvh - var(--dsh-dockkit-float-top, 0px));')
+})
+
describe('macOS app-region ownership', () => {
it('subtracts the chip run, strip-end chrome, pane body, and divider, and declares no drag itself', () => {
// The strip row's drag is the markup mark's job: a sheet that declares it
diff --git a/packages/client/ui-dockkit/tests/components.client.spec.tsx b/packages/client/ui-dockkit/tests/components.client.spec.tsx
index 9a44d625f4..492f7478dd 100644
--- a/packages/client/ui-dockkit/tests/components.client.spec.tsx
+++ b/packages/client/ui-dockkit/tests/components.client.spec.tsx
@@ -1353,6 +1353,34 @@ describe('divider drags', () => {
expect(intents.placeTab).not.toHaveBeenCalled()
expect([first, second]).toHaveLength(2)
})
+
+ it('does not re-render the docked bodies while the divider preview redraws the panes', () => {
+ const controller = seededController()
+ controller.setExpanded(true)
+ controller.splitPane()
+ const [first, second] = dockPaneIds(controller.getSnapshot().state)
+ if (first === undefined || second === undefined) throw new Error('expected two docked panes')
+ layOut([first, second], 420)
+ const renderTab = vi.fn(() => null)
+ const surface = (renderer: typeof renderTab) => (
+
+ )
+ const view = render(surface(renderTab))
+ const rendered = renderTab.mock.calls.length
+ expect(rendered).toBeGreaterThan(0)
+ const divider = document.querySelector('[data-dockkit-divider]')
+ if (divider === null) throw new Error('expected a divider between the two panes')
+ const splitId = divider.getAttribute('data-dockkit-divider')?.split(':')[0]
+ drag(divider, [420, 300], [504, 300], false)
+ fireEvent.pointerMove(window, { pointerId: 7, clientX: 460, clientY: 300 })
+ expect(Number(document.querySelector
(`[data-dockkit-cell="${splitId}:0"]`)?.style.flexGrow)).toBeCloseTo(0.55)
+ expect(renderTab).toHaveBeenCalledTimes(rendered)
+ fireEvent.pointerUp(window, { pointerId: 7, clientX: 460, clientY: 300 })
+ // A new renderer is how an embedder refreshes its bodies.
+ const next = vi.fn(() => null)
+ view.rerender(surface(next))
+ expect(next).toHaveBeenCalledTimes(rendered)
+ })
})
describe('FloatLayer', () => {
@@ -1518,7 +1546,7 @@ describe('FloatLayer', () => {
drag(grip, [150, 90], [180, 110], false)
expect(panel.style.left).toBe('130px')
- expect(panel.style.top).toBe('max(var(--dsh-dockkit-float-top, 0px), 100px)')
+ expect(panel.style.top).toBe('100px')
// Mid-gesture the panel draws on top, as the move it records will leave it.
expect(panel.style.zIndex).toBe('3')
fireEvent.pointerUp(window, { pointerId: 7, clientX: 180, clientY: 110 })
@@ -1526,7 +1554,8 @@ describe('FloatLayer', () => {
expect(intents.moveFloat).toHaveBeenCalledTimes(1)
expect(intents.focusPane).not.toHaveBeenCalled()
// The recorded rectangle and depth are drawn again once the preview clears.
- expect(panel.style.left).toBe('100px')
+ expect(panel.style.left).toBe('')
+ expect(panel.style.getPropertyValue('--dsh-dockkit-float-x')).toBe('100px')
expect(panel.style.zIndex).toBe('1')
})
diff --git a/packages/client/ui-dockkit/tests/float-bounds.client.spec.ts b/packages/client/ui-dockkit/tests/float-bounds.client.spec.ts
new file mode 100644
index 0000000000..29875a872c
--- /dev/null
+++ b/packages/client/ui-dockkit/tests/float-bounds.client.spec.ts
@@ -0,0 +1,118 @@
+/**
+ * Floating-panel edge recovery geometry: the legal projection into the
+ * viewport, the drag resistance past an edge and its inverse, and the
+ * viewport-bounded resize.
+ */
+import { describe, expect, it } from 'vitest'
+import {
+ boundedResize, FLOAT_RESISTANCE, legalFloatRect, resistedRect, unresistedRect,
+} from '../src/engine/geometry.ts'
+import type { FloatBounds } from '../src/engine/geometry.ts'
+import { FLOAT_MIN_SIZE } from '../src/index.ts'
+
+const VIEWPORT: FloatBounds = { top: 0, width: 1000, height: 800 }
+const PANEL = { x: 100, y: 80, width: 300, height: 200 }
+
+describe('legalFloatRect', () => {
+ it('keeps a panel that is already inside, and returns a fresh rectangle of the same size', () => {
+ expect(legalFloatRect(PANEL, VIEWPORT)).toEqual(PANEL)
+ })
+
+ it.each([
+ { edge: 'left', rect: { ...PANEL, x: -50 }, expected: { x: 0, y: 80 } },
+ { edge: 'right', rect: { ...PANEL, x: 900 }, expected: { x: 700, y: 80 } },
+ { edge: 'top', rect: { ...PANEL, y: -10 }, expected: { x: 100, y: 0 } },
+ { edge: 'bottom', rect: { ...PANEL, y: 700 }, expected: { x: 100, y: 600 } },
+ { edge: 'corner', rect: { ...PANEL, x: 2000, y: -400 }, expected: { x: 700, y: 0 } },
+ ])('moves a panel past the $edge edge back by the overflow only', ({ rect, expected }) => {
+ expect(legalFloatRect(rect, VIEWPORT)).toEqual({ ...rect, ...expected })
+ })
+
+ it('honours the top floor that keeps the header below window chrome', () => {
+ expect(legalFloatRect({ ...PANEL, y: 10 }, { ...VIEWPORT, top: 60 }).y).toBe(60)
+ })
+
+ it('draws a panel larger than the viewport at the viewport size, so its header controls and corner stay reachable', () => {
+ const big = { x: 300, y: 300, width: 1200, height: 900 }
+ expect(legalFloatRect(big, { ...VIEWPORT, top: 20 })).toEqual({ x: 0, y: 20, width: 1000, height: 780 })
+ })
+
+ it('caps only the axis that does not fit', () => {
+ expect(legalFloatRect({ ...PANEL, x: 900, width: 1200 }, VIEWPORT)).toEqual({ ...PANEL, x: 0, width: 1000 })
+ })
+})
+
+describe('resistedRect', () => {
+ it('is the desired rectangle while it is legal', () => {
+ expect(resistedRect(PANEL, VIEWPORT)).toEqual(PANEL)
+ })
+
+ it.each([
+ { overflow: 10, shown: 9.2308 },
+ { overflow: 40, shown: 30 },
+ { overflow: 120, shown: 60 },
+ { overflow: 400, shown: 92.3077 },
+ ])('draws $overflow px of pointer overflow as $shown px past the edge', ({ overflow, shown }) => {
+ const right = resistedRect({ ...PANEL, x: 700 + overflow }, VIEWPORT)
+ expect(right.x - 700).toBeCloseTo(shown, 3)
+ const left = resistedRect({ ...PANEL, x: -overflow }, VIEWPORT)
+ expect(left.x).toBeCloseTo(-shown, 3)
+ })
+
+ it('starts with slope 1 at the edge, grows monotonically, and never reaches the resistance limit', () => {
+ let previous = 0
+ for (let overflow = 1; overflow <= 5000; overflow *= 2) {
+ const shown = resistedRect({ ...PANEL, y: 600 + overflow }, VIEWPORT).y - 600
+ expect(shown).toBeGreaterThan(previous)
+ expect(shown).toBeLessThan(FLOAT_RESISTANCE)
+ previous = shown
+ }
+ expect(resistedRect({ ...PANEL, y: 600.001 }, VIEWPORT).y - 600).toBeCloseTo(0.001, 6)
+ })
+
+ it('resists each axis independently, leaving a legal axis untouched', () => {
+ const shown = resistedRect({ ...PANEL, x: 820, y: 80 }, VIEWPORT)
+ expect(shown.y).toBe(80)
+ expect(shown.x).toBeCloseTo(760)
+ })
+
+ it('draws at the legal size when the desired rectangle no longer fits the viewport', () => {
+ const narrow = { ...VIEWPORT, width: 500 }
+ // 60 px of overflow past the legal x of 0 draws 40 px past it, at the 500 px the viewport allows.
+ expect(resistedRect({ ...PANEL, x: 60, width: 700 }, narrow)).toEqual({ ...PANEL, x: 40, width: 500 })
+ expect(unresistedRect({ ...PANEL, x: 0, width: 700 }, narrow)).toEqual({ ...PANEL, x: 0, width: 500 })
+ })
+})
+
+describe('unresistedRect', () => {
+ it('inverts the resistance, so a press on a drawn panel picks it up where it is', () => {
+ for (const desired of [{ ...PANEL, x: 760 }, { ...PANEL, x: -300, y: 950 }, PANEL]) {
+ const drawn = resistedRect(desired, VIEWPORT)
+ const start = unresistedRect(drawn, VIEWPORT)
+ expect(start.x).toBeCloseTo(desired.x, 6)
+ expect(start.y).toBeCloseTo(desired.y, 6)
+ }
+ })
+
+ it('caps a displacement at the resistance limit instead of producing an infinite overflow', () => {
+ const start = unresistedRect({ ...PANEL, x: 700 + FLOAT_RESISTANCE * 2 }, VIEWPORT)
+ expect(Number.isFinite(start.x)).toBe(true)
+ expect(start.x - 700).toBeCloseTo(FLOAT_RESISTANCE * 99, 6)
+ })
+})
+
+describe('boundedResize', () => {
+ it('keeps the bottom-right corner inside the viewport', () => {
+ expect(boundedResize({ ...PANEL, width: 2000, height: 2000 }, VIEWPORT, FLOAT_MIN_SIZE))
+ .toEqual({ ...PANEL, width: 900, height: 720 })
+ })
+
+ it('never shrinks a panel below the minimum size', () => {
+ expect(boundedResize({ x: 950, y: 780, width: 400, height: 400 }, VIEWPORT, FLOAT_MIN_SIZE))
+ .toEqual({ x: 950, y: 780, ...FLOAT_MIN_SIZE })
+ })
+
+ it('leaves a size that fits unchanged', () => {
+ expect(boundedResize(PANEL, VIEWPORT, FLOAT_MIN_SIZE)).toEqual(PANEL)
+ })
+})
diff --git a/packages/client/ui-dockkit/tests/float-recovery.client.spec.tsx b/packages/client/ui-dockkit/tests/float-recovery.client.spec.tsx
new file mode 100644
index 0000000000..f7cb177b85
--- /dev/null
+++ b/packages/client/ui-dockkit/tests/float-recovery.client.spec.tsx
@@ -0,0 +1,441 @@
+// @vitest-environment jsdom
+/**
+ * Floating-panel edge recovery through the rendered layer: resistance while a
+ * move is past an edge, one legal `moveFloat` per release, the spring back to
+ * it, cancellation, reduced motion, and the viewport projection. Frames and
+ * the clock are driven by hand so every trajectory is deterministic.
+ */
+import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'
+import { act, cleanup, fireEvent, render } from '@testing-library/react'
+import { FloatLayer } from '../src/components/FloatLayer.tsx'
+import type { DockIntents } from '../src/contract/adapter.ts'
+import type { FloatRect, PaneId } from '../src/contract/types.ts'
+import { floatRect, getPane } from '../src/engine/tree.ts'
+import { seededController, TEST_LABELS } from './fixtures.client.ts'
+
+let now = 0
+let queued = new Map()
+let nextFrame = 1
+
+beforeEach(() => {
+ now = 0
+ queued = new Map()
+ nextFrame = 1
+ vi.spyOn(performance, 'now').mockImplementation(() => now)
+ vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
+ const id = nextFrame++
+ queued.set(id, callback)
+ return id
+ })
+ vi.stubGlobal('cancelAnimationFrame', (id: number) => { queued.delete(id) })
+ viewport(1024, 768)
+})
+
+afterEach(() => {
+ cleanup()
+ vi.restoreAllMocks()
+ vi.unstubAllGlobals()
+})
+
+/** Set the window size the layer reads. */
+function viewport(width: number, height: number): void {
+ Object.defineProperty(window, 'innerWidth', { configurable: true, value: width })
+ Object.defineProperty(window, 'innerHeight', { configurable: true, value: height })
+}
+
+/** Advance the clock and run every frame callback that was waiting. */
+function frame(ms = 16): void {
+ now += ms
+ const due = [...queued.values()]
+ queued.clear()
+ act(() => { for (const callback of due) callback(now) })
+}
+
+/** Run frames until no callback is waiting; returns the elapsed time. */
+function runOut(): number {
+ const started = now
+ for (let guard = 0; queued.size > 0; guard++) {
+ if (guard > 200) throw new Error('animation never ended')
+ frame()
+ }
+ return now - started
+}
+
+/** One floating panel over a live controller whose recorded moves redraw the layer. */
+function mounted(rect: FloatRect = { x: 100, y: 80, width: 300, height: 200 }) {
+ const controller = seededController()
+ const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
+ const paneId = controller.floatTab(tabId, rect)
+ const renderTab = vi.fn(() => null)
+ const redraw = (): void => {
+ view.rerender()
+ }
+ const moveFloat = vi.fn((id: PaneId, x: number, y: number) => { controller.moveFloat(id, x, y); redraw() })
+ const resizeFloat = vi.fn((id: PaneId, next: FloatRect) => { controller.resizeFloat(id, next); redraw() })
+ const focusPane = vi.fn((id: PaneId) => { controller.focusPane(id); redraw() })
+ const intents: DockIntents = {
+ focusTab: vi.fn(), focusPane, splitPane: vi.fn(), addTab: vi.fn(), closeTab: vi.fn(), duplicateTab: vi.fn(),
+ floatTab: vi.fn(), unfloatPane: vi.fn(), placeTab: vi.fn(), dropTab: vi.fn(), moveFloat, resizeFloat,
+ resizeSplit: vi.fn(),
+ }
+ const view = render()
+ const panel = (): HTMLElement => view.container.querySelector(`[data-dockkit-float="${paneId}"]`) as HTMLElement
+ const grip = (): HTMLElement => panel().querySelector('[data-dockkit-float-grip]') as HTMLElement
+ // A lifted panel writes left and top inline; a resting one publishes them to the `.float` rule.
+ const drawn = (side: 'left' | 'top', axis: 'x' | 'y'): number =>
+ Number.parseFloat(panel().style.getPropertyValue(side) || panel().style.getPropertyValue(`--dsh-dockkit-float-${axis}`))
+ const left = (): number => drawn('left', 'x')
+ const top = (): number => drawn('top', 'y')
+ const stored = (): FloatRect => floatRect(getPane(controller.getSnapshot().state, paneId))
+ const corner = (): HTMLElement => panel().querySelector('[data-dockkit-float-resize]') as HTMLElement
+ return { controller, tabId, paneId, view, redraw, renderTab, intents, moveFloat, resizeFloat, focusPane, left, top, grip, corner, stored }
+}
+
+/** Press the grip at `from`, move to `to`, and optionally release there. */
+function drag(grip: HTMLElement, from: readonly [number, number], to: readonly [number, number], release = true): void {
+ fireEvent.pointerDown(grip, { clientX: from[0], clientY: from[1], pointerId: 7, button: 0 })
+ fireEvent.pointerMove(window, { pointerId: 7, clientX: to[0], clientY: to[1] })
+ if (release) fireEvent.pointerUp(window, { pointerId: 7, clientX: to[0], clientY: to[1] })
+}
+
+describe('dragging past an edge', () => {
+ it('draws the panel with resistance, records one legal move, and springs there', () => {
+ const h = mounted()
+ // The panel's legal x tops out at 1024 - 300 = 724; the pointer asks for 120 px beyond it.
+ drag(h.grip(), [150, 90], [150 + 744, 90], false)
+ expect(h.left()).toBeCloseTo(784)
+ fireEvent.pointerUp(window, { pointerId: 7, clientX: 894, clientY: 90 })
+ expect(h.moveFloat).toHaveBeenCalledTimes(1)
+ expect(h.moveFloat).toHaveBeenCalledWith(h.paneId, 724, 80)
+ expect(h.stored()).toMatchObject({ x: 724, y: 80 })
+ expect(h.left()).toBeCloseTo(784)
+
+ const trail: number[] = []
+ for (let guard = 0; queued.size > 0 && guard < 100; guard++) {
+ frame()
+ trail.push(h.left())
+ }
+ expect(queued.size).toBe(0)
+ expect(now).toBeLessThan(900)
+ expect(h.left()).toBe(724)
+ // The return overshoots inward by a few pixels, never back outside.
+ expect(Math.min(...trail)).toBeLessThan(724)
+ expect(Math.min(...trail)).toBeGreaterThan(724 - 120 * 0.06)
+ expect(h.top()).toBe(80)
+ })
+
+ it('does not re-render the body through the drag and the return, and re-renders it for a new renderer', () => {
+ const h = mounted()
+ const rendered = h.renderTab.mock.calls.length
+ drag(h.grip(), [150, 90], [150 + 744, 90])
+ runOut()
+ expect(h.left()).toBe(724)
+ // The release's moveFloat redraws the layer, but the tab record and the renderer stay the same.
+ expect(h.renderTab).toHaveBeenCalledTimes(rendered)
+ const next = vi.fn(() => null)
+ h.view.rerender()
+ expect(next).toHaveBeenCalledTimes(1)
+ })
+
+ it('undoes the whole gesture in one step', () => {
+ const h = mounted()
+ drag(h.grip(), [150, 90], [150 - 400, 90])
+ runOut()
+ expect(h.stored().x).toBe(0)
+ expect(h.controller.undo()).toBe(true)
+ expect(h.stored().x).toBe(100)
+ })
+
+ it('carries a fling: a fast release outward keeps travelling before it turns back', () => {
+ const fast = mounted()
+ fireEvent.pointerDown(fast.grip(), { clientX: 150, clientY: 90, pointerId: 7, button: 0 })
+ now += 50
+ fireEvent.pointerMove(window, { pointerId: 7, clientX: 850, clientY: 90 })
+ now += 20
+ fireEvent.pointerUp(window, { pointerId: 7, clientX: 894, clientY: 90 })
+ const released = fast.left()
+ frame()
+ expect(fast.left()).toBeGreaterThan(released)
+ runOut()
+ expect(fast.left()).toBe(724)
+ })
+
+ it('starts a fling at the drawn speed, so even the fastest one stays within the resistance limit', () => {
+ const h = mounted()
+ fireEvent.pointerDown(h.grip(), { clientX: 150, clientY: 90, pointerId: 7, button: 0 })
+ // A long push that ends in a fast flick: the samples older than the velocity window drop out.
+ for (let x = 150; x <= 850; x += 50) {
+ now += 50
+ fireEvent.pointerMove(window, { pointerId: 7, clientX: x, clientY: 90 })
+ }
+ now += 10
+ fireEvent.pointerMove(window, { pointerId: 7, clientX: 3000, clientY: 90 })
+ fireEvent.pointerUp(window, { pointerId: 7, clientX: 3000, clientY: 90 })
+ const trail = [h.left()]
+ for (let guard = 0; queued.size > 0 && guard < 100; guard++) {
+ frame()
+ trail.push(h.left())
+ }
+ expect(Math.max(...trail)).toBeLessThan(724 + 120)
+ expect(h.left()).toBe(724)
+ })
+
+ it('draws above the top edge while dragged and springs back below it', () => {
+ const h = mounted()
+ drag(h.grip(), [150, 90], [150, 90 - 200], false)
+ expect(h.top()).toBeLessThan(0)
+ fireEvent.pointerUp(window, { pointerId: 7, clientX: 150, clientY: -110 })
+ expect(h.moveFloat).toHaveBeenCalledWith(h.paneId, 100, 0)
+ runOut()
+ expect(h.top()).toBe(0)
+ })
+
+ it('springs straight back after a slow push, which has no release velocity', () => {
+ const h = mounted()
+ drag(h.grip(), [150, 90], [150 + 744, 90])
+ const released = h.left()
+ frame()
+ expect(h.left()).toBeLessThan(released)
+ })
+
+ it('leaves a release inside the viewport where it landed, with no animation', () => {
+ const h = mounted()
+ drag(h.grip(), [150, 90], [170, 110])
+ expect(h.moveFloat).toHaveBeenCalledWith(h.paneId, 120, 100)
+ expect(queued.size).toBe(0)
+ expect(h.left()).toBe(120)
+ })
+
+ it('records nothing for a push that ends where the panel already was, yet still springs back', () => {
+ const h = mounted({ x: 724, y: 80, width: 300, height: 200 })
+ drag(h.grip(), [800, 90], [900, 90])
+ expect(h.moveFloat).not.toHaveBeenCalled()
+ expect(queued.size).toBe(1)
+ runOut()
+ expect(h.left()).toBe(724)
+ })
+})
+
+describe('interrupting a return', () => {
+ it('stops on a new press and picks the panel up where it is drawn', () => {
+ const h = mounted()
+ drag(h.grip(), [150, 90], [150 + 744, 90])
+ frame()
+ frame()
+ const drawn = h.left()
+ expect(drawn).toBeGreaterThan(724)
+ fireEvent.pointerDown(h.grip(), { clientX: 800, clientY: 90, pointerId: 8, button: 0 })
+ expect(queued.size).toBe(0)
+ fireEvent.pointerMove(window, { pointerId: 8, clientX: 800, clientY: 90 })
+ expect(h.left()).toBeCloseTo(drawn, 6)
+ // Released in place, outside the viewport: no second move, and the spring resumes.
+ fireEvent.pointerUp(window, { pointerId: 8, clientX: 800, clientY: 90 })
+ expect(h.moveFloat).toHaveBeenCalledTimes(1)
+ runOut()
+ expect(h.left()).toBe(724)
+ })
+
+ it('ends at the target when the frames stall', () => {
+ const h = mounted()
+ drag(h.grip(), [150, 90], [150 + 744, 90])
+ frame(300)
+ expect(queued.size).toBe(0)
+ expect(h.left()).toBe(724)
+ })
+
+ it('ends at the target at the deadline even while still moving', () => {
+ const h = mounted()
+ drag(h.grip(), [150, 90], [150 + 744, 90])
+ // Each 200 ms frame advances the spring by the 64 ms frame cap, so it is still moving at 900 ms.
+ for (let elapsed = 0; elapsed < 800; elapsed += 200) frame(200)
+ expect(queued.size).toBe(1)
+ frame(200)
+ expect(queued.size).toBe(0)
+ expect(h.left()).toBe(724)
+ })
+
+ it('yields to an undo: the panel draws at the restored record at once', () => {
+ const h = mounted()
+ drag(h.grip(), [150, 90], [150 + 744, 90])
+ frame()
+ act(() => {
+ h.controller.undo()
+ h.redraw()
+ })
+ frame()
+ expect(queued.size).toBe(0)
+ expect(h.left()).toBe(100)
+ })
+
+ it('picks the panel up where it is drawn on a corner press, and a release in place resumes the return', () => {
+ const h = mounted({ x: 0, y: 80, width: 300, height: 200 })
+ drag(h.grip(), [150, 90], [150 - 400, 90])
+ frame()
+ const drawn = h.left()
+ expect(drawn).toBeLessThan(0)
+ fireEvent.pointerDown(h.corner(), { clientX: 250, clientY: 275, pointerId: 8, button: 0 })
+ expect(queued.size).toBe(0)
+ expect(h.left()).toBe(drawn)
+ fireEvent.pointerMove(window, { pointerId: 8, clientX: 250, clientY: 275 })
+ expect(h.left()).toBe(drawn)
+ fireEvent.pointerUp(window, { pointerId: 8, clientX: 250, clientY: 275 })
+ expect(h.left()).toBe(drawn)
+ expect(h.resizeFloat).not.toHaveBeenCalled()
+ runOut()
+ expect(h.left()).toBe(0)
+ })
+
+ it('resizes a panel caught during a return from where it is drawn, then springs it inside', () => {
+ const h = mounted({ x: 0, y: 80, width: 300, height: 200 })
+ drag(h.grip(), [150, 90], [150 - 400, 90])
+ frame()
+ const drawn = h.left()
+ fireEvent.pointerDown(h.corner(), { clientX: 250, clientY: 275, pointerId: 8, button: 0 })
+ fireEvent.pointerMove(window, { pointerId: 8, clientX: 270, clientY: 295 })
+ // The origin stays where it was drawn; only the size follows the corner.
+ expect(h.left()).toBe(drawn)
+ fireEvent.pointerUp(window, { pointerId: 8, clientX: 270, clientY: 295 })
+ expect(h.resizeFloat).toHaveBeenCalledWith(h.paneId, { x: 0, y: 80, width: 320, height: 220 })
+ expect(h.left()).toBe(drawn)
+ runOut()
+ expect(h.left()).toBe(0)
+ })
+
+ it('yields when its panel closes', () => {
+ const h = mounted()
+ drag(h.grip(), [150, 90], [150 + 744, 90])
+ act(() => {
+ h.controller.closeTab(h.tabId)
+ h.redraw()
+ })
+ frame()
+ expect(queued.size).toBe(0)
+ })
+
+ it('ends at its target when another panel is pressed', () => {
+ const h = mounted()
+ const other = h.controller.floatTab(
+ h.controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'file' }),
+ { x: 100, y: 400, width: 300, height: 200 },
+ )
+ act(() => { h.redraw() })
+ drag(h.grip(), [150, 90], [150 + 744, 90])
+ frame()
+ expect(h.left()).toBeGreaterThan(724)
+ const otherGrip = h.view.container.querySelector(`[data-dockkit-float="${other}"] [data-dockkit-float-grip]`) as HTMLElement
+ fireEvent.pointerDown(otherGrip, { clientX: 150, clientY: 410, pointerId: 8, button: 0 })
+ expect(queued.size).toBe(0)
+ expect(h.left()).toBe(724)
+ fireEvent.pointerUp(window, { pointerId: 8, clientX: 150, clientY: 410 })
+ })
+
+ it('cancels its frame when the layer unmounts', () => {
+ const h = mounted()
+ drag(h.grip(), [150, 90], [150 + 744, 90])
+ expect(queued.size).toBe(1)
+ h.view.unmount()
+ expect(queued.size).toBe(0)
+ })
+})
+
+describe('reduced motion', () => {
+ it('clamps at the edge with no resistance and never schedules a frame', () => {
+ vi.stubGlobal('matchMedia', (query: string) => ({ matches: query === '(prefers-reduced-motion: reduce)' }))
+ const h = mounted()
+ drag(h.grip(), [150, 90], [150 + 744, 90], false)
+ expect(h.left()).toBe(724)
+ fireEvent.pointerUp(window, { pointerId: 7, clientX: 894, clientY: 90 })
+ expect(h.moveFloat).toHaveBeenCalledWith(h.paneId, 724, 80)
+ expect(queued.size).toBe(0)
+ })
+
+ it('draws a resize at its legal rectangle, so a viewport that shrank past its origin animates nothing', () => {
+ vi.stubGlobal('matchMedia', (query: string) => ({ matches: query === '(prefers-reduced-motion: reduce)' }))
+ const h = mounted({ x: 700, y: 80, width: 300, height: 200 })
+ fireEvent.pointerDown(h.corner(), { clientX: 1000, clientY: 280, pointerId: 7, button: 0 })
+ viewport(500, 768)
+ act(() => { window.dispatchEvent(new Event('resize')) })
+ fireEvent.pointerMove(window, { pointerId: 7, clientX: 990, clientY: 280 })
+ expect(h.left()).toBe(280)
+ fireEvent.pointerUp(window, { pointerId: 7, clientX: 990, clientY: 280 })
+ expect(h.resizeFloat).toHaveBeenCalledWith(h.paneId, { x: 280, y: 80, width: 220, height: 200 })
+ expect(queued.size).toBe(0)
+ })
+
+ it('keeps the resistance when the platform states no preference', () => {
+ vi.stubGlobal('matchMedia', () => ({ matches: false }))
+ const h = mounted()
+ drag(h.grip(), [150, 90], [150 + 744, 90], false)
+ expect(h.left()).toBeCloseTo(784)
+ })
+})
+
+describe('the viewport projection', () => {
+ it('pushes a panel in when the window shrinks and returns it when the window grows, recording nothing', () => {
+ const h = mounted()
+ viewport(350, 768)
+ act(() => { window.dispatchEvent(new Event('resize')) })
+ expect(h.left()).toBe(50)
+ viewport(1024, 768)
+ act(() => { window.dispatchEvent(new Event('resize')) })
+ expect(h.left()).toBe(100)
+ expect(h.moveFloat).not.toHaveBeenCalled()
+ expect(h.focusPane).not.toHaveBeenCalled()
+ })
+
+ it('ends a running return at its recorded rectangle when the window resizes', () => {
+ const h = mounted()
+ drag(h.grip(), [150, 90], [150 + 744, 90])
+ frame()
+ act(() => { window.dispatchEvent(new Event('resize')) })
+ expect(queued.size).toBe(0)
+ expect(h.left()).toBe(724)
+ })
+
+ it('treats a click on a projected panel as a raise, keeping its record for a larger window', () => {
+ const h = mounted()
+ viewport(350, 768)
+ act(() => { window.dispatchEvent(new Event('resize')) })
+ expect(h.left()).toBe(50)
+ drag(h.grip(), [100, 90], [100, 90])
+ drag(h.corner(), [340, 270], [340, 270])
+ expect(h.moveFloat).not.toHaveBeenCalled()
+ expect(h.resizeFloat).not.toHaveBeenCalled()
+ viewport(1024, 768)
+ act(() => { window.dispatchEvent(new Event('resize')) })
+ expect(h.left()).toBe(100)
+ })
+
+ it('bounds a gesture by the viewport the window resized to mid-gesture', () => {
+ const h = mounted()
+ fireEvent.pointerDown(h.corner(), { clientX: 400, clientY: 280, pointerId: 7, button: 0 })
+ fireEvent.pointerMove(window, { pointerId: 7, clientX: 800, clientY: 280 })
+ viewport(500, 768)
+ act(() => { window.dispatchEvent(new Event('resize')) })
+ fireEvent.pointerUp(window, { pointerId: 7, clientX: 800, clientY: 280 })
+ expect(h.resizeFloat).toHaveBeenCalledWith(h.paneId, { x: 100, y: 80, width: 400, height: 200 })
+ })
+
+ it('keeps a resize origin inside a viewport that shrank past it mid-gesture', () => {
+ const h = mounted({ x: 700, y: 80, width: 300, height: 200 })
+ fireEvent.pointerDown(h.corner(), { clientX: 1000, clientY: 280, pointerId: 7, button: 0 })
+ fireEvent.pointerMove(window, { pointerId: 7, clientX: 990, clientY: 280 })
+ viewport(500, 768)
+ act(() => { window.dispatchEvent(new Event('resize')) })
+ fireEvent.pointerUp(window, { pointerId: 7, clientX: 990, clientY: 280 })
+ expect(h.resizeFloat).toHaveBeenCalledWith(h.paneId, { x: 280, y: 80, width: 220, height: 200 })
+ })
+
+ it('keeps the 20px floor on a Windows root that publishes no caption height', () => {
+ document.documentElement.setAttribute('data-windows-titlebar', '')
+ onTestFinished(() => { document.documentElement.removeAttribute('data-windows-titlebar') })
+ const h = mounted({ x: 100, y: 0, width: 300, height: 200 })
+ expect(h.top()).toBe(20)
+ })
+
+ it('draws a stored rectangle from outside the viewport inside it', () => {
+ const h = mounted({ x: 2000, y: 900, width: 300, height: 200 })
+ expect(h.left()).toBe(724)
+ expect(h.top()).toBe(568)
+ })
+})
diff --git a/packages/client/ui-dockkit/tests/spring.client.spec.ts b/packages/client/ui-dockkit/tests/spring.client.spec.ts
new file mode 100644
index 0000000000..5959a1fd27
--- /dev/null
+++ b/packages/client/ui-dockkit/tests/spring.client.spec.ts
@@ -0,0 +1,88 @@
+/**
+ * The edge-recovery spring: settling, the inward overshoot bound, frame-rate
+ * independence, and determinism, all with fixed frame steps.
+ */
+import { describe, expect, it } from 'vitest'
+import { launchVelocity, SPRING_DEADLINE_MS, SPRING_MAX_VELOCITY, springSettled, stepSpring } from '../src/engine/spring.ts'
+import type { SpringAxis } from '../src/engine/spring.ts'
+
+/** Analytic overshoot bound for the shipped damping ratio, exp(-pi*zeta/sqrt(1-zeta^2)). */
+const OVERSHOOT_BOUND = Math.exp(-Math.PI * 0.72 / Math.sqrt(1 - 0.72 ** 2))
+
+/** Run a return at a fixed frame rate until it rests; positions sampled once per frame. */
+function settle(start: SpringAxis, target: number, hz: number): { readonly ms: number; readonly positions: number[] } {
+ let axis = start
+ const positions: number[] = []
+ for (let frame = 1; frame <= 1000; frame++) {
+ axis = stepSpring(axis, target, 1 / hz)
+ positions.push(axis.position)
+ if (springSettled(axis, target)) return { ms: frame * 1000 / hz, positions }
+ }
+ throw new Error('spring did not settle')
+}
+
+describe('stepSpring', () => {
+ it('returns a 200 px overflow inside the deadline with an inward overshoot within the analytic bound', () => {
+ const { ms, positions } = settle({ position: -200, velocity: 0 }, 0, 60)
+ expect(ms).toBeLessThan(SPRING_DEADLINE_MS)
+ expect(ms).toBeCloseTo(366.7, 0)
+ const overshoot = Math.max(...positions) / 200
+ expect(overshoot).toBeGreaterThan(0)
+ expect(Math.abs(overshoot - OVERSHOOT_BOUND)).toBeLessThan(0.01)
+ })
+
+ it('moves the same way whatever the frame rate, because it integrates in fixed substeps', () => {
+ const reference = settle({ position: 120, velocity: 0 }, 0, 60).ms
+ for (const hz of [30, 120]) {
+ const { ms } = settle({ position: 120, velocity: 0 }, 0, hz)
+ expect(Math.abs(ms - reference)).toBeLessThanOrEqual(1000 / 30)
+ }
+ })
+
+ it('carries a release velocity: a fling outward travels further before it returns', () => {
+ const still = settle({ position: 60, velocity: 0 }, 0, 60).positions
+ const flung = settle({ position: 60, velocity: 1500 }, 0, 60).positions
+ expect(Math.max(...flung)).toBeGreaterThan(Math.max(...still))
+ })
+
+ it('is deterministic for the same inputs', () => {
+ expect(settle({ position: -90, velocity: 400 }, 10, 60)).toEqual(settle({ position: -90, velocity: 400 }, 10, 60))
+ })
+
+ it('stays put at its target', () => {
+ expect(stepSpring({ position: 5, velocity: 0 }, 5, 1 / 60)).toEqual({ position: 5, velocity: 0 })
+ })
+})
+
+describe('launchVelocity', () => {
+ it('keeps an axis that is already at its target still', () => {
+ expect(launchVelocity(40, 40, 900, 120)).toBe(0)
+ })
+
+ it('passes an inward velocity through unchanged', () => {
+ expect(launchVelocity(60, 0, -900, 120)).toBe(-900)
+ expect(launchVelocity(-60, 0, 900, 120)).toBe(900)
+ })
+
+ it('caps an outward velocity on either side, to nothing once the axis is at the reach', () => {
+ expect(launchVelocity(119, 0, 1500, 120)).toBeLessThan(1500)
+ expect(launchVelocity(-119, 0, -1500, 120)).toBeGreaterThan(-1500)
+ expect(launchVelocity(130, 0, 1500, 120)).toBe(0)
+ })
+
+ it('keeps every return within the reach of its target, however fast the fling', () => {
+ for (let offset = 1; offset < 120; offset += 1) {
+ const velocity = launchVelocity(offset, 0, SPRING_MAX_VELOCITY, 120)
+ const { positions } = settle({ position: offset, velocity }, 0, 60)
+ expect(Math.max(offset, ...positions)).toBeLessThan(120)
+ }
+ })
+})
+
+describe('springSettled', () => {
+ it('needs both closeness and slowness', () => {
+ expect(springSettled({ position: 0.5, velocity: 10 }, 0)).toBe(true)
+ expect(springSettled({ position: 2, velocity: 0 }, 0)).toBe(false)
+ expect(springSettled({ position: 0, velocity: 100 }, 0)).toBe(false)
+ })
+})
diff --git a/packages/client/ui-dockkit/tests/tab-layout.client.spec.tsx b/packages/client/ui-dockkit/tests/tab-layout.client.spec.tsx
index 7924776768..1bbc46f3cd 100644
--- a/packages/client/ui-dockkit/tests/tab-layout.client.spec.tsx
+++ b/packages/client/ui-dockkit/tests/tab-layout.client.spec.tsx
@@ -176,6 +176,30 @@ it('keeps body ancestors through pane moves, floating, raising and docking', ()
expect(body.closest('[data-dockkit-host]')).toBe(host)
})
+it('does not re-render a docked or floating body while a float gesture redraws the layout', () => {
+ const controller = seededController()
+ controller.setExpanded(true)
+ const tab = controller.openContent({ kind: 'file', contentId: 'file:a', title: 'A' })
+ controller.openContent({ kind: 'file', contentId: 'file:b', title: 'B' })
+ controller.floatTab(tab, { x: 100, y: 80, width: 300, height: 200 })
+ const h = mounted(controller, { keepMounted: () => true })
+ const renderTab = vi.fn(h.callbacks.renderTab)
+ h.redraw({ callbacks: { ...h.callbacks, renderTab } })
+ const rendered = renderTab.mock.calls.length
+ expect(rendered).toBeGreaterThan(0)
+ const floating = controller.getSnapshot().state.floats[0]!
+ fireEvent.pointerDown(h.node(`[data-dockkit-float="${floating}"] [data-dockkit-float-grip]`),
+ { pointerId: 3, button: 0, clientX: 150, clientY: 90 })
+ for (const x of [170, 190, 210]) fireEvent.pointerMove(window, { pointerId: 3, clientX: x, clientY: 90 })
+ expect(h.node(`[data-dockkit-float="${floating}"]`).style.left).toBe('160px')
+ expect(renderTab).toHaveBeenCalledTimes(rendered)
+ fireEvent.pointerUp(window, { pointerId: 3, clientX: 210, clientY: 90 })
+ // A new renderer is how an embedder refreshes its bodies.
+ const next = vi.fn(h.callbacks.renderTab)
+ h.redraw({ callbacks: { ...h.callbacks, renderTab: next } })
+ expect(next).toHaveBeenCalledTimes(rendered)
+})
+
it('unmounts unretained bodies and restores focus to their replacement strip', () => {
const controller = seededController()
controller.setExpanded(true)
diff --git a/packages/client/ui-goal/README.i18n.yaml b/packages/client/ui-goal/README.i18n.yaml
index 0d819456e1..815b11e50f 100644
--- a/packages/client/ui-goal/README.i18n.yaml
+++ b/packages/client/ui-goal/README.i18n.yaml
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-client-ui-goal/use-this-package:
- en: 4f6e6353d793a0de
- zh: cde6e470f7dd9057
+ en: 7f6620a84a087d07
+ zh: 80071126cc64b3e5
/deepseek-ai-dsh-client-ui-goal/use-this-package/the-command-input-bubble:
en: 05f0360b6c6dd3e9
zh: d14c3f15dc4cea5b
@@ -36,8 +36,8 @@
en: 68c9a2a057acb6cf
zh: baba87876e432666
/deepseek-ai-dsh-client-ui-goal/known-limitations-and-deferred-work:
- en: bbb97ea1b94eae0f
- zh: 8e6b0e0516746dcf
+ en: 7b69fe29ac31722e
+ zh: eef4e78fa12b4ff3
/deepseek-ai-dsh-client-ui-goal/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: 3bb0c20c93f16f98
diff --git a/packages/client/ui-goal/README.md b/packages/client/ui-goal/README.md
index ecdd666241..986e5c6811 100644
--- a/packages/client/ui-goal/README.md
+++ b/packages/client/ui-goal/README.md
@@ -25,7 +25,7 @@ The Web GUI goal surface shows both the durable goal state and its current proce
## Use this package
-Mount this plugin alongside `ui-conversation` and the goal domain package; the strip then appears as the second card in the composer-context stack (after Todo, before Queue) whenever the session has a goal. Todo and Goal use the same panel elevation above the composer. An armed active goal offers pause; an active-but-disarmed or paused goal offers resume; edit rewrites the objective in the ui-primitives `InlineEditor`, so multi-line objectives survive editing; clear removes the goal and suppresses the strip until the projection catches up.
+Mount this plugin alongside `ui-conversation` and the goal domain package; the strip then appears as the second card in the composer-context stack (after Todo, before Queue) whenever the session has a goal. Todo and Goal use the same panel elevation above the composer. An armed active goal offers pause; an active-but-disarmed, paused, or blocked goal offers resume; edit rewrites the objective in the ui-primitives `InlineEditor`, so multi-line objectives survive editing; clear removes the goal and suppresses the strip until the projection catches up.
### The command-input bubble
@@ -79,6 +79,7 @@ None unless the queued goal context is admitted. An admitted context extends the
These limits define the current goal surface. They are current package constraints, not a goal-domain comparison or a task backlog.
- **Preset-independent host state** — switching an active session to `minimal` leaves its host-owned goal intact. `/goal` and goal tools disappear, while this strip can still edit, pause, resume, or clear the goal.
+- **No round-budget control** — the strip cannot raise `maxGoalRounds`. Resuming a goal that used every round, such as one blocked by its round limit, fails with an inline error; ask the agent to raise the goal's round limit first.
### Dev Note
diff --git a/packages/client/ui-goal/README.zh.md b/packages/client/ui-goal/README.zh.md
index e3c8ef955e..36e47153d9 100644
--- a/packages/client/ui-goal/README.zh.md
+++ b/packages/client/ui-goal/README.zh.md
@@ -25,7 +25,7 @@ Web GUI 的 goal 界面同时显示持久 goal 状态及当前的进程本地激
## 使用本包
-与 `ui-conversation` 及 goal 领域包一起挂载本插件;只要会话存在目标,条带就会作为 composer 上下文堆栈的第二张卡片出现(位于 Todo 之后、Queue 之前)。Todo 与 Goal 使用相同的面板 elevation,使其位于 composer 层级之上。已 armed 的 active goal 提供暂停动作;active-but-disarmed 或 paused 的 goal 提供恢复;编辑在 ui-primitives 的 `InlineEditor` 中重写目标文本,多行目标编辑后仍保留换行;清除移除目标,并在投影追上之前抑制条带。
+与 `ui-conversation` 及 goal 领域包一起挂载本插件;只要会话存在目标,条带就会作为 composer 上下文堆栈的第二张卡片出现(位于 Todo 之后、Queue 之前)。Todo 与 Goal 使用相同的面板 elevation,使其位于 composer 层级之上。已 armed 的 active goal 提供暂停动作;active-but-disarmed、paused 或 blocked 的 goal 提供恢复;编辑在 ui-primitives 的 `InlineEditor` 中重写目标文本,多行目标编辑后仍保留换行;清除移除目标,并在投影追上之前抑制条带。
### 指令输入气泡
@@ -79,6 +79,7 @@ Web GUI 的 goal 界面同时显示持久 goal 状态及当前的进程本地激
这些限制界定了当前 goal 界面。它们是当前包约束,不是 goal 领域对比或任务积压。
- **Host 状态与 preset 无关**——把活跃会话切换到 `minimal` 后,Host 拥有的 goal 仍会保留。`/goal` 与 goal 工具会消失,但该条带仍可编辑、暂停、恢复或清除 goal。
+- **无轮次预算控制**——该条带无法提高 `maxGoalRounds`。恢复已用完全部轮次的 goal(例如因轮次上限而受阻的 goal)会失败,并显示行内错误;需先请 agent 提高该 goal 的轮次上限。
### 开发备注
diff --git a/packages/client/ui-goal/src/client/GoalBar.tsx b/packages/client/ui-goal/src/client/GoalBar.tsx
index a5358f39ac..120538c3c4 100644
--- a/packages/client/ui-goal/src/client/GoalBar.tsx
+++ b/packages/client/ui-goal/src/client/GoalBar.tsx
@@ -1,10 +1,10 @@
/**
* GoalBar: the goal indicator docked above the message composer (input dock
* strip). A present goal shows a goal glyph, a phase label, the truncated
- * objective, and icon actions — resume when active-disarmed or paused, edit
- * (inline form in the same strip), and clear. Goal creation lives on the
- * `/goal` command, not here: loading (undefined), no goal (null), and complete
- * goals render nothing. Durable state arrives as the projected whole snapshot;
+ * objective, and icon actions — resume when active-disarmed, paused, or
+ * blocked, edit (inline form in the same strip), and clear. Goal creation
+ * lives on the `/goal` command, not here: loading (undefined), no goal
+ * (null), and complete goals render nothing. Durable state arrives as the projected whole snapshot;
* process-local activation arrives through the injected activation hook.
*/
@@ -128,8 +128,7 @@ export function GoalBar({ goal, activation, onEdit, onPause, onResume, onClear,
const title = goal.phase === 'blocked' ? goal.blockedReason?.message : undefined
const label = goal.phase === 'active' ? activeLabel(activation, t) : t(PHASE_LABELS[goal.phase])
- const showResume = goal.phase === 'paused'
- || (goal.phase === 'active' && activation === 'disarmed')
+ const showResume = goal.phase !== 'active' || activation === 'disarmed'
return (
diff --git a/packages/client/ui-goal/tests/goalbar.client.spec.tsx b/packages/client/ui-goal/tests/goalbar.client.spec.tsx
index 2d018de60b..d14f2c854a 100644
--- a/packages/client/ui-goal/tests/goalbar.client.spec.tsx
+++ b/packages/client/ui-goal/tests/goalbar.client.spec.tsx
@@ -207,6 +207,14 @@ describe('GoalBar', () => {
expect(screen.getByText('受阻的目标').closest('[title]')?.getAttribute('title')).toBe('No progress in 3 rounds')
})
+ it('blocked goal: offers resume and no pause', () => {
+ const actions = makeActions()
+ render(
)
+ expect(screen.queryByRole('button', { name: '暂停目标' })).toBeNull()
+ fireEvent.click(screen.getByRole('button', { name: '恢复目标' }))
+ expect(actions.onResume).toHaveBeenCalledTimes(1)
+ })
+
it('blocked goal without a reason carries no tooltip', () => {
const actions = makeActions()
render(
)
diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml
index 1a7e784eda..5e2e05cf28 100644
--- a/packages/client/ui-tool/README.i18n.yaml
+++ b/packages/client/ui-tool/README.i18n.yaml
@@ -30,8 +30,8 @@
en: 672e63b2f92e689b
zh: ba950ecb480325a6
/deepseek-ai-dsh-client-ui-tool/understand-the-implementation/cards:
- en: d3cfe0abdce4718d
- zh: c2b5708e8a028226
+ en: 50c77d8a90aab9d1
+ zh: 0edf7a97867acb8d
/deepseek-ai-dsh-client-ui-tool/understand-the-implementation/declared-optional-capabilities:
en: c1926deb4e5f3734
zh: c5845f465b94a459
diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md
index 48ed13c53b..3854a5a92a 100644
--- a/packages/client/ui-tool/README.md
+++ b/packages/client/ui-tool/README.md
@@ -81,6 +81,8 @@ Tool Definitions own the [lazy argument view](../../util/values/README.md). Rows
Every card is read in place in the call tree; there is no second, full-height presentation of a selected call. Row renderers share one pure card model for each terminal, read, diff, search, and web card, and the image card's gallery renders through the tool-owned `tool.call.images` slot, followed by the full recorded path and each attached image's pixel size (with the original size when downscaled) and byte count. These models validate raw call arguments, result content, failure state, persisted metadata, PTC dispatch `parentCallId`, and Session path facts. Unsupported or malformed inputs use flattened Tool result text. A file-path summary opens the file through the owner's `openFile`, which the chat view routes to the right Sidebar's text preview; `inspect` opens the trajectory view and is absent when that View is unavailable; cards then omit Inspect. Card-specific limits and fallback rules for the terminal, diff, read, search, and web cards remain in [the ui-primitives README](../ui-primitives/README.md); the image card's model in this package carries its own fallback rules.
+Search cards treat an exact empty `include` as omitted; invalid types, whitespace-only values, negated filters, and comma-separated lists remain generic.
+
Chat diff cards keep nine rows before folding, enough for a file header, one removed/added pair, and three context lines on either side. The tool row shows the primitive's exact or coarse-replacement counts; the expanded card contains the diff body without a totals footer. Hovering the tool header colors its added count green and removed count red; both retain those colors while the card is expanded.
An Auto denial takes precedence over keyed specialized views. Its generic row preserves the call identity, omits raw arguments, and normalizes the stored reason only for display: trim surrounding whitespace and collapse line separators to spaces, with localized fallback for an empty result. Session and SDK error details keep the original reason.
diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md
index 8ac4a366c2..822caa6bee 100644
--- a/packages/client/ui-tool/README.zh.md
+++ b/packages/client/ui-tool/README.zh.md
@@ -81,6 +81,8 @@ Tool Definition 拥有[懒计算参数视图](../../util/values/README.zh.md)。
每张卡片都直接在调用树中查看;选中调用后不会再显示第二个全高视图。行 renderer 为 terminal、read、diff、search 和 web 卡片各复用同一个纯 card model,image 卡片的图库经由工具自有 `tool.call.images` slot 渲染,其下显示完整的记录路径以及每张附件图片的像素尺寸(缩小时附原图尺寸)和字节数。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata、PTC dispatch 的 `parentCallId` 与会话路径信息。不受支持或格式错误的输入使用压平的工具结果文本。文件路径摘要经属主的 `openFile` 打开文件,chat 视图把它路由到右侧 Sidebar 的文本预览;`inspect` 打开轨迹视图;该视图不可用时不提供此回调,卡片随之隐藏 Inspect。terminal、diff、read、search 与 web 卡片的上限与 fallback 规则仍由 [ui-primitives README](../ui-primitives/README.zh.md) 负责;image 卡片的 fallback 规则由本包内的 card model 自行承载。
+搜索卡片将值恰好为空字符串的 `include` 视为省略;类型错误、仅含空白的值、否定过滤器和逗号分隔列表仍使用通用显示。
+
Chat diff 卡片在折叠前保留九行,足以容纳文件标题、一对删除与新增行及两侧各三行上下文。工具行显示原语提供的精确或粗粒度替换统计;展开卡片包含差异正文,不显示底部统计。悬停工具标题行时,新增数量显示绿色、删除数量显示红色;卡片展开期间保留这两种颜色。
Auto 拒绝优先于按工具名选择的专门视图。其通用行保留调用身份、省略原始参数,并且只在显示时归一化存储的理由:去除首尾空白,把行分隔符折叠为空格,结果为空时使用本地化通用理由。Session 与 SDK 错误详情保留原始理由。
diff --git a/packages/client/ui-tool/src/client/tool/models/search-card-model.ts b/packages/client/ui-tool/src/client/tool/models/search-card-model.ts
index cc4d25b6b5..bf265b1996 100644
--- a/packages/client/ui-tool/src/client/tool/models/search-card-model.ts
+++ b/packages/client/ui-tool/src/client/tool/models/search-card-model.ts
@@ -30,7 +30,7 @@ function validSearchCall(block: ToolCallBlock): 'grep' | 'glob' | null {
if (path !== undefined && (typeof path !== 'string' || path.trim() === '')) return null
if (call.name === 'grep') {
const { include } = call.args
- if (include !== undefined && (typeof include !== 'string' || !validInclude(include))) return null
+ if (include !== undefined && include !== '' && (typeof include !== 'string' || !validInclude(include))) return null
}
return call.name
}
diff --git a/packages/client/ui-tool/tests/search-card.client.spec.tsx b/packages/client/ui-tool/tests/search-card.client.spec.tsx
index 9ba00cd18d..42766a2e9d 100644
--- a/packages/client/ui-tool/tests/search-card.client.spec.tsx
+++ b/packages/client/ui-tool/tests/search-card.client.spec.tsx
@@ -110,6 +110,21 @@ describe('searchCardModel', () => {
})
})
+ it('keeps the matches card when the recorded include is empty', () => {
+ const call = { name: 'grep', argsRaw: '{"pattern":"foo","path":"src","include":""}' }
+ expect(searchCardModel(settledGrep({ call }))).toEqual(searchCardModel(settledGrep()))
+ expect(searchCardModel(settledGrep({ call, isError: true }))).toBeNull()
+ })
+
+ it.each([null, false, 7, [], {}, ' ', '\t\n', '!*.ts', '*.ts,*.md'])(
+ 'keeps invalid include %j on the generic path',
+ (include) => {
+ expect(searchCardModel(settledGrep({
+ call: { name: 'grep', argsRaw: JSON.stringify({ pattern: 'foo', include }) },
+ }))).toBeNull()
+ },
+ )
+
it('derives a paths card from glob result metadata, carrying the truncation signal', () => {
// Empty block content isolates the truncation signal from the recovery arm.
expect(searchCardModel(settledGlob({ content: [], meta: pathsMeta({ truncated: true, total: 20 }) }))).toEqual({
@@ -196,9 +211,12 @@ describe('chat row search body (GenericToolCard fallback)', () => {
fireEvent.click(view.container.querySelector('[data-expandable]')!)
}
- it('the expanded body is the grouped matches, capped tighter than the panel', () => {
+ it.each([undefined, ''])('the expanded body groups matches when include is %j', (include) => {
expect(CHAT_SEARCH_MAX_LINES).toBeLessThan(16)
- const view = render(
)
+ const block = settledGrep({
+ call: { name: 'grep', argsRaw: JSON.stringify({ pattern: 'foo', path: 'src', ...include === undefined ? {} : { include } }) },
+ })
+ const view = render(
)
// Collapsed: the one-line summary row only, no card.
expect(view.queryByText(/const foo = 1/)).toBeNull()
toggleRow(view)
diff --git a/packages/client/ui-trajectory/tests/views.client.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx
index c753bd0889..5095175049 100644
--- a/packages/client/ui-trajectory/tests/views.client.spec.tsx
+++ b/packages/client/ui-trajectory/tests/views.client.spec.tsx
@@ -276,7 +276,9 @@ async function bench(snapshot = historySnapshot(NODES)) {
const conversationStore = createSnapshotStore
(conversationSnapshot(snapshot))
const uiConversation = new UiConversation(ctx, runtime.sessions)
const { events, views } = uiConversation
- const targetSources: ConversationTargetSources = {
+ // Only the targets this trajectory bench binds; a typed lookup keeps the map
+ // non-exhaustive so other packages' targets need no dependency here.
+ const targetSources: Partial = {
chat: createSnapshotStore(undefined),
trajectory: trajectoryStore,
'tool-todo-history': createSnapshotStore(undefined),
@@ -285,7 +287,11 @@ async function bench(snapshot = historySnapshot(NODES)) {
snapshot: conversationStore,
openTurn: createSnapshotStore(undefined),
activate: () => {},
- target: target => targetSources[target],
+ target: (target) => {
+ const source = targetSources[target]
+ if (source === undefined) throw new Error(`trajectory bench has no source for target "${target}"`)
+ return source
+ },
}
vi.spyOn(uiConversation, 'binding').mockReturnValue(binding)
// The conversation entry's role: declare the ring, then seed the chat entry.
diff --git a/packages/core/tool-executor-ptc/README.i18n.yaml b/packages/core/tool-executor-ptc/README.i18n.yaml
index 8b9724e5c1..a056d5ebf3 100644
--- a/packages/core/tool-executor-ptc/README.i18n.yaml
+++ b/packages/core/tool-executor-ptc/README.i18n.yaml
@@ -21,8 +21,8 @@
en: 9d4f61c351945d30
zh: 1c575526230b0e02
/deepseek-ai-dsh-tool-executor-ptc/understand-the-implementation/ptc-mode:
- en: 11d32e05ba397f86
- zh: 4e071ec4e6222d9d
+ en: a87a9af0deeddabe
+ zh: a4cef6f43a3b1422
/deepseek-ai-dsh-tool-executor-ptc/further-exploration:
en: ee69078c38c655a9
zh: fb0873407f23c8c5
@@ -33,8 +33,8 @@
en: eaefd7303f22977d
zh: 9c425f9501128224
/deepseek-ai-dsh-tool-executor-ptc/model-experience/ptc-mode-schema-and-system-prompt/what-the-model-sees:
- en: 4980bfab8310efc0
- zh: ef318d5b8ac7bf76
+ en: 52e1437dc292ad3a
+ zh: 43fcfb76a3715f51
/deepseek-ai-dsh-tool-executor-ptc/model-experience/ptc-mode-schema-and-system-prompt/what-the-model-sees/typescript-ptc-mode-sdk-instructions-with-bash:
en: 3a8df15e4ae32f9e
zh: fc4a800ba363e835
@@ -45,8 +45,8 @@
en: 78906628d634441f
zh: 492f133aea50cfe3
/deepseek-ai-dsh-tool-executor-ptc/known-limitations-and-deferred-work:
- en: 73836fc806368269
- zh: 943ecf50e19b3c69
+ en: 5030c17a9c6a2a85
+ zh: 6334bbe1a1d19cb4
/deepseek-ai-dsh-tool-executor-ptc/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: 3bb0c20c93f16f98
diff --git a/packages/core/tool-executor-ptc/README.md b/packages/core/tool-executor-ptc/README.md
index 5a1e781989..833a798623 100644
--- a/packages/core/tool-executor-ptc/README.md
+++ b/packages/core/tool-executor-ptc/README.md
@@ -59,7 +59,7 @@ New sub-calls use `:ptc:` ids. Consumers treat these ids as opaque an
Successful image-bearing subcall results become deferred user-message context with `source.kind` set to `ptc-mode`. Other additional contexts retain their producing tool's attribution.
-`run_code` accepts `timeoutMs` when the mounted runtime supports an override; its schema reports the configured default and maximum, and the runtime's usage instructions. The Node default is 120,000 ms with a 600,000 ms cap, including nested tool and approval waits. A wider `sandbox_permissions` mode requires a non-empty `justification` and approval before the program starts. The schema asks the model to write that reason in the current user's language. The grant applies to that complete execution; standing Session policy and nested tools retain their own authority. Programs are never replayed automatically: inspect earlier effects before explicitly retrying a denied program. Retained PTC environments follow the standing file policy. PTC observes committed Session mode changes and immediately cancels affected calls in both Node modes. Later PTC calls wait for their nested-tool cleanup before resolving the latest policy. The runtime owns process invalidation and cleanup; permission setters remain synchronous. One-time escalations omit the state owner and complete in a temporary environment; standing variables remain available to ordinary calls.
+`run_code` accepts `timeoutMs` when the mounted runtime supports an override; its schema reports the configured default and maximum, and the runtime's usage instructions. The Node default is 120,000 ms with a 600,000 ms cap, including nested tool and approval waits. A wider `sandbox_permissions` mode requires a non-empty `justification` and approval before the program starts. The schema asks the model to write that reason in the current user's language. The grant applies to that complete execution; standing Session policy and nested tools retain their own authority. Programs are never replayed automatically: inspect earlier effects before explicitly retrying a denied program. Retained PTC environments follow the standing file policy. PTC observes committed Session mode changes and immediately cancels affected calls in all Node modes. Later PTC calls wait for their nested-tool cleanup before resolving the latest policy. The runtime owns process invalidation and cleanup; permission setters remain synchronous. One-time escalations omit the state owner and complete in a temporary environment; standing variables remain available to ordinary calls.
- [`src/index.ts`](src/index.ts) — Validated configuration and scoped registration; tool descriptions and batch execution.
@@ -83,7 +83,7 @@ Successful image-bearing subcall results become deferred user-message context wi
#### What the model sees
-PTC mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-executor-ptc), the SDK instructions below, and the generated exact SDK block for the loaded runtime's language. Both runtime languages list `code` before `description`; this is presentation order, not an enforced JSON member order. The TypeScript instructions use a return-value example for function-body runtimes and a printed-output example for REPL runtimes. They identify generated declarations as program-only bindings. When the current `bash` parameter schema accepts the example arguments, they also show a complete `run_code` call around `tools.bash(...)`. The `tools:sdk` section uses first-party order 5000 and disables prompt-variable interpolation, preserving literal `{{…}}` text in tool descriptions and schemas for both runtime languages. Under `ptc`, the prompt also carries the `tools:ptc-only` rule earlier in the first-party order, so the model reads which tools it may call before it reads what each one is for.
+PTC mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-executor-ptc), the SDK instructions below, and the generated exact SDK block for the loaded runtime's language. Both runtime languages list `code` before `description`; this is presentation order, not an enforced JSON member order. REPL and Script runtimes share the generic tool description and SDK instructions; their `executionInstructions` supply syntax and state rules. The TypeScript bash example returns a value for function-body runtimes, and prints output for REPL and Script runtimes. The instructions identify generated declarations as program-only bindings. When the current `bash` parameter schema accepts the example arguments, they also show a complete `run_code` call around `tools.bash(...)`. The `tools:sdk` section uses first-party order 5000 and disables prompt-variable interpolation, preserving literal `{{…}}` text in tool descriptions and schemas for both runtime languages. Under `ptc`, the prompt also carries the `tools:ptc-only` rule earlier in the first-party order, so the model reads which tools it may call before it reads what each one is for.
##### TypeScript PTC mode SDK instructions with bash
@@ -120,7 +120,7 @@ These constraints apply to accepted calls.
- **PTC mode's SDK language follows the one loaded runtime, and a presentation is per agent rather than per tool** — PTC execution rejects prompt assembly unless `ctx.ptcRuntime.language` has a registered SDK renderer; within one agent no tool can be native-only while another is ptc-only.
- **PTC mode intermediate values are execution-local and unbounded by bytes** — they cannot be reconstructed from session replay and may exhaust process or worker memory; only the outer `run_code` output has the worker's configurable hard cap.
-- **State depends on the runtime**: the default backend starts fresh; a REPL backend retains memory per live Agent. Agent disposal awaits release. Restart and execution failures can clear memory; model-visible guidance explains how to recreate missing variables. Programs and results remain in the Session log, without serializing live objects.
+- **State depends on the runtime**: the default backend starts fresh; the Node REPL and Script mode with `scriptPersistence: true` retain memory per live Agent. Agent disposal awaits release. Restart and execution failures can clear memory; model-visible guidance explains how to recreate missing variables. Programs and results remain in the Session log, without serializing live objects.
### Dev Note
diff --git a/packages/core/tool-executor-ptc/README.zh.md b/packages/core/tool-executor-ptc/README.zh.md
index c2b0ad1356..c1b3f6cf20 100644
--- a/packages/core/tool-executor-ptc/README.zh.md
+++ b/packages/core/tool-executor-ptc/README.zh.md
@@ -59,7 +59,7 @@ PTC 执行器公开保留的 `run_code` 传输以及按所加载运行时语言
成功且包含图片的子调用结果会成为延后的 user-message 上下文,其 `source.kind` 为 `ptc-mode`。其他 additional context 保留其生产工具的归属。
-当已挂载运行时支持覆盖时,`run_code` 接受 `timeoutMs`;其 schema 报告配置的默认值和上限以及运行时使用说明。Node 默认值为 120,000 ms,上限为 600,000 ms,包含嵌套工具和审批等待。更宽的 `sandbox_permissions` 模式要求非空 `justification`,并在程序启动前获得审批。schema 提示模型使用当前用户的语言撰写理由。授权仅用于该次完整执行;常驻 Session 策略与嵌套工具保留各自权限。程序不会自动重放:显式重试被拒程序前,应检查先前已发生的效果。 保留状态的 PTC 环境遵循会话文件策略。PTC 观察已提交的 Session 模式变化,并立即取消两种 Node 模式下受影响的调用。后续 PTC 调用等待这些调用的嵌套工具清理后,再解析最新策略。运行时负责进程失效和清理,权限设置保持同步。单次提权省略状态所有者,在临时环境中执行;普通调用仍可访问原环境的变量。
+当已挂载运行时支持覆盖时,`run_code` 接受 `timeoutMs`;其 schema 报告配置的默认值和上限以及运行时使用说明。Node 默认值为 120,000 ms,上限为 600,000 ms,包含嵌套工具和审批等待。更宽的 `sandbox_permissions` 模式要求非空 `justification`,并在程序启动前获得审批。schema 提示模型使用当前用户的语言撰写理由。授权仅用于该次完整执行;常驻 Session 策略与嵌套工具保留各自权限。程序不会自动重放:显式重试被拒程序前,应检查先前已发生的效果。 保留状态的 PTC 环境遵循会话文件策略。PTC 观察已提交的 Session 模式变化,并立即取消所有 Node 模式下受影响的调用。后续 PTC 调用等待这些调用的嵌套工具清理后,再解析最新策略。运行时负责进程失效和清理,权限设置保持同步。单次提权省略状态所有者,在临时环境中执行;普通调用仍可访问原环境的变量。
- [`src/index.ts`](src/index.ts) — 配置校验和作用域注册;工具描述和批次执行。
@@ -83,7 +83,7 @@ PTC 执行器公开保留的 `run_code` 传输以及按所加载运行时语言
#### 模型看到什么
-PTC mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-executor-ptc)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块。两种运行时语言都将 `code` 列在 `description` 前;这是呈现顺序,不强制 JSON 成员顺序。TypeScript 说明对函数体运行时使用返回值示例,对 REPL 运行时使用打印输出示例,并把生成声明明确标为只能在程序内使用的绑定。当当前 `bash` 参数 schema 接受示例参数时,说明还会给出以 `run_code` 包住 `tools.bash(...)` 的完整调用。`tools:sdk` 段使用 first-party 顺序 5000,并关闭提示词变量插值,使两种运行时语言都原样保留工具描述和 schema 中的 `{{…}}` 文本。在 `ptc` 下,提示词还会带上处于更早 first-party 顺序的 `tools:ptc-only` 规则,让模型先读到「可以调用哪些工具」再读「每个工具做什么」。
+PTC mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-executor-ptc)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块。两种运行时语言都将 `code` 列在 `description` 前;这是呈现顺序,不强制 JSON 成员顺序。REPL 与 Script 运行时共用通用工具描述和 SDK 说明;语法与状态规则由各自的 `executionInstructions` 提供。TypeScript bash 示例对函数体运行时返回值,对 REPL 和 Script 运行时打印输出。说明将生成声明明确标为只能在程序内使用的绑定。当当前 `bash` 参数 schema 接受示例参数时,说明还会给出以 `run_code` 包住 `tools.bash(...)` 的完整调用。`tools:sdk` 段使用 first-party 顺序 5000,并关闭提示词变量插值,使两种运行时语言都原样保留工具描述和 schema 中的 `{{…}}` 文本。在 `ptc` 下,提示词还会带上处于更早 first-party 顺序的 `tools:ptc-only` 规则,让模型先读到「可以调用哪些工具」再读「每个工具做什么」。
##### 带 bash 的 TypeScript PTC mode SDK 说明
@@ -120,7 +120,7 @@ Program-only SDK bindings:
- **PTC mode 的 SDK 语言由当前加载的运行时决定,且呈现方式按 agent 而非按工具**:PTC 会拒绝组装提示词,除非 `ctx.ptcRuntime.language` 有已注册的 SDK 渲染器;同一个 agent 内不能让一个工具仅使用 Native,而另一个仅使用 PTC。
- **PTC mode 中间值只存在于执行局部,且没有字节上限**:它们无法从会话回放重建,并可能耗尽进程或 worker 内存;只有外层 `run_code` 输出受 worker 可配置的硬上限约束。
-- **状态由运行时决定**:默认后端每次重新开始;REPL 后端按存活 Agent 保留内存。Agent 销毁时等待环境释放。重启与执行失败可能清除内存;模型说明会指导重新创建缺失变量。程序与结果仍写入 Session 日志,不序列化运行中的对象。
+- **状态由运行时决定**:默认后端每次重新开始;Node REPL 和启用 `scriptPersistence: true` 的 Script 模式按存活 Agent 保留内存。Agent 销毁时等待环境释放。重启与执行失败可能清除内存;模型说明会指导重新创建缺失变量。程序与结果仍写入 Session 日志,不序列化运行中的对象。
### 开发备注
diff --git a/packages/core/tool-executor-ptc/src/index.ts b/packages/core/tool-executor-ptc/src/index.ts
index f098e280c3..3078577eb9 100644
--- a/packages/core/tool-executor-ptc/src/index.ts
+++ b/packages/core/tool-executor-ptc/src/index.ts
@@ -1,5 +1,6 @@
/** PTC execution plugin for the mounting Agent, preset, or deployment scope. @module */
import type { Context } from '@deepseek-ai/cordis'
+import type { PtcProgramFormat } from '@deepseek-ai/dsh-ptc-runtime'
import { RUN_CODE_NAME, ToolNotFoundError, projectToolSchema } from '@deepseek-ai/dsh-tools'
import type { ToolDefinition, ToolExecutor, ToolModelInput, ToolPromptSection, ToolBatchInput, ToolBatchResult } from '@deepseek-ai/dsh-tools'
import { ToolExecutionPipeline, executeToolBatch } from '@deepseek-ai/dsh-tools/pipeline'
@@ -13,7 +14,7 @@ const PTC_ONLY_INSTRUCTION = `\`${RUN_CODE_NAME}\` is the only tool you can call
const SDK_RENDERERS = {
typescript: renderToolsSdk,
python: renderToolsSdkPy,
-} satisfies Record string>
+} satisfies Record string>
/** Runs programs whose nested tool calls use the shared guarded invocation pipeline. */
export class PtcExecutor implements ToolExecutor {
diff --git a/packages/core/tool-executor-ptc/src/ts-types.ts b/packages/core/tool-executor-ptc/src/ts-types.ts
index 007c95c16d..8026c847d2 100644
--- a/packages/core/tool-executor-ptc/src/ts-types.ts
+++ b/packages/core/tool-executor-ptc/src/ts-types.ts
@@ -7,6 +7,7 @@
*/
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
+import type { PtcProgramFormat } from '@deepseek-ai/dsh-ptc-runtime'
import { assertSupportedJsonSchema } from '@deepseek-ai/dsh-tools'
import type { JsonSchemaNode, JsonSchemaScalar } from '@deepseek-ai/dsh-tools'
/** Internal PTC mode projection: the model-facing schema plus the canonical output schema. */
@@ -247,7 +248,7 @@ export function jsonSchemaToTs(schema: unknown, indent = 0): string {
}
/** Render the argument and call instructions for the selected execution syntax. */
-function renderSdkInstructions(programFormat: 'function-body' | 'repl'): string {
+function renderSdkInstructions(programFormat: PtcProgramFormat): string {
const programDescription = programFormat === 'function-body'
? 'the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped)'
: 'the program to execute'
@@ -257,7 +258,7 @@ function renderSdkInstructions(programFormat: 'function-body' | 'repl'): string
}
/** Render tool execution and output instructions for the selected execution syntax. */
-function renderSdkProgramInstructions(programFormat: 'function-body' | 'repl'): string {
+function renderSdkProgramInstructions(programFormat: PtcProgramFormat): string {
const outputInstruction = programFormat === 'function-body'
? 'Emit results with `return` and/or `console.log(...)`. Only what you print or return is program output.'
: 'Only values emitted as program output enter the conversation.'
@@ -279,7 +280,7 @@ function acceptsExampleString(schema: JsonSchemaNode | undefined, value: string)
}
/** Render the bash example only when its literal arguments satisfy the current parameter schema. */
-function renderBashExample(schemas: ToolSdkSchema[], programFormat: 'function-body' | 'repl'): string {
+function renderBashExample(schemas: ToolSdkSchema[], programFormat: PtcProgramFormat): string {
const bash = schemas.find(schema => schema.name === 'bash')
if (bash === undefined) return ''
const parameters = bash.parameters as JsonSchemaNode
@@ -306,10 +307,10 @@ function renderBashExample(schemas: ToolSdkSchema[], programFormat: 'function-bo
* by name, so the input never carries a duplicate.
* @param schemas - the tool schemas to declare (the caller excludes
* `run_code` itself).
- * @param programFormat - Runtime syntax; function bodies support returning a result, REPLs print it.
+ * @param programFormat - Runtime syntax selecting function-body, REPL, or Script instructions and examples.
* @returns the complete section text.
*/
-export function renderToolsSdk(schemas: readonly ToolSdkSchema[], programFormat: 'function-body' | 'repl' = 'function-body'): string {
+export function renderToolsSdk(schemas: readonly ToolSdkSchema[], programFormat: PtcProgramFormat = 'function-body'): string {
const sorted = [...schemas].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
const argsMembers: string[] = []
const outputMembers: string[] = []
diff --git a/packages/experimental/README.i18n.yaml b/packages/experimental/README.i18n.yaml
index 21f50eee7a..188724214d 100644
--- a/packages/experimental/README.i18n.yaml
+++ b/packages/experimental/README.i18n.yaml
@@ -18,8 +18,8 @@
en: b55aeb927737b90d
zh: 0b215fcb5c2c7908
/packages-experimental/packages:
- en: 036d034ac12d686c
- zh: c748a9230fefee86
+ en: e33fba56551dea3e
+ zh: 9a926b22d9994215
/packages-experimental/related-documentation:
en: 1cb9d3448d44e48d
zh: cd326d70a746b896
diff --git a/packages/experimental/README.md b/packages/experimental/README.md
index 4d89dd0faf..9a74c95bed 100644
--- a/packages/experimental/README.md
+++ b/packages/experimental/README.md
@@ -71,6 +71,7 @@ Promotion requires a named product role, documented public behavior and limitati
| [`agent-team-profile`](agent-team-profile/README.md) | Agent Teams collaboration, tools, and Web UI bundle | — |
| [`agent-team`](agent-team/README.md) | Named teammates with direct messages and a durable shared task board | `ctx.agentTeams` |
| [`client-ui-agent-team`](client-ui-agent-team/README.md) | Team roster, task board, and teammate navigation for Web | — |
+| [`client-ui-stats`](client-ui-stats/README.md) | Per-turn and per-step context, timing, cache, and tool cost for Web | — |
| [`auto-review`](auto-review/README.md) | Explicit Web layer for same-model review before each native or PTC inner tool call | — |
| [`subagent-oar`](subagent-oar/README.md) | Run standalone external coding-agent tasks through OAR | `ctx.subagents` |
| [`tool-subagent-oar`](tool-subagent-oar/README.md) | Select a configured external agent for each delegated task | `ctx.tools` |
diff --git a/packages/experimental/README.zh.md b/packages/experimental/README.zh.md
index d979466bd1..67e1093b3e 100644
--- a/packages/experimental/README.zh.md
+++ b/packages/experimental/README.zh.md
@@ -71,6 +71,7 @@ kind: "package-group"
| [`agent-team-profile`](agent-team-profile/README.zh.md) | Agent Teams 协作、工具与 Web UI 组合包 | — |
| [`agent-team`](agent-team/README.zh.md) | 具名 teammate,成员之间直接消息与持久共享任务板 | `ctx.agentTeams` |
| [`client-ui-agent-team`](client-ui-agent-team/README.zh.md) | Web Team roster、任务板与 teammate 导航 | — |
+| [`client-ui-stats`](client-ui-stats/README.zh.md) | Web 端按轮次与步骤展示上下文、耗时、缓存与工具成本 | — |
| [`auto-review`](auto-review/README.zh.md) | 显式 Web 层,在每个原生或 PTC inner 工具调用前使用同一模型审查 | — |
| [`subagent-oar`](subagent-oar/README.zh.md) | 通过 OAR 执行外部编程智能体的独立任务 | `ctx.subagents` |
| [`tool-subagent-oar`](tool-subagent-oar/README.zh.md) | 为每项委派任务选择配置的外部代理 | `ctx.tools` |
diff --git a/packages/experimental/agent-team-profile/docs/DETAIL.md b/packages/experimental/agent-team-profile/docs/DETAIL.md
index 62dd975f03..ec8b43f5c3 100644
--- a/packages/experimental/agent-team-profile/docs/DETAIL.md
+++ b/packages/experimental/agent-team-profile/docs/DETAIL.md
@@ -1,18 +1 @@

-
-Ask the agent you are talking to, the lead, to split a job. It creates named teammates, puts the work on a shared task board, waits for their results, and reports back.
-
-## How to use
-
-- Ask in plain words, for example: "Use agent teams to split this task between two teammates, wait, and summarize."
-- Click **Agent team** next to the conversation title to see **Members** and **Shared tasks**. Each task shows its status, **Owner**, **Write scopes**, and the tasks it is **Blocked by**.
-- Click a teammate to open its conversation and talk to it there.
-- The panel is read-only. To change tasks or teammates, ask the lead.
-
-## Good to know
-
-- Each teammate is a separate agent with its own model requests, so a team uses more tokens than one agent.
-- Teammates share the same files. Overlapping **Write scopes** only show a warning; nothing stops two teammates from editing the same file.
-- A team can create at most 8 teammates in total, including ones that failed to start, and hold at most 256 tasks; deleting a task frees a slot.
-- While the plugin is on, the ordinary subagent tools are off and agents delegate through the team; workflows still start one-shot agents. Turning the plugin off restores the subagent tools.
-- After turning it on with a conversation open, reload the page to see the panel.
diff --git a/packages/experimental/agent-team-profile/docs/DETAIL.zh.md b/packages/experimental/agent-team-profile/docs/DETAIL.zh.md
index 72daa21f27..62644f5b46 100644
--- a/packages/experimental/agent-team-profile/docs/DETAIL.zh.md
+++ b/packages/experimental/agent-team-profile/docs/DETAIL.zh.md
@@ -1,18 +1 @@

-
-让与你对话的智能体(即负责人)拆分一项工作。它会创建有名字的队友,把工作放到共享任务看板上,等待他们的结果,再向你汇报。
-
-## 使用方法
-
-- 用平常的话提出要求,例如:「用智能体团队把这个任务分给两名队友,等他们完成后汇总结果。」
-- 点击对话标题旁的 **智能体团队**,查看 **成员** 和 **共享任务**。每个任务显示状态、**Owner**、**写入范围**,以及它的 **依赖**。
-- 点击一名队友可打开它的对话,并在那里与它交谈。
-- 面板是只读的。如需修改任务或队友,请告诉负责人。
-
-## 注意事项
-
-- 每名队友都是独立的智能体,各自发起模型请求,因此团队比单个智能体消耗更多 token。
-- 队友们共用同一批文件。**写入范围** 重叠时只显示警告,不会阻止两名队友修改同一个文件。
-- 一个团队总共最多创建 8 名队友(包括启动失败的队友),最多保有 256 个任务;删除任务会空出一个名额。
-- 启用此插件后,普通的子智能体工具会关闭,智能体通过团队分派工作;工作流仍可启动一次性智能体。关闭此插件后,子智能体工具会恢复。
-- 如果在对话打开时启用此插件,请刷新页面以显示团队面板。
diff --git a/packages/experimental/agent-team-profile/locale/de.json b/packages/experimental/agent-team-profile/locale/de.json
index 07058805df..92e4d9ebc0 100644
--- a/packages/experimental/agent-team-profile/locale/de.json
+++ b/packages/experimental/agent-team-profile/locale/de.json
@@ -1,7 +1,7 @@
{
"meta": {
"title": "Agententeams",
- "description": "Aktivieren Sie Teamzusammenarbeit, Teamtools, die Mitgliederliste und das gemeinsame Aufgabenboard.",
+ "description": "Ermöglicht dem Agenten, mit dem Sie sprechen, eine Aufgabe auf benannte Teammitglieder aufzuteilen, sie auf einem gemeinsamen Aufgabenboard zu verfolgen und Ihnen mit deren Ergebnissen zu berichten.",
"detail": "docs/DETAIL.md"
}
}
diff --git a/packages/experimental/agent-team-profile/locale/en.json b/packages/experimental/agent-team-profile/locale/en.json
index 7086787e61..fa8fba7439 100644
--- a/packages/experimental/agent-team-profile/locale/en.json
+++ b/packages/experimental/agent-team-profile/locale/en.json
@@ -1,7 +1,7 @@
{
"meta": {
"title": "Agent teams",
- "description": "Enable team collaboration, team tools, the member roster, and the shared task board.",
+ "description": "Lets the agent you talk to split a job among named teammates, track it on a shared task board, and report back with their results.",
"detail": "docs/DETAIL.md"
}
}
diff --git a/packages/experimental/agent-team-profile/locale/es.json b/packages/experimental/agent-team-profile/locale/es.json
index b094b4371b..f543dcd1bd 100644
--- a/packages/experimental/agent-team-profile/locale/es.json
+++ b/packages/experimental/agent-team-profile/locale/es.json
@@ -1,7 +1,7 @@
{
"meta": {
"title": "Equipos de agentes",
- "description": "Habilite la colaboración en equipo, las herramientas del equipo, la lista de miembros y el tablero de tareas compartido.",
+ "description": "Permite que el agente con el que habla reparta un trabajo entre compañeros con nombre, lo siga en un tablero de tareas compartido y le informe con sus resultados.",
"detail": "docs/DETAIL.md"
}
}
diff --git a/packages/experimental/agent-team-profile/locale/fr.json b/packages/experimental/agent-team-profile/locale/fr.json
index a37e31344d..ff19f252e1 100644
--- a/packages/experimental/agent-team-profile/locale/fr.json
+++ b/packages/experimental/agent-team-profile/locale/fr.json
@@ -1,7 +1,7 @@
{
"meta": {
"title": "Équipes d'agents",
- "description": "Activez la collaboration en équipe, les outils d’équipe, la liste des membres et le tableau des tâches partagé.",
+ "description": "Permet à l'agent avec lequel vous parlez de répartir un travail entre des coéquipiers nommés, de le suivre sur un tableau de tâches partagé et de vous rendre compte de leurs résultats.",
"detail": "docs/DETAIL.md"
}
}
diff --git a/packages/experimental/agent-team-profile/locale/ja.json b/packages/experimental/agent-team-profile/locale/ja.json
index 68a53c025c..ef46f4be9e 100644
--- a/packages/experimental/agent-team-profile/locale/ja.json
+++ b/packages/experimental/agent-team-profile/locale/ja.json
@@ -1,7 +1,7 @@
{
"meta": {
"title": "エージェントチーム",
- "description": "チーム連携、チームツール、メンバー一覧、共有タスクボードを有効にします。",
+ "description": "対話しているエージェントが、仕事を名前付きのチームメイトに分担させ、共有タスクボードで進捗を追跡し、チームメイトの結果をまとめて報告します。",
"detail": "docs/DETAIL.md"
}
}
diff --git a/packages/experimental/agent-team-profile/locale/pt.json b/packages/experimental/agent-team-profile/locale/pt.json
index 12b0894600..38e917c00a 100644
--- a/packages/experimental/agent-team-profile/locale/pt.json
+++ b/packages/experimental/agent-team-profile/locale/pt.json
@@ -1,7 +1,7 @@
{
"meta": {
"title": "Equipes de agentes",
- "description": "Habilite a colaboração em equipe, as ferramentas da equipe, a lista de membros e o quadro de tarefas compartilhado.",
+ "description": "Permite que o agente com quem você conversa divida um trabalho entre colegas de equipe nomeados, acompanhe-o em um quadro de tarefas compartilhado e informe você com os resultados deles.",
"detail": "docs/DETAIL.md"
}
}
diff --git a/packages/experimental/agent-team-profile/locale/ru.json b/packages/experimental/agent-team-profile/locale/ru.json
index 828f0f7b3c..786c1ca82d 100644
--- a/packages/experimental/agent-team-profile/locale/ru.json
+++ b/packages/experimental/agent-team-profile/locale/ru.json
@@ -1,7 +1,7 @@
{
"meta": {
"title": "Команды агентов",
- "description": "Включите совместную работу в команде, инструменты группы, список участников и общую доску задач.",
+ "description": "Позволяет агенту, с которым вы общаетесь, распределить работу между именованными участниками команды, отслеживать её на общей доске задач и отчитываться перед вами об их результатах.",
"detail": "docs/DETAIL.md"
}
}
diff --git a/packages/experimental/agent-team-profile/locale/zh.json b/packages/experimental/agent-team-profile/locale/zh.json
index ba949c0409..1945f2d70d 100644
--- a/packages/experimental/agent-team-profile/locale/zh.json
+++ b/packages/experimental/agent-team-profile/locale/zh.json
@@ -1,7 +1,7 @@
{
"meta": {
"title": "智能体团队",
- "description": "启用团队协作、团队工具、成员列表和共享任务看板。",
+ "description": "让与你对话的智能体把工作分给有名字的队友,在共享任务看板上跟踪进度,并汇总队友的结果向你汇报。",
"detail": "docs/DETAIL.zh.md"
}
}
diff --git a/packages/experimental/auto-review/docs/DETAIL.md b/packages/experimental/auto-review/docs/DETAIL.md
index bb59b3fcd1..bf83e224e9 100644
--- a/packages/experimental/auto-review/docs/DETAIL.md
+++ b/packages/experimental/auto-review/docs/DETAIL.md
@@ -1,17 +1 @@

-
-The review checks each call against what you asked for. Allowed calls run at once with full access; denied calls wait for you.
-
-## How to use
-
-- In the message box, open the access mode menu, choose **Auto review**, and confirm with **Enable Auto review**, or type `/permission auto`. It applies to the current session only.
-- Name the action and its target. Ordinary project work is allowed. Deleting existing data, force pushes, production access, external writes, and security changes need your explicit request. Sending secrets or private data outside is always denied. Project files and tool results cannot grant permission.
-- For a denied call, **Waiting for approval** shows the reason. Choose **Reject** or **Allow once**. In an in-process subagent, a denial is final.
-
-## Good to know
-
-- Auto review is experimental. It can allow an unsafe action or deny useful work.
-- Allowed calls run without a sandbox, with full access to your computer.
-- In **PTC mode**, each tool call the program makes is reviewed, but not the program itself or its own file and process operations.
-- Each review is one extra model request that includes your messages, project instructions, earlier tool calls, and the pending call.
-- Turning this plugin off switches live Auto review sessions to **Full access**. A saved session left in Auto review opens only while the plugin is on.
diff --git a/packages/experimental/auto-review/docs/DETAIL.zh.md b/packages/experimental/auto-review/docs/DETAIL.zh.md
index 7be3574dbc..329fb83100 100644
--- a/packages/experimental/auto-review/docs/DETAIL.zh.md
+++ b/packages/experimental/auto-review/docs/DETAIL.zh.md
@@ -1,17 +1 @@

-
-审查会对照你的要求检查每次调用。获准的调用以完全权限立即运行;被拒绝的调用等你决定。
-
-## 使用方法
-
-- 在消息框中打开访问模式菜单,选择 **自动审查**,再点击 **启用自动审查** 确认;也可以输入 `/permission auto`。它只对当前会话生效。
-- 说明要做的操作和对象。普通的项目内工作会被允许。删除已有数据、强制推送、访问生产环境、向外部写入以及修改安全设置,都需要你明确提出。把密钥或隐私数据发往外部的操作一律拒绝。项目文件和工具结果不能授予权限。
-- 调用被拒绝时,**等待审批** 中会显示原因。选择 **拒绝** 或 **允许一次**。在进程内子智能体中,拒绝即为最终结果。
-
-## 注意事项
-
-- 自动审查是实验性功能,可能放行不安全的操作,也可能拒绝有用的工作。
-- 获准的调用在沙箱之外运行,对你的计算机拥有完全权限。
-- 在 **PTC 模式** 下,程序发起的每个工具调用都会被审查,但程序本身及其自身的文件和进程操作不会被审查。
-- 每次审查都是一次额外的模型请求,其中包含你的消息、项目指令、此前的工具调用以及待执行的调用。
-- 关闭此插件后,处于自动审查的活跃会话会切换为 **完全权限**。停留在自动审查的已保存会话只有在此插件开启时才能打开。
diff --git a/packages/experimental/auto-review/locale/de.json b/packages/experimental/auto-review/locale/de.json
index 457bddd8e4..5675c09b45 100644
--- a/packages/experimental/auto-review/locale/de.json
+++ b/packages/experimental/auto-review/locale/de.json
@@ -1,7 +1,7 @@
{
"meta": {
"title": "Automatische Autorisierungsüberprüfung",
- "description": "Fügen Sie einen Auto review-Berechtigungsmodus hinzu, der das Modell verwendet, um die Autorisierung vor jedem Toolaufruf zu bewerten.",
+ "description": "Fügt den Zugriffsmodus „Auto review“ hinzu: Vor jedem Tool-Aufruf prüft ein Modell den Aufruf anhand Ihrer Anforderung; zugelassene Aufrufe laufen sofort, abgelehnte Aufrufe warten auf Ihre Genehmigung.",
"detail": "docs/DETAIL.md"
}
}
diff --git a/packages/experimental/auto-review/locale/en.json b/packages/experimental/auto-review/locale/en.json
index c051b5bcfc..d27110b102 100644
--- a/packages/experimental/auto-review/locale/en.json
+++ b/packages/experimental/auto-review/locale/en.json
@@ -1,7 +1,7 @@
{
"meta": {
"title": "Auto authorization review",
- "description": "Add an Auto review permission mode that uses the model to assess authorization before each tool call.",
+ "description": "Adds an Auto review access mode: before each tool call, a model checks it against what you asked for, runs allowed calls at once, and holds denied calls for your approval.",
"detail": "docs/DETAIL.md"
}
}
diff --git a/packages/experimental/auto-review/locale/es.json b/packages/experimental/auto-review/locale/es.json
index 0ff73b015d..d95ff0715a 100644
--- a/packages/experimental/auto-review/locale/es.json
+++ b/packages/experimental/auto-review/locale/es.json
@@ -1,7 +1,7 @@
{
"meta": {
"title": "Revisión de autorización automática",
- "description": "Agregue un modo de permiso Auto review que utilice el modelo para evaluar la autorización antes de cada llamada a la herramienta.",
+ "description": "Añade el modo de acceso “Auto review”: antes de cada llamada a una herramienta, un modelo comprueba la llamada frente a lo que usted pidió; las llamadas permitidas se ejecutan de inmediato y las denegadas esperan su aprobación.",
"detail": "docs/DETAIL.md"
}
}
diff --git a/packages/experimental/auto-review/locale/fr.json b/packages/experimental/auto-review/locale/fr.json
index 533cd20bf0..dfcd84b5f4 100644
--- a/packages/experimental/auto-review/locale/fr.json
+++ b/packages/experimental/auto-review/locale/fr.json
@@ -1,7 +1,7 @@
{
"meta": {
"title": "Examen des autorisations automatiques",
- "description": "Ajoutez un mode d'autorisation Auto review qui utilise le modèle pour évaluer l'autorisation avant chaque appel d'outil.",
+ "description": "Ajoute le mode d'accès « Auto review » : avant chaque appel d'outil, un modèle vérifie l'appel par rapport à ce que vous avez demandé ; les appels autorisés s'exécutent immédiatement et les appels refusés attendent votre approbation.",
"detail": "docs/DETAIL.md"
}
}
diff --git a/packages/experimental/auto-review/locale/ja.json b/packages/experimental/auto-review/locale/ja.json
index cbe474b367..0bd4f27df3 100644
--- a/packages/experimental/auto-review/locale/ja.json
+++ b/packages/experimental/auto-review/locale/ja.json
@@ -1,7 +1,7 @@
{
"meta": {
"title": "自動認可レビュー",
- "description": "各ツール呼び出しの前にモデルが認可を判断する、自動認可レビュー権限モードを提供します。",
+ "description": "「自動認可レビュー」アクセスモードを追加します。各ツール呼び出しの前に、モデルがその呼び出しをあなたの依頼内容と照らし合わせて確認し、許可された呼び出しは即座に実行され、拒否された呼び出しはあなたの承認を待ちます。",
"detail": "docs/DETAIL.md"
}
}
diff --git a/packages/experimental/auto-review/locale/pt.json b/packages/experimental/auto-review/locale/pt.json
index 6dd7e9ee7d..2599e3ad76 100644
--- a/packages/experimental/auto-review/locale/pt.json
+++ b/packages/experimental/auto-review/locale/pt.json
@@ -1,7 +1,7 @@
{
"meta": {
"title": "Revisão de autorização automática",
- "description": "Adicione um modo de permissão Auto review que usa o modelo para avaliar a autorização antes de cada chamada de ferramenta.",
+ "description": "Adiciona o modo de acesso “Auto review”: antes de cada chamada de ferramenta, um modelo verifica a chamada em relação ao que você pediu; as chamadas permitidas são executadas imediatamente e as negadas aguardam sua aprovação.",
"detail": "docs/DETAIL.md"
}
}
diff --git a/packages/experimental/auto-review/locale/ru.json b/packages/experimental/auto-review/locale/ru.json
index ddb103d129..7ff2269613 100644
--- a/packages/experimental/auto-review/locale/ru.json
+++ b/packages/experimental/auto-review/locale/ru.json
@@ -1,7 +1,7 @@
{
"meta": {
"title": "Проверка автоматической авторизации",
- "description": "Добавьте режим разрешений Auto review, который использует модель для оценки авторизации перед каждым вызовом инструмента.",
+ "description": "Добавляет режим доступа «Auto review»: перед каждым вызовом инструмента модель сверяет вызов с вашей просьбой; разрешённые вызовы выполняются сразу, а отклонённые ждут вашего одобрения.",
"detail": "docs/DETAIL.md"
}
}
diff --git a/packages/experimental/auto-review/locale/zh.json b/packages/experimental/auto-review/locale/zh.json
index a976e70a5c..0371bf8599 100644
--- a/packages/experimental/auto-review/locale/zh.json
+++ b/packages/experimental/auto-review/locale/zh.json
@@ -1,7 +1,7 @@
{
"meta": {
"title": "自动授权审查",
- "description": "提供自动审查权限模式,由模型在每次工具调用前判断是否授权。",
+ "description": "提供“自动审查”访问模式:每次工具调用前,由模型对照你的要求检查,获准的调用立即运行,被拒绝的调用等你审批。",
"detail": "docs/DETAIL.zh.md"
}
}
diff --git a/packages/experimental/client-ui-stats/README.i18n.yaml b/packages/experimental/client-ui-stats/README.i18n.yaml
new file mode 100644
index 0000000000..3275928dc3
--- /dev/null
+++ b/packages/experimental/client-ui-stats/README.i18n.yaml
@@ -0,0 +1,43 @@
+# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# After editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write packages/experimental/client-ui-stats/README.md
+/:
+ en: db1b5b42664d338d
+ zh: 6985139d86d1b08f
+/deepseek-ai-dsh-experimental-client-ui-stats:
+ en: 5f8749a942af8c93
+ zh: 969e2b9f331bd83e
+/deepseek-ai-dsh-experimental-client-ui-stats/summary:
+ en: cd8563fb8d8a9a3f
+ zh: a5fd771cabccedd7
+/deepseek-ai-dsh-experimental-client-ui-stats/table-of-contents:
+ en: d152484eb41ac6b4
+ zh: 09388d293f9be9cb
+/deepseek-ai-dsh-experimental-client-ui-stats/use-this-package:
+ en: 0fde622d41dedcbf
+ zh: a5d6e96e732f81ce
+/deepseek-ai-dsh-experimental-client-ui-stats/use-this-package/when-to-choose-it:
+ en: 2fd311c521059d04
+ zh: c9a5373274039878
+/deepseek-ai-dsh-experimental-client-ui-stats/use-this-package/minimal-configuration:
+ en: e001601f2f9779b1
+ zh: 077d3703b8c82774
+/deepseek-ai-dsh-experimental-client-ui-stats/understand-the-implementation:
+ en: 7e560f4017eed7a8
+ zh: ea7d8b4c0f938dab
+/deepseek-ai-dsh-experimental-client-ui-stats/further-exploration:
+ en: 285cfe1d7a99af34
+ zh: fbcee76e0c2f20fb
+/deepseek-ai-dsh-experimental-client-ui-stats/model-experience:
+ en: 9cc467886fff1f2d
+ zh: 4b10d95f64769bad
+/deepseek-ai-dsh-experimental-client-ui-stats/model-experience/kv-cache-effect:
+ en: 7605bb868f93b4a8
+ zh: 184ca10ce6680499
+/deepseek-ai-dsh-experimental-client-ui-stats/known-limitations-and-deferred-work:
+ en: 952b20669aec9c80
+ zh: d44f0a126f111e9a
+/deepseek-ai-dsh-experimental-client-ui-stats/known-limitations-and-deferred-work/dev-note:
+ en: 3518dbaf30fb19a6
+ zh: 73565f0bcc32a364
diff --git a/packages/experimental/client-ui-stats/README.md b/packages/experimental/client-ui-stats/README.md
new file mode 100644
index 0000000000..757054075a
--- /dev/null
+++ b/packages/experimental/client-ui-stats/README.md
@@ -0,0 +1,121 @@
+---
+description: "Experimental conversation view showing per-turn and per-step context size, first-token latency, model versus tool time, cache hit rate, and output speed."
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-experimental-client-ui-stats
+
+English | [中文](README.zh.md)
+
+## Summary
+
+Adds a Statistics tab beside Chat and Trajectory. Turn rows expand into steps with context and output tokens, cache hit rate, first-token latency, model and tool time, output speed, and a total-time bar. Scrolling up pulls earlier turns through shared Session paging. The Trigger column names what opened the turn, including a team message's sender, and prompt images show a count pill with a hover preview in the shared viewer. The tab reads Session events and durable prompt images; it registers no tool, prompt section, or service.
+
+## Table of Contents
+
+- [Use this package](#use-this-package)
+- [Understand the implementation](#understand-the-implementation)
+- [Further Exploration](#further-exploration)
+- [Model Experience](#model-experience)
+- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
+- [Dev Note](#dev-note)
+
+-----
+
+
+## Use this package
+
+Mount it in a client composition that already provides `slots`, `sessions`, `uiConversation`, and `locale`. It contributes one entry to the `conversation.view` slot; no configuration fields exist.
+
+### When to choose it
+
+Choose it when you need to compare turns or steps by prompt length, cache reuse, and where wall time went. Choose `@deepseek-ai/dsh-client-ui-trajectory` when you need the full event ledger with request headers and tool payloads; the two panels read the same Session and can be enabled together.
+
+### Minimal configuration
+
+```yaml
+- id: ui-stats
+ name: '@deepseek-ai/dsh-experimental-client-ui-stats'
+```
+
+-----
+
+
+## Understand the implementation
+
+
+Implementation internals — click to expand
+
+Six Conversation Definitions fold the event window into nodes for one view target named `stats`. `stats-step` owns one context per `step/start`; `stats-prompt` owns one context per waking user message (human prompt or automated trigger); `stats-turn-end` records the turn boundary so an interrupted tail closes; `stats-code-mode` owns one context per Code-Mode root call for the inner dispatches its runner performed; `stats-compaction` owns one compaction transaction; `stats-event` owns the non-step request, prompt, retry, approval, and prune rows. Turn and step identity comes from the engine's match Location, never from module state, so history paging and multiple Sessions cannot cross-attribute events.
+
+| File | Responsibility |
+| --- | --- |
+| `src/client/stats-definition.ts` | The six Definitions, their match rules, publication cadence, and node envelopes |
+| `src/client/stats-fold.ts` | Event folding, tool pairing, Code-Mode dispatch attribution, and per-step metrics |
+| `src/client/stats-aggregate.ts` | Turn aggregation and ordering, the interrupted-tail close, and the Code-Mode merge into the owning step |
+| `src/client/stats-compaction.ts` | Compaction transaction state and its synthetic summary row |
+| `src/client/stats-event.ts` | Normalization of non-step event rows |
+| `src/client/stats-format.ts` | Display formatting for timestamps, durations, tokens, and percentages |
+| `src/client/stats-usage.ts` | Token-usage derivation shared by step, compaction, and totals rows |
+| `src/client/stats-totals.ts` | Whole-session totals from the host stats and token-usage projections |
+| `src/client/stats-snapshot-builder.ts` | The `stats` view target builder |
+| `src/client/StatsView.tsx` | Scrollport with scroll-to-top paging, table, totals and event rows, time bars, step/tool/answer details, copy controls, and the prompt image pill with its preview and viewer |
+| `src/client/locales.ts` | Every visible string in all eight shipped languages |
+
+The footer switches control the totals row, event rows, and the context basis, and the footer stays pinned above the composer while the table scrolls. `Show context delta` is off by default; when on, every turn and step shows its prompt-size change against the previous measured value in that scope, with negative deltas (for example after compaction) in the error colour. An expanded step row fills the Trigger column with its start timestamp (`MM-DD HH:MM:SS.mmm`; the tooltip carries the full `YYYY-MM-DD HH:MM:SS.mmm`) and any gap of at least one second since the previous step; sub-second scheduler noise is hidden. A step carrying a model answer is expandable: clicking its row opens a detail row that renders the answer as Markdown through the shared Chat primitive, capped at 16,000 characters with a truncation notice.
+
+`Context basis` changes only how a turn row aggregates its steps: `Latest` shows the turn's final prompt size, while `Cumulative` sums that turn's step prompt sizes. The totals row is not affected by this switch. When the host exposes the `sessionStats` and `tokenUsage` projections, the totals row covers the complete Session: turns, steps, cumulative context and output, token-weighted cache, first-token/model/tool time, and output speed. Total time there is model time + tool time because no whole-session wall projection exists. Without those projections the row falls back to the loaded window and is labelled `Window total`. Token cells switch from `K` to `M` at ten million.
+
+Non-step rows are hidden by default; the `Show event rows (incl. compaction)` footer switch enables them. Compaction is a log-only transaction, not a step. The table inserts a synthetic `Compaction` row between the steps it ran between; the row shows the summary call's context, output and cache-hit cells, the shadowed token count, the summary size, and its wall time. The same switch reveals request-header, route-context, system-prompt, tool-set, model-retry, model-attempt, context-refresh, approval, and model-free prune rows: a request header shows its reason, provider/model, series flag and tool count; a route context shows provider/model and the prompt-update mode; a system prompt shows append/replace/clear and size; a tool-set row lists added and removed tool names; a retry row shows the attempt number, wait, and failure message; an attempt row marks a model attempt that committed no message; a context-refresh row shows runtime/time context refresh size; an approval row shows the tool and waiting/allowed/rejected state; a prune row shows the pruned token count. These rows are excluded from the turn and totals step sums, so the step columns keep their step-only meaning.
+
+The turn cache-hit cell is token-weighted across the steps that reported usage (summed cache reads ÷ summed prompt tokens), so one transient cold step is not hidden by a warmer neighbour; the tooltip shows the summed read/input/write breakdown. A step row keeps its own measured share.
+
+Windows are bounded: when a step's `step/start` is outside the loaded window, its start-dependent columns show `—` with a `start unknown` marker rather than a guessed value. Scrolling to the top asks the Session for one older page; the view offsets the scroll position by the height it prepended, so the row being read stays in place.
+
+
+
+-----
+
+
+## Further Exploration
+
+- [`@deepseek-ai/dsh-client-ui-trajectory`](../../client/ui-trajectory/README.md) — the event ledger this panel borrows its table conventions from.
+- [`@deepseek-ai/dsh-session-stats`](../../session/session-stats/README.md) — the host-side session totals the footer vocabulary follows.
+- [`docs/subsystems/conversation.md`](../../../docs/subsystems/conversation.md) — how view targets and Definitions assemble.
+
+-----
+
+
+## Model Experience
+
+None, as the tab only reads Session events and renders them in the browser; it registers no tool, prompt section, or model input.
+
+#### KV Cache effect
+
+No effect. The package never constructs a model request and never mutates a prompt.
+
+## Known Limitations and Deferred Work
+
+
+
+- **Not mounted by the default product.** The Web bundle refuses experimental packages by rule; the optional Developer tools bundle (`@deepseek-ai/dsh-experimental-inspector-profile`) mounts this view, or a profile can add the row explicitly.
+- **Trimmed windows lose start times.** When the loaded window begins mid-step, first-token, model time, and total time cannot be derived; the row says `start unknown` instead of estimating.
+- **Code-Mode dispatches merge by root-call context.** Inner calls carry no turn or step, so each root call opens its own context keyed by the root call id, and the builder merges its dispatches into the step that owns the runner. A window that lost the dispatch start still merges the calls but cannot show their measured duration.
+- **Older-page retries are explicit.** A page request reports `grew`, `exhausted`, or `retry`; when Chat or Trajectory owns the Session's in-flight page, the Statistics trigger stays visible instead of marking history exhausted or throwing.
+- **Known limitation: no whole-session wall projection.** The totals row substitutes model time + tool time because the host projections expose no whole-session wall clock. The tool segment sums paired call durations, so parallel calls can make that split look longer than a non-overlapping wall interval. This is recorded here; the view does not change host logic to fill the gap.
+- **Recorded-session coverage is exempted for this experimental PR.** Package tests cover folding, aggregation, formatting, paging, and the jsdom table; `apps/web/tests/client-ui-stats-panel.e2e.ts` mounts the plugin through a test-only overlay over the shipped Web surface and compares a committed ARIA golden. By owner decision, this experimental PR does not add a keyless recorded-session scenario; a profile or bundle promotion must add one.
+- **Late re-deliveries are not turns.** Compaction can restate a pruned tool result under its original turn and step after that turn left the window; a turn with no loaded start, prompt, or end marker is dropped instead of appearing as an empty ghost row.
+- **Timing is derived, not sampled.** Total time is the last step's end minus the first step's start; queueing and scheduler waits land in the "rest" segment. The total-time bar draws tool calls as the union of their paired intervals, so parallel calls count once; the tool column keeps the summed per-call time. A turn bar is scaled against the turns currently visible on screen, so a much larger turn that scrolled away does not flatten the rows in view.
+- **Prompt images load through the shared image cache.** The pill and its preview resolve durable attachments with `uiConversation.imageUrl`, so the cache owns and revokes the browser URLs per Session binding; the view creates none of its own. A failed read shows a localized failure state with retry. Touch clients do not open the hover preview; tapping the pill loads the first image directly.
+
+
+### Dev Note
+
+
+Working context for maintainers — click to expand
+
+The panel was ported from a standalone client plugin. The table vocabulary follows `@deepseek-ai/dsh-session-stats` (model time, tool time, first token, output speed), while the layout follows the Trajectory table so the two tabs read alike.
+
+
+
+**Runtime invariant:** No companion is published. All state derives from the Session event window, so independent observation would restate the register/fold logic rather than assert a distinct relationship.
diff --git a/packages/experimental/client-ui-stats/README.zh.md b/packages/experimental/client-ui-stats/README.zh.md
new file mode 100644
index 0000000000..573c66dbac
--- /dev/null
+++ b/packages/experimental/client-ui-stats/README.zh.md
@@ -0,0 +1,121 @@
+---
+description: "实验性会话视图:按轮次与步骤展示上下文大小、首 token 延迟、模型与工具耗时、缓存命中率和输出速度。"
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-experimental-client-ui-stats
+
+[English](README.md) | 中文
+
+## 概述
+
+在「对话」和「轨迹」旁边加一个「统计」页签。每轮一行,展开后列出各步的上下文与输出词数、缓存命中率、首 token 延迟、模型与工具用时、输出速度和总用时条;滚到顶部通过共用 Session 分页加载更早轮次。「触发」列说明这一轮由什么触发,团队消息会写明发送方;带图提问显示张数标签,悬停预览、点击在共用看图器中打开。页签只读会话事件和提问里的持久图片,不注册工具、提示词段落或服务。
+
+## 目录
+
+- [使用本包](#use-this-package)
+- [理解实现](#understand-the-implementation)
+- [进一步探索](#further-exploration)
+- [模型体验](#model-experience)
+- [已知限制与延期工作](#known-limitations-and-deferred-work)
+- [开发备注](#dev-note)
+
+-----
+
+
+## 使用本包
+
+挂到已经提供 `slots`、`uiConversation`、`locale` 的客户端组合里。它向 `conversation.view` 槽贡献一个条目,没有配置字段。
+
+### 何时选择它
+
+需要按提示词长度、缓存复用情况和总时间去向比较轮次或步骤时选它。需要完整事件清单、请求头和工具正文时选 `@deepseek-ai/dsh-client-ui-trajectory`;两个页签读同一份会话,可以同时启用。
+
+### 最小配置
+
+```yaml
+- id: ui-stats
+ name: '@deepseek-ai/dsh-experimental-client-ui-stats'
+```
+
+-----
+
+
+## 理解实现
+
+
+实现细节——点击展开
+
+六个 Conversation Definition 把事件窗口折叠成名为 `stats` 的视图目标节点。`stats-step` 每个 `step/start` 一个上下文;`stats-prompt` 每条唤醒该轮的用户消息(人写提示或自动触发)一个上下文;`stats-turn-end` 记录轮次边界,让被中断的尾部也能收尾;`stats-code-mode` 每个 Code-Mode 根调用一个上下文,收纳它实际派发的内层调用;`stats-compaction` 每个压缩事务一个上下文;`stats-event` 负责请求、提示词、重试、审批和裁剪等非步骤事件行。轮次与步骤身份取自引擎给出的 match Location,而不是模块级状态,因此历史分页和多个 Session 都不会串归属。
+
+| 文件 | 职责 |
+| --- | --- |
+| `src/client/stats-definition.ts` | 六个 Definition:匹配规则、发布节奏与节点信封 |
+| `src/client/stats-fold.ts` | 事件折叠、工具配对、Code-Mode 内层调用归属、单步指标 |
+| `src/client/stats-aggregate.ts` | 轮次汇总与排序,含被中断尾部的收尾 |
+| `src/client/stats-compaction.ts` | 压缩事务状态与合成摘要行 |
+| `src/client/stats-event.ts` | 非步骤事件行的归一化 |
+| `src/client/stats-format.ts` | 时间、耗时、token、百分比的显示格式化 |
+| `src/client/stats-usage.ts` | 步骤、压缩与合计共用的 token 用量推导 |
+| `src/client/stats-totals.ts` | 从宿主 stats 与 tokenUsage projection 生成全量合计 |
+| `src/client/stats-snapshot-builder.ts` | `stats` 视图目标的增量 builder |
+| `src/client/StatsView.tsx` | 表格、合计/事件行、时间条、步骤/工具/回答详情、复制按钮,以及提问图片标签、预览与看图器 |
+| `src/client/locales.ts` | 全部可见文案,覆盖 8 种 shipped 语言 |
+
+页脚的开关控制合计行、事件行与上下文口径,滚表格时页脚钉在输入框上方。「显示上下文增量」默认关闭;打开后每轮/每步显示相对同范围内上一个已测值的提示词大小变化,负增量(例如压缩后)用错误色显示。展开的步骤行在「触发」列显示该步开始时间戳(`MM-DD HH:MM:SS.mmm`,tooltip 为完整的 `YYYY-MM-DD HH:MM:SS.mmm`),以及距上一步至少一秒的空闲;亚秒级调度噪声不显示。带模型回答的步骤也可以展开:点击该行会打开详情行,通过共享的 Chat Markdown 原语渲染回答,最多保留 16,000 字符并提示截断。
+
+「上下文口径」只改变轮次行对其各步的聚合方式:「最新」显示该轮最后一步的提示词大小,「累计」显示该轮各步提示词大小之和。合计行不受这个开关影响。宿主提供 `sessionStats` 与 `tokenUsage` projection 时,合计行覆盖整段会话:轮次、步数、累计上下文与输出、token 加权缓存、首 token/模型/工具用时和输出速度;总用时为模型用时 + 工具用时(暂无整段墙钟 projection)。projection 不可用时回退到已加载窗口,并标为「本窗口合计」。token 超过一千万时从 K 切换为 M。
+
+非步骤行默认隐藏,由页脚的「显示事件行(含压缩)」开关打开。压缩是 log-only 事务,不是 step。表格会在压缩发生的相邻步骤之间插入一条合成的「压缩」行,显示摘要调用的上下文、输出与缓存命中、被压缩的 token 数、摘要大小和墙钟时间。同一个开关还会显示请求头、路由能力、系统提示词、工具集变更、模型重试、模型尝试、上下文刷新、审批和纯裁剪行:请求头显示 reason、provider/model、是否新序列和工具数;路由能力显示 provider/model 和提示词更新模式;系统提示词显示追加/替换/清空和字符数;工具集行列出新增与移除;重试行显示第几次、等待时间和失败信息;尝试行标记未提交消息的模型尝试;上下文刷新行显示运行时/时间上下文的刷新大小;审批行显示工具和等待/允许/拒绝状态;裁剪行显示被裁剪的 token 数。这些行不计入轮次与合计的步骤求和,步骤列仍只表示步骤。
+
+轮次的缓存命中率按报了 usage 的各步做 token 加权(缓存读之和 ÷ 提示词 token 之和),不会因为相邻步命中高就把某一次冷启动盖掉;tooltip 展示汇总后的读/输入/写明细。步骤行仍显示该步自己的实测比例。
+
+会话窗口是有界的:某一步的 `step/start` 不在已加载窗口里时,与起点有关的列显示 `—` 并标「起止未知」,不估算数值。滚到顶部会向 Session 请求更早的一页;视图按新增内容的高度补偿滚动位置,正在读的行不会跳走。
+
+
+
+-----
+
+
+## 进一步探索
+
+- [`@deepseek-ai/dsh-client-ui-trajectory`](../../client/ui-trajectory/README.zh.md) —— 本页签的表格约定参考它。
+- [`@deepseek-ai/dsh-session-stats`](../../session/session-stats/README.zh.md) —— 列名口径与会话统计一致。
+- [`docs/subsystems/conversation.md`](../../../docs/subsystems/conversation.zh.md) —— 视图目标与 Definition 的装配方式。
+
+-----
+
+
+## 模型体验
+
+无直接影响,因为页签只读会话事件并在浏览器里渲染,不注册工具、提示词段落或模型输入。
+
+#### KV Cache 影响
+
+没有影响。本包不构造模型请求,也不修改提示词。
+
+## 已知限制与延期工作
+
+
+
+- **默认产品不挂载。** Web 包按规则拒绝引用 experimental 包;可选的「开发者工具」组合包(`@deepseek-ai/dsh-experimental-inspector-profile`)会挂载本视图,也可以由 profile 显式添加。
+- **窗口被截断时没有起始时刻。** 已加载窗口从半步中间开始时,首 token、模型用时和总用时都推导不出来;行上标「起止未知」,不估算。
+- **Code-Mode 内层调用按根调用上下文并回步骤。** 内层调用不带 turn/step,因此每个根调用各自开一个以 root call id 为键的上下文,由 builder 把它的派发并回持有 runner 的那一步。窗口里丢了派发起点时仍会合并调用,但无法给出它们各自的实测耗时。
+- **更早历史的加载结果显式区分重试。** 页面请求只返回 `grew`、`exhausted` 或 `retry`;如果 Chat 或轨迹视图正在加载同一个 Session 的页,统计视图会保留加载按钮,而不是误报历史已耗尽或抛出错误。
+- **已知限制:没有整段墙钟 projection。** 合计行的总用时用模型用时 + 工具用时替代,因为宿主 projection 没有整段墙钟;工具段是逐调用求和,并行调用会让这段显得比非重叠墙钟更长。这里作为已知限制记录,插件不为此改动宿主逻辑。
+- **本实验 PR 的 recorded-session 覆盖已获豁免。** 包测试覆盖折叠、聚合、格式化、分页与 jsdom 表格;`apps/web/tests/client-ui-stats-panel.e2e.ts` 通过仅测试用的 overlay 挂载 shipped Web 表面并比较已提交的 ARIA golden。经 owner 决定,本实验 PR 不新增 keyless recorded-session 场景;后续 profile 或 bundle 转正时必须补充。
+- **迟到重投不是轮次。** 压缩可能把被裁剪的工具结果按原来的轮次/步骤在之后重新投递;窗口里既没有加载到的起点、也没有问题或结束标记的轮次会被丢弃,不再显示成空的幽灵行。
+- **计时是推导值。** 总用时取最后一步结束减第一步开始;排队和调度等待落在「其余」段里。总用时条把工具调用画成配对区间之并,并行调用只算一次;工具列仍显示逐次求和。轮次时间条以当前屏幕内可见的轮次为刻度,滚出屏幕的超大轮次不会把本屏的行压扁。
+- **提问图片走共用图片缓存。** 标签与预览用 `uiConversation.imageUrl` 解析持久附件,浏览器 URL 由缓存按 Session binding 持有并释放,视图不自己创建 URL。读取失败时显示本地化失败状态并可重试。触屏不打开悬停预览,点击标签会直接加载第一张图。
+
+
+### 开发备注
+
+
+维护者的工作上下文——点击展开
+
+本页签从一份独立的客户端插件移植而来。列名口径参照 `@deepseek-ai/dsh-session-stats`(模型用时、工具用时、首 token、输出速度),版式参照轨迹表格,让两个页签读起来一致。
+
+
+
+**运行时不变式:** 不发布伴生入口。全部状态都从会话事件窗口推导,独立观察只会重复注册与折叠逻辑,而不会断言另一种关系。
diff --git a/packages/experimental/client-ui-stats/locale/de.json b/packages/experimental/client-ui-stats/locale/de.json
new file mode 100644
index 0000000000..c3c33fb2ea
--- /dev/null
+++ b/packages/experimental/client-ui-stats/locale/de.json
@@ -0,0 +1,6 @@
+{
+ "meta": {
+ "title": "Statistikansicht",
+ "description": "Kontext, Zeit, Cache und Tool-Kosten pro Turn und Schritt neben dem Chat anzeigen."
+ }
+}
diff --git a/packages/experimental/client-ui-stats/locale/en.json b/packages/experimental/client-ui-stats/locale/en.json
new file mode 100644
index 0000000000..967420992b
--- /dev/null
+++ b/packages/experimental/client-ui-stats/locale/en.json
@@ -0,0 +1,6 @@
+{
+ "meta": {
+ "title": "Statistics view",
+ "description": "Show per-turn and per-step context, timing, cache, and tool cost beside Chat."
+ }
+}
diff --git a/packages/experimental/client-ui-stats/locale/es.json b/packages/experimental/client-ui-stats/locale/es.json
new file mode 100644
index 0000000000..43464d96db
--- /dev/null
+++ b/packages/experimental/client-ui-stats/locale/es.json
@@ -0,0 +1,6 @@
+{
+ "meta": {
+ "title": "Vista de estadísticas",
+ "description": "Muestra contexto, tiempos, caché y coste de herramientas por turno y paso junto al chat."
+ }
+}
diff --git a/packages/experimental/client-ui-stats/locale/fr.json b/packages/experimental/client-ui-stats/locale/fr.json
new file mode 100644
index 0000000000..644a4755ac
--- /dev/null
+++ b/packages/experimental/client-ui-stats/locale/fr.json
@@ -0,0 +1,6 @@
+{
+ "meta": {
+ "title": "Vue statistiques",
+ "description": "Affiche le contexte, les durées, le cache et le coût des outils par tour et par étape à côté du chat."
+ }
+}
diff --git a/packages/experimental/client-ui-stats/locale/ja.json b/packages/experimental/client-ui-stats/locale/ja.json
new file mode 100644
index 0000000000..e6f5f2494d
--- /dev/null
+++ b/packages/experimental/client-ui-stats/locale/ja.json
@@ -0,0 +1,6 @@
+{
+ "meta": {
+ "title": "統計ビュー",
+ "description": "チャットの横にターン/ステップごとのコンテキスト、時間、キャッシュ、ツールコストを表示します。"
+ }
+}
diff --git a/packages/experimental/client-ui-stats/locale/pt.json b/packages/experimental/client-ui-stats/locale/pt.json
new file mode 100644
index 0000000000..35e68f9f14
--- /dev/null
+++ b/packages/experimental/client-ui-stats/locale/pt.json
@@ -0,0 +1,6 @@
+{
+ "meta": {
+ "title": "Visão de estatísticas",
+ "description": "Mostra contexto, tempos, cache e custo de ferramentas por turno e passo ao lado do chat."
+ }
+}
diff --git a/packages/experimental/client-ui-stats/locale/ru.json b/packages/experimental/client-ui-stats/locale/ru.json
new file mode 100644
index 0000000000..d6ccb13839
--- /dev/null
+++ b/packages/experimental/client-ui-stats/locale/ru.json
@@ -0,0 +1,6 @@
+{
+ "meta": {
+ "title": "Статистика",
+ "description": "Показывает контекст, время, кэш и стоимость инструментов по ходам и шагам рядом с чатом."
+ }
+}
diff --git a/packages/experimental/client-ui-stats/locale/zh.json b/packages/experimental/client-ui-stats/locale/zh.json
new file mode 100644
index 0000000000..886d02216e
--- /dev/null
+++ b/packages/experimental/client-ui-stats/locale/zh.json
@@ -0,0 +1,6 @@
+{
+ "meta": {
+ "title": "统计视图",
+ "description": "在对话旁展示每轮/每步的上下文、耗时、缓存和工具成本。"
+ }
+}
diff --git a/packages/experimental/client-ui-stats/package.json b/packages/experimental/client-ui-stats/package.json
new file mode 100644
index 0000000000..ea766eb1bb
--- /dev/null
+++ b/packages/experimental/client-ui-stats/package.json
@@ -0,0 +1,79 @@
+{
+ "name": "@deepseek-ai/dsh-experimental-client-ui-stats",
+ "description": "Per-turn and per-step context, timing, cache, and tool-cost table contributed as an experimental conversation View",
+ "version": "0.2.1-rc.3",
+ "publishConfig": {
+ "access": "public"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+ "directory": "packages/experimental/client-ui-stats"
+ },
+ "type": "module",
+ "main": "lib/index.js",
+ "types": "lib/types/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./lib/types/index.d.ts",
+ "default": "./lib/index.js"
+ },
+ "./client": {
+ "types": "./lib/types/client/index.d.ts",
+ "default": "./lib/client.js"
+ },
+ "./src/*": "./src/*",
+ "./package.json": "./package.json",
+ "./locale/*.json": "./locale/*.json"
+ },
+ "dsh": {
+ "client": {
+ "inject": [
+ "@deepseek-ai/dsh-api-session-controller",
+ "@deepseek-ai/dsh-client-locale",
+ "@deepseek-ai/dsh-client-ui-conversation",
+ "@deepseek-ai/dsh-client-ui-renderer"
+ ],
+ "platform": "web"
+ }
+ },
+ "scripts": {
+ "bundle": "tsdown",
+ "watch": "tsdown --watch"
+ },
+ "license": "MIT",
+ "peerDependencies": {
+ "@deepseek-ai/cordis": "workspace:~"
+ },
+ "devDependencies": {
+ "@deepseek-ai/cordis": "workspace:~",
+ "@deepseek-ai/dsh-api-session-controller": "workspace:*",
+ "@deepseek-ai/dsh-attachment": "workspace:*",
+ "@deepseek-ai/dsh-client-locale": "workspace:*",
+ "@deepseek-ai/dsh-client-store": "workspace:*",
+ "@deepseek-ai/dsh-client-test-runtime": "workspace:*",
+ "@deepseek-ai/dsh-client-ui-conversation": "workspace:*",
+ "@deepseek-ai/dsh-client-ui-primitives": "workspace:*",
+ "@deepseek-ai/dsh-client-ui-renderer": "workspace:*",
+ "@deepseek-ai/dsh-client-ui-session": "workspace:*",
+ "@deepseek-ai/dsh-client-ui-slots": "workspace:*",
+ "@deepseek-ai/dsh-llm": "workspace:*",
+ "@deepseek-ai/dsh-llm-retry": "workspace:*",
+ "@deepseek-ai/dsh-session": "workspace:*",
+ "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*",
+ "@deepseek-ai/dsh-tools": "workspace:*",
+ "@testing-library/react": "^16.1.0",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "@deepseek-ai/dsh-compaction": "workspace:*",
+ "@deepseek-ai/dsh-session-stats": "workspace:*",
+ "@deepseek-ai/dsh-token-meter": "workspace:*",
+ "@deepseek-ai/dsh-user-approval": "workspace:*"
+ },
+ "files": [
+ "locale/*.json",
+ "lib/index.js",
+ "lib/client.js",
+ "lib/types/**/*.d.ts"
+ ]
+}
diff --git a/packages/experimental/client-ui-stats/src/client/StatsTable.tsx b/packages/experimental/client-ui-stats/src/client/StatsTable.tsx
new file mode 100644
index 0000000000..39a9c292f5
--- /dev/null
+++ b/packages/experimental/client-ui-stats/src/client/StatsTable.tsx
@@ -0,0 +1,1908 @@
+/**
+ * Statistics table for one conversation: per-turn aggregates, expandable step
+ * rows, the time-composition bars, and the per-step tool-call accordion.
+ */
+
+import {
+ Fragment, useCallback, useEffect, useMemo, useState,
+ type ReactElement, type ReactNode,
+} from 'react'
+import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
+import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
+import { HoverCard, ImageLightbox, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
+import type {
+ StatsCall, StatsCompactionMetrics, StatsEventKind, StatsEventMetrics, StatsSnapshot, StatsStepMetrics,
+ LoadOlderResult, StatsToolCount, StatsTurnMetrics,
+} from './stats-contract.ts'
+import {
+ DASH, barShare, formatChars, formatClock, formatDateTime, formatDateTimeShort, formatDateTimeMs, formatMs,
+ formatPercent, formatStepClock,
+ formatTokens, formatTokensDelta, formatTokensShort, formatTps,
+} from './stats-format.ts'
+import { turnContext, type ContextMode, type StatsTotals } from './stats-aggregate.ts'
+import { isNumber } from './stats-usage.ts'
+import { CALL_LIMIT } from './stats-fold.ts'
+import type { StatsTranslate } from './locales.ts'
+import css from './stats.module.css'
+// Type-only: merges sessionStats and tokenUsage into SessionProjectionMap for useProjection.
+import type {} from '@deepseek-ai/dsh-session-stats/client'
+import type {} from '@deepseek-ai/dsh-token-meter/client'
+
+/** Steps one turn renders before the "show all" control appears. */
+const STEP_LIMIT = 24
+/** Distance from the scroll top, in pixels, that requests one older history page. */
+export const LOAD_OLDER_THRESHOLD = 64
+/** Ignore sub-second idles when showing the gap since the previous step. */
+const STEP_GAP_MIN_MS = 1_000
+/** How long the clipboard button reports its outcome, in milliseconds. */
+const COPY_FEEDBACK_MS = 1_200
+/** Lines one code block renders before the line-cap notice. */
+const CODE_LINE_LIMIT = 400
+/** Line count above which a diff falls back to whole-side removal and addition. */
+const DIFF_LINE_LIMIT = 300
+/** Hover dwell before the prompt image preview opens. */
+const IMAGE_PREVIEW_DELAY_MS = 200
+/** Numeric columns in table order. */
+export const COLUMNS = ['context', 'output', 'cache', 'ttft', 'model', 'tps', 'tool'] as const
+
+/** One numeric column name. */
+type NumericColumn = typeof COLUMNS[number]
+
+/** Column count of the scope, prompt, did, numeric, and time columns. */
+const TOTAL_COLUMNS = COLUMNS.length + 4
+
+/** Fixed palette indexes for tools that keep one tone across sessions. */
+const TOOL_TONES: Readonly> = {
+ read: 0,
+ glob: 1,
+ grep: 1,
+ edit: 2,
+ write: 2,
+ apply_patch: 2,
+ pwsh: 4,
+ shell: 4,
+ bash: 4,
+ web_fetch: 5,
+ web_search: 5,
+ ask_user_question: 3,
+ todo_write: 7,
+ subagent: 5,
+ subagent_fork: 5,
+}
+
+/** Tone classes, one per palette index; each sets `--stats-tool-color`. */
+const TONE_CLASSES: readonly (string | undefined)[] = [
+ css.tone0, css.tone1, css.tone2, css.tone3,
+ css.tone4, css.tone5, css.tone6, css.tone7,
+]
+
+/**
+ * Join CSS module classes into one `className`, dropping absent entries: the
+ * module's index-signature declaration types every lookup as possibly absent
+ * under `noUncheckedIndexedAccess`.
+ * @param names - class names in declaration order.
+ * @returns the space-joined class list.
+ */
+function cx(...names: readonly (string | undefined)[]): string {
+ return names.filter((name): name is string => name !== undefined).join(' ')
+}
+
+/** Business face injected by the browser plugin. */
+export interface StatsViewInjected {
+ /** Snapshot sources bound by InjectFace. */
+ hooks: {
+ /** This target's snapshot; InjectFace exposes it as `useStats`. */
+ stats: ObservableSnapshot
+ }
+ /**
+ * Load one older history page into the published window.
+ * @returns whether the page grew, exhausted history, or must be retried.
+ */
+ loadOlder: () => Promise
+ /** Cancel a pending open-window wait when this view unmounts. */
+ cancel: () => void
+ /**
+ * Resolve one durable prompt image through the shared Conversation image cache.
+ * @param attachment - image reference the user message recorded.
+ * @returns browser URL owned by the cache for the Session binding's lifetime.
+ */
+ loadImage: (attachment: ImageAttachmentRef) => Promise
+}
+
+/** Full props of the performance conversation View. */
+export type StatsViewProps =
+ ConvViewProps & InjectFace & PropsLocale<'stats'>
+
+/** Resolve one durable prompt image to a browser URL. */
+type LoadImage = StatsViewInjected['loadImage']
+
+/** Prompt image shown by the full-size lightbox. */
+interface LightboxImage {
+ readonly src: string
+ readonly alt: string
+}
+
+/** Load state of one preview thumbnail. */
+type ImageLoad =
+ | { readonly status: 'loading' }
+ | { readonly status: 'failed' }
+ | { readonly status: 'ready'; readonly src: string }
+
+/** Open/closed default for turns the user never toggled explicitly. */
+type ViewMode = 'newest' | 'all' | 'none'
+
+/** Step-list presentation of one turn: newest slice or all steps, plus filters. */
+export interface StepViewState {
+ readonly mode?: 'tail' | 'all'
+ readonly filter?: string | null
+ readonly open?: Readonly>
+ readonly raw?: Readonly>
+ readonly wrap?: boolean
+}
+
+/** Component-local table state: turn folds and per-turn step state. */
+export interface ViewState {
+ readonly mode: ViewMode
+ readonly openTurns: Readonly>
+ readonly stepViews: Readonly>
+ readonly showTotals: boolean
+ readonly showEvents: boolean
+ readonly showContextDelta: boolean
+ readonly contextMode: ContextMode
+}
+
+/** Fields both row kinds expose to the numeric column helpers. */
+interface MetricsValues {
+ readonly context: number | null
+ readonly output: number | null
+ readonly cacheHit: number | null
+ readonly ttft: number | null
+ readonly model: number | null
+ readonly tps: number | null
+ readonly tool: number | null
+ readonly cacheRead: number | null
+ readonly cacheWrite: number | null
+ /** Weighted prompt denominator of a turn's cache rate; step rows use `context`. */
+ readonly cacheContext?: number | null
+ readonly ttftPending?: number
+ readonly modelPending?: number
+ readonly toolPending?: number
+}
+
+/** Fields the scope markers read from a turn or step row. */
+interface ScopeState {
+ readonly running: boolean
+ readonly interrupted: boolean
+ readonly aborted: boolean
+ readonly retries: number
+ readonly cacheHit: number | null
+}
+
+/** Fields the time-composition bar reads from a turn or step row. */
+interface TimeState {
+ readonly running: boolean
+ readonly startTime: number | null
+ readonly wall: number | null
+ readonly ttft: number | null
+ readonly model: number | null
+ readonly tool: number | null
+ /** Non-overlapping union of the paired tool calls; the bar prefers it over `tool`. */
+ readonly toolWall: number | null
+}
+
+/** Text and tooltip of one numeric cell. */
+interface CellText {
+ readonly text: string
+ readonly title: string
+}
+
+/** One numeric cell with its column name. */
+interface MetricCell extends CellText {
+ readonly name: NumericColumn
+}
+
+/** One rendered code line: diff sign plus the line text. */
+interface CodeLine {
+ readonly sign: ' ' | '+' | '−'
+ readonly text: string
+}
+
+/** File body unwrapped from a `read` tool result. */
+interface FileContent {
+ readonly path: string | null
+ readonly body: string
+}
+
+/** A tool-name plus target group shown in the step's did column. */
+interface CallGroup {
+ readonly key: string
+ readonly name: string | null
+ readonly hint: string | null
+ readonly calls: readonly StatsCall[]
+}
+
+/** Table state shared by one turn's rendered rows. */
+interface TurnRowsOptions {
+ readonly turn: StatsTurnMetrics
+ readonly t: StatsTranslate
+ readonly loadImage: LoadImage
+ readonly now: number
+ readonly open: boolean
+ readonly state: StepViewState
+ readonly turnScale: number
+ readonly contextMode: ContextMode
+ readonly showEvents: boolean
+ readonly showContextDelta: boolean
+ readonly contextDelta: number | null
+ readonly onToggle: () => void
+ readonly onToggleDetail: (stepNumber: number) => void
+ readonly onControl: (patch: Partial) => void
+}
+
+/** Row state of one turn's step rows (no turn header fields). */
+interface StepRowsOptions {
+ readonly turn: StatsTurnMetrics
+ readonly t: StatsTranslate
+ readonly now: number
+ readonly state: StepViewState
+ readonly showEvents: boolean
+ readonly showContextDelta: boolean
+ readonly onToggleDetail: (stepNumber: number) => void
+ readonly onControl: (patch: Partial) => void
+}
+
+/** Props of one tool-call block inside the step accordion. */
+interface CallBlockProps {
+ readonly call: StatsCall
+ readonly t: StatsTranslate
+ readonly wrap: boolean
+ readonly raw: boolean
+ readonly onToggleRaw: () => void
+}
+
+/** Props of the clipboard button; it renders only where the host exposes the Clipboard API. */
+interface CopyButtonProps {
+ readonly clipboard: Clipboard
+ readonly clipboardValue: string
+ readonly t: StatsTranslate
+}
+
+/** Props of one numeric cell group (context excluded by the caller). */
+interface MetricCellsProps {
+ readonly cells: readonly MetricCell[]
+ readonly className: string | undefined
+}
+
+/** Props of the context column cell. */
+interface ContextCellProps {
+ readonly value: number | null
+ readonly className: string | undefined
+ readonly delta?: number | null
+}
+
+/** Props of the time-composition cell. */
+interface TimeCellProps {
+ readonly value: TimeState
+ readonly scale: number
+ readonly gap: number | undefined
+ readonly className: string | undefined
+ readonly trackClass: string | undefined
+ readonly now: number
+ readonly t: StatsTranslate
+}
+
+/** Props of the pagination/expand footer button. */
+interface TableButtonProps {
+ readonly caption: string
+ readonly enabled: boolean
+ readonly onClick: () => void
+}
+
+/** Props of the prompt column cell. */
+interface PromptCellProps {
+ readonly turn: StatsTurnMetrics
+ readonly t: StatsTranslate
+ readonly className: string | undefined
+ readonly loadImage: LoadImage
+}
+
+/** Props of one preview thumbnail inside the image popover. */
+interface PromptImageThumbProps {
+ readonly attachment: ImageAttachmentRef
+ readonly index: number
+ readonly count: number
+ readonly loadImage: LoadImage
+ readonly t: StatsTranslate
+ readonly onOpen: (image: LightboxImage) => void
+}
+
+/** Props of the image-count pill and its hover/focus preview. */
+interface ImagePromptPillProps {
+ readonly first: ImageAttachmentRef
+ readonly images: readonly ImageAttachmentRef[]
+ readonly loadImage: LoadImage
+ readonly t: StatsTranslate
+}
+
+/** Empty per-turn step state, shared so unexpanded turns allocate nothing. */
+export const EMPTY_STEP_VIEW: StepViewState = {}
+
+/** Index of the tool tone for one call name; unknown names hash to a stable tone. */
+function toneIndex(name: string | null): number {
+ if (name === null) return 7
+ const fixed = TOOL_TONES[name]
+ if (fixed !== undefined) return fixed
+ let hash = 0
+ for (let index = 0; index < name.length; index += 1) {
+ hash = (hash * 31 + name.charCodeAt(index)) % 9973
+ }
+ return hash % TONE_CLASSES.length
+}
+
+/** CSS class carrying the tool tone for one call name. */
+function toneClass(name: string | null): string | undefined {
+ return TONE_CLASSES[toneIndex(name)]
+}
+
+/** Whether a row is running with no measured wall time yet. */
+export function isTimeRunning(value: TimeState): boolean {
+ return value.running && !isNumber(value.wall) && isNumber(value.startTime)
+}
+
+/** Measured span; a running row falls back to the elapsed time at `now`. */
+export function totalSpan(value: TimeState, now: number): number | null {
+ if (isNumber(value.wall)) return value.wall
+ if (isNumber(value.startTime) && value.running) return Math.max(0, now - value.startTime)
+ return null
+}
+
+/** Default fold state of a turn the user never toggled. */
+export function defaultTurnOpen(mode: ViewMode, turnNumber: number, latestTurn: number | null): boolean {
+ if (mode === 'all') return true
+ if (mode === 'none') return false
+ return turnNumber === latestTurn
+}
+
+/** Fresh table state: follow the newest turn's open default. */
+export function createViewState(): ViewState {
+ return { mode: 'newest', openTurns: {}, stepViews: {}, showTotals: false, showEvents: false, showContextDelta: false, contextMode: 'latest' }
+}
+
+/** Read an array element whose index the surrounding bounds already prove. */
+function at(lines: readonly string[], index: number): string {
+ return lines[index] as string
+}
+
+/** Split a text block into lines; an empty string has no lines. */
+function splitLines(text: string): readonly string[] {
+ return text === '' ? [] : text.split('\n')
+}
+
+/** Map a plain text block to code lines. */
+function textLines(text: string): readonly CodeLine[] {
+ return splitLines(text).map(line => ({ sign: ' ', text: line }))
+}
+
+/**
+ * Readable argument body: parse and indent JSON by two spaces, or keep the
+ * text verbatim when the model produced arguments this build cannot parse.
+ * @param args - raw tool arguments as recorded.
+ * @returns the body the raw view displays and copies.
+ */
+function prettyArguments(args: string): string {
+ try {
+ const parsed: unknown = JSON.parse(args)
+ return JSON.stringify(parsed, null, 2)
+ } catch {
+ // Model-produced arguments are not always JSON; show them verbatim.
+ return args
+ }
+}
+
+/**
+ * Line-level diff: removal/addition rows for the parts that differ, common
+ * lines kept in place. Above the size cap the two sides are shown whole.
+ */
+function lineDiff(before: string | null, after: string | null): readonly CodeLine[] {
+ const left = splitLines(before ?? '')
+ const right = splitLines(after ?? '')
+ if (left.length > DIFF_LINE_LIMIT || right.length > DIFF_LINE_LIMIT) {
+ return [
+ ...left.map(text => ({ sign: '−' as const, text })),
+ ...right.map(text => ({ sign: '+' as const, text })),
+ ]
+ }
+ const width = right.length + 1
+ // LCS lengths for cells with row and column at least 1; the implicit zero row
+ // and column are served by the missing-key fallback, never stored.
+ const table = new Map()
+ let previousRow = 0
+ for (let row = 1; row <= left.length; row += 1) {
+ let leftCell = 0
+ for (let column = 1; column <= right.length; column += 1) {
+ const up = table.get(previousRow + column) ?? 0
+ const diagonal = table.get(previousRow + column - 1) ?? 0
+ const value = at(left, row - 1) === at(right, column - 1)
+ ? diagonal + 1
+ : Math.max(up, leftCell)
+ table.set(row * width + column, value)
+ leftCell = value
+ }
+ previousRow = row * width
+ }
+ const rows: CodeLine[] = []
+ let row = left.length
+ let column = right.length
+ while (row > 0 && column > 0) {
+ const leftLine = at(left, row - 1)
+ const rightLine = at(right, column - 1)
+ if (leftLine === rightLine) {
+ rows.push({ sign: ' ', text: leftLine })
+ row -= 1
+ column -= 1
+ } else if ((table.get((row - 1) * width + column) ?? 0) >= (table.get(row * width + column - 1) ?? 0)) {
+ rows.push({ sign: '−', text: leftLine })
+ row -= 1
+ } else {
+ rows.push({ sign: '+', text: rightLine })
+ column -= 1
+ }
+ }
+ while (row > 0) {
+ rows.push({ sign: '−', text: at(left, row - 1) })
+ row -= 1
+ }
+ while (column > 0) {
+ rows.push({ sign: '+', text: at(right, column - 1) })
+ column -= 1
+ }
+ return rows.reverse()
+}
+
+/** Unwrap the ``/`` envelope a `read` result carries. */
+function fileContentOf(text: string | null): FileContent | null {
+ if (text === null) return null
+ const open = text.indexOf('')
+ const close = text.lastIndexOf('')
+ if (open < 0 || close < 0 || close < open) return null
+ const pathMatch = /([\s\S]*?)<\/path>/.exec(text)
+ const body = text.slice(open + ''.length, close).replace(/^\n/, '')
+ let path: string | null = null
+ if (pathMatch !== null) {
+ /* v8 ignore next -- a matched `` group is non-optional in the regexp, so the fallback is unreachable. */
+ path = (pathMatch[1] ?? '').trim()
+ }
+ return { path, body }
+}
+
+/** CSS class of one code line by its diff sign. */
+function lineClass(sign: CodeLine['sign']): string | undefined {
+ if (sign === '+') return css.diffAdd
+ if (sign === '−') return css.diffDel
+ return css.codeLine
+}
+
+/** Code block: monospaced lines, capped at CODE_LINE_LIMIT rows. */
+function codeBlock(lines: readonly CodeLine[], t: StatsTranslate, wrap: boolean): ReactElement {
+ const shown = lines.length > CODE_LINE_LIMIT ? lines.slice(0, CODE_LINE_LIMIT) : lines
+ return (
+
+ {shown.map((line, index) => (
+