Repository navigation
feat(client): draggable usage panel with persisted position (closes #16) #17
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,12 +3,13 @@ | |
| * 一行展示:输入 / 输出 / 缓存命中率 / 成本估算 / 模型 / 余额 + 细上下文压力条, | ||
| * 点击展开可视化面板。成本只消费 /pricing 快照(ADR 2),快照未就绪时隐藏成本位。 | ||
| */ | ||
| import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' | ||
| import { useEffect, useLayoutEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react' | ||
| import type { TokenUsageBuckets } from '../pricing/calc.ts' | ||
| import { billedInputTokens, cacheHitPercent, formatMoney, formatTokens } from '../pricing/calc.ts' | ||
| import { currencySymbol, useBalance } from './balance.ts' | ||
| import { useDisplayCurrency } from './currency.ts' | ||
| import { getUiCopy, useUiLocale } from './i18n.ts' | ||
| import { clampPanelOffset, PANEL_POS_KEY, readPanelOffset, type PanelOffset, type PanelRect } from './panel-position.ts' | ||
| import { resolveCost, usePricing } from './pricing-api.ts' | ||
| import { useHistoryRounds } from './rounds/history.ts' | ||
| import { useObservedRounds } from './rounds/observed.ts' | ||
|
|
@@ -82,6 +83,12 @@ export function UsageIndicator(props: DockUsageProps): JSX.Element | null { | |
| const toggleRef = useRef<HTMLButtonElement | null>(null) | ||
| // 悬浮面板的锚点坐标(fixed 定位,始终在可视区内) | ||
| const [anchor, setAnchor] = useState<{ left: number; width: number; bottom: number } | null>(null) | ||
| // 面板可拖动:偏移叠加在锚点上(transform,不动包含块坐标换算),持久化到 localStorage。 | ||
| const [offset, setOffset] = useState<PanelOffset>(() => readPanelOffset()) | ||
| const offsetRef = useRef(offset) | ||
| offsetRef.current = offset | ||
| const panelRef = useRef<HTMLDivElement | null>(null) | ||
| const dragRef = useRef<{ pointerX: number; pointerY: number; offset: PanelOffset } | null>(null) | ||
|
|
||
| const totals = useProjection('tokenUsage') as TokenUsageBuckets | undefined | ||
| const pressure = useProjection('contextPressure') as { pressureTokens?: number; projectedTokens?: number; contextWindow?: number } | undefined | ||
|
|
@@ -195,6 +202,76 @@ export function UsageIndicator(props: DockUsageProps): JSX.Element | null { | |
| } | ||
| }, [expanded]) | ||
|
|
||
| // 卸载时兜底恢复:拖动中途组件被卸载不该把整页文字设成不可选。 | ||
| useEffect(() => () => { document.body.style.userSelect = '' }, []) | ||
|
|
||
| /** 当前包含块(祖先无 transform/filter/contain 时即视口)的视口矩形。 */ | ||
| const dragBounds = (): PanelRect => { | ||
| const el = rootRef.current | ||
| const rect = el === null ? undefined : containingBlock(el)?.getBoundingClientRect() | ||
| return rect === undefined | ||
| ? { left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight } | ||
| : { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom } | ||
| } | ||
|
|
||
| /** 夹取并落盘一个候选偏移(面板矩形扣掉当前偏移 = 未位移基准)。 */ | ||
| const applyOffset = (next: PanelOffset): void => { | ||
| const panel = panelRef.current | ||
| let final = next | ||
| if (panel !== null) { | ||
| const rect = panel.getBoundingClientRect() | ||
| const base = { | ||
| left: rect.left - offsetRef.current.x, | ||
| top: rect.top - offsetRef.current.y, | ||
| right: rect.right - offsetRef.current.x, | ||
| bottom: rect.bottom - offsetRef.current.y, | ||
| } | ||
| final = clampPanelOffset(next, base, dragBounds()) | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2] Re-clamp restored offsets when the panel opens or its bounds change This clamp runs only through moveDrag. Restored positions, window resize, and anchor changes can therefore leave the panel entirely outside the viewport. Reproduction: at a 1200px viewport drag right and save offset {x:900,y:0}, then shrink to 400px. The panel's left edge becomes 908px, and reloading/reopening retains that off-screen position. The user cannot reach the panel or handle without knowing the double-click reset gesture. Please validate the restored offset after the panel can be measured and re-clamp when the anchor/bounds change. Add resize and narrow-window restoration coverage. |
||
| } | ||
| offsetRef.current = final // pointermove 是连续的,ref 必须立刻跟上(setState 异步) | ||
| setOffset(final) | ||
| try { | ||
| localStorage.setItem(PANEL_POS_KEY, JSON.stringify(final)) | ||
| } catch { | ||
| // 隐私模式 / 配额满:位置不持久化,拖动本身照常可用。 | ||
| } | ||
| } | ||
|
|
||
| /** 双击「用量」复位到按钮正上方(并清掉已存的位置)。 */ | ||
| const clearOffset = (): void => { | ||
| offsetRef.current = { x: 0, y: 0 } | ||
| setOffset({ x: 0, y: 0 }) | ||
| try { | ||
| localStorage.removeItem(PANEL_POS_KEY) | ||
| } catch { | ||
| // ignore | ||
| } | ||
| } | ||
|
|
||
| const beginDrag = (event: ReactPointerEvent<HTMLDivElement>): void => { | ||
| if (event.button !== 0) return | ||
| dragRef.current = { pointerX: event.clientX, pointerY: event.clientY, offset: offsetRef.current } | ||
| event.currentTarget.setPointerCapture?.(event.pointerId) | ||
| event.preventDefault() | ||
| document.body.style.userSelect = 'none' | ||
| } | ||
|
|
||
| const moveDrag = (event: ReactPointerEvent<HTMLDivElement>): void => { | ||
| const drag = dragRef.current | ||
| if (drag === null) return | ||
| applyOffset({ | ||
| x: drag.offset.x + event.clientX - drag.pointerX, | ||
| y: drag.offset.y + event.clientY - drag.pointerY, | ||
| }) | ||
| } | ||
|
|
||
| const endDrag = (event: ReactPointerEvent<HTMLDivElement>): void => { | ||
| if (dragRef.current === null) return | ||
| dragRef.current = null | ||
| event.currentTarget.releasePointerCapture?.(event.pointerId) | ||
| document.body.style.userSelect = '' | ||
| } | ||
|
|
||
| // 全空时保持隐藏(与官方 StatsLine 的零数据策略一致)。 | ||
| if (!hasTokens && model === undefined && balanceStatus !== 'ok' && balanceStatus !== 'loading' && !expanded) { | ||
| return null | ||
|
|
@@ -228,6 +305,7 @@ export function UsageIndicator(props: DockUsageProps): JSX.Element | null { | |
| aria-expanded={expanded} | ||
| title={expanded ? copy.collapseUsage : copy.expandUsage} | ||
| onClick={toggle} | ||
| onDoubleClick={clearOffset} | ||
| > | ||
| <ChartIcon /> | ||
| <span className="duc-toggle-label">{copy.usage}</span> | ||
|
|
@@ -270,8 +348,17 @@ export function UsageIndicator(props: DockUsageProps): JSX.Element | null { | |
| {expanded && anchor !== null && ( | ||
| <div | ||
| className="duc-popover" | ||
| style={{ left: anchor.left, width: anchor.width, bottom: anchor.bottom }} | ||
| ref={panelRef} | ||
| style={{ left: anchor.left, width: anchor.width, bottom: anchor.bottom, transform: `translate(${offset.x}px, ${offset.y}px)` }} | ||
| > | ||
| <div | ||
| className="duc-popover-handle" | ||
| title={copy.dragPanelTitle} | ||
| onPointerDown={beginDrag} | ||
| onPointerMove={moveDrag} | ||
| onPointerUp={endDrag} | ||
| onPointerCancel={endDrag} | ||
| /> | ||
| <UsagePanel | ||
| history={history} | ||
| locale={locale} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| /** | ||
| * 悬浮面板的位置偏移:读取与夹取的纯函数(无 React / 无 DOM 依赖,便于单测)。 | ||
| * | ||
| * 面板是 `position: fixed`;祖先含 transform / filter / contain 时,坐标基准是那个 | ||
| * 祖先而不是视口(见 UsageIndicator 的 containingBlock())。拖动只做 | ||
| * `transform: translate()` 的相对位移,因此与包含块坐标换算互不干扰。 | ||
| */ | ||
|
|
||
| /** 拖动偏移的持久化键(沿用既有键名,升级后已保存的位置不丢)。 */ | ||
| export const PANEL_POS_KEY = 'dsh-usage-chart:panel-pos' | ||
|
|
||
| /** 拖到边界时,面板在包含块内至少保留的可见像素。 */ | ||
| export const MIN_VISIBLE_PX = 48 | ||
|
|
||
| export interface PanelOffset { | ||
| x: number | ||
| y: number | ||
| } | ||
|
|
||
| /** 视口坐标下的矩形(DOMRect 的最小子集)。 */ | ||
| export interface PanelRect { | ||
| left: number | ||
| top: number | ||
| right: number | ||
| bottom: number | ||
| } | ||
|
|
||
| /** 读取已持久化的偏移;缺失、坏 JSON、非有限数值一律回 {0,0}。 */ | ||
| export function readPanelOffset( | ||
| storage: Pick<Storage, 'getItem'> | undefined = typeof localStorage === 'undefined' ? undefined : localStorage, | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P1] Access localStorage inside the guarded function body When browser policy denies storage access, the window.localStorage getter itself throws SecurityError. The default argument is evaluated before the function body, so the try/catch below cannot handle it. UsageIndicator calls readPanelOffset() during its useState initializer; in the Chromium reproduction the indicator does not render at all. The existing currency storage reads guard this access inside try, so this is a new regression. Please move acquisition of the default storage into the try block and add a regression test for a throwing localStorage getter (not only a missing global or a throwing getItem). |
||
| ): PanelOffset { | ||
| try { | ||
| const raw = storage?.getItem(PANEL_POS_KEY) ?? null | ||
| if (raw === null) return { x: 0, y: 0 } | ||
| const parsed = JSON.parse(raw) as { x?: unknown; y?: unknown } | null | ||
| const finite = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0) | ||
| return { x: finite(parsed?.x), y: finite(parsed?.y) } | ||
| } catch { | ||
| return { x: 0, y: 0 } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * 夹取偏移:未位移的面板矩形 panel 平移 offset 后,四条边都至少保留 | ||
| * minVisible 像素落在包含块 bounds 内——任意方向都拖不出可视区。 | ||
| * 面板本身比包含块还大(该轴 min > max)时保持原值,避免被顶到反方向。 | ||
| */ | ||
| export function clampPanelOffset( | ||
| offset: PanelOffset, | ||
| panel: PanelRect, | ||
| bounds: PanelRect, | ||
| minVisible: number = MIN_VISIBLE_PX, | ||
| ): PanelOffset { | ||
| const clamp = (value: number, min: number, max: number): number => Math.min(Math.max(value, min), max) | ||
| const minX = bounds.left - panel.right + minVisible | ||
| const maxX = bounds.right - panel.left - minVisible | ||
| const minY = bounds.top - panel.bottom + minVisible | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2] Keep the only drag handle reachable at the upper boundary The vertical minimum allows the panel's top to move above the viewport as long as its bottom 48px remain visible. The handle is sticky inside the popover's scroll container, not to the viewport, so it moves off-screen with the panel. In Chromium, dragging upward leaves the panel at y=-371 and the handle at y=-366; after releasing the pointer the panel cannot be dragged back. Only the reset gesture recovers it. Please constrain the handle/header to remain within the visible bounds, rather than only retaining the panel's bottom edge, and cover release/re-drag at the upper boundary. |
||
| const maxY = bounds.bottom - panel.top - minVisible | ||
| return { | ||
| x: minX <= maxX ? clamp(offset.x, minX, maxX) : offset.x, | ||
| y: minY <= maxY ? clamp(offset.y, minY, maxY) : offset.y, | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| /** | ||
| * 面板拖动偏移的纯函数回归:持久化读取容错 + 边界夹取。 | ||
| * 交互本身(pointer 拖动 / 双击复位)由运行中的 DSH Web 手工验证;这里锁住数学与容错。 | ||
| */ | ||
| import assert from 'node:assert/strict' | ||
| import test from 'node:test' | ||
| import { fileURLToPath } from 'node:url' | ||
| import { build } from 'esbuild' | ||
|
|
||
| /** 与 merge-regression.test.mjs 同款:esbuild 打包 src 后以 ESM 载入(同 realm)。 */ | ||
| async function loadModule(path) { | ||
| const result = await build({ | ||
| entryPoints: [fileURLToPath(new URL(path, import.meta.url))], | ||
| bundle: true, | ||
| write: false, | ||
| format: 'esm', | ||
| platform: 'node', | ||
| target: 'node20', | ||
| }) | ||
| return import(`data:text/javascript;base64,${Buffer.from(result.outputFiles[0].contents).toString('base64')}`) | ||
| } | ||
|
|
||
| const { readPanelOffset, clampPanelOffset, PANEL_POS_KEY, MIN_VISIBLE_PX } = | ||
| await loadModule('../src/client/panel-position.ts') | ||
|
|
||
| /** 只实现 getItem 的假存储。 */ | ||
| const storage = (raw) => ({ getItem: (key) => (key === PANEL_POS_KEY ? raw : null) }) | ||
|
|
||
| test('readPanelOffset: missing / broken / invalid values fall back to zero', () => { | ||
| assert.deepEqual(readPanelOffset(storage(null)), { x: 0, y: 0 }) | ||
| assert.deepEqual(readPanelOffset(storage('not json')), { x: 0, y: 0 }) | ||
| assert.deepEqual(readPanelOffset(storage('null')), { x: 0, y: 0 }) | ||
| assert.deepEqual(readPanelOffset(storage('{"x":12}')), { x: 12, y: 0 }) | ||
| assert.deepEqual(readPanelOffset(storage('{"x":"12","y":null}')), { x: 0, y: 0 }) | ||
| assert.deepEqual(readPanelOffset(storage('{"x":1e999,"y":-3}')), { x: 0, y: -3 }) | ||
| assert.deepEqual(readPanelOffset(undefined), { x: 0, y: 0 }) | ||
| }) | ||
|
|
||
| test('readPanelOffset: valid offsets round-trip', () => { | ||
| assert.deepEqual(readPanelOffset(storage('{"x":-40,"y":120}')), { x: -40, y: 120 }) | ||
| }) | ||
|
|
||
| const BOUNDS = { left: 0, top: 0, right: 1000, bottom: 800 } | ||
| const PANEL = { left: 100, top: 100, right: 600, bottom: 500 } | ||
|
|
||
| test('clampPanelOffset: keeps MIN_VISIBLE_PX of the panel inside the containing block', () => { | ||
| const right = clampPanelOffset({ x: 5000, y: 5000 }, PANEL, BOUNDS) | ||
| assert.equal(right.x, BOUNDS.right - PANEL.left - MIN_VISIBLE_PX) | ||
| assert.equal(right.y, BOUNDS.bottom - PANEL.top - MIN_VISIBLE_PX) | ||
| const left = clampPanelOffset({ x: -5000, y: -5000 }, PANEL, BOUNDS) | ||
| assert.equal(left.x, BOUNDS.left - PANEL.right + MIN_VISIBLE_PX) | ||
| assert.equal(left.y, BOUNDS.top - PANEL.bottom + MIN_VISIBLE_PX) | ||
| }) | ||
|
|
||
| test('clampPanelOffset: in-range offsets and oversized panels pass through', () => { | ||
| assert.deepEqual(clampPanelOffset({ x: 12, y: -20 }, PANEL, BOUNDS), { x: 12, y: -20 }) | ||
| const huge = { left: 0, top: 0, right: 1400, bottom: 900 } | ||
| assert.deepEqual(clampPanelOffset({ x: 300, y: 300 }, huge, BOUNDS), { x: 300, y: 300 }) | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P2] End the drag when the popover closes, not only when the indicator unmounts
Start dragging, press Escape while holding the pointer, then release. Escape removes the popover/handle but leaves UsageIndicator mounted, so neither endDrag nor this component-unmount cleanup restores the body style. Chromium confirms the panel is gone while document.body.style.userSelect remains 'none'; conversation text remains unselectable.
Please clear active drag state and restore the previous body userSelect value when the popover closes or pointer capture is lost, as well as on component unmount. Add an Escape-during-drag regression test.