Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses [Semantic Versioning](https://semver.org/). 中文版见 [CHANGELOG_ZH.md](./CHANGELOG_ZH.md).

## [Unreleased]

### Added

- **The usage panel can be dragged out of the way**: a handle at the top of the panel moves it by pointer drag (the position is stored in `localStorage` and survives reloads); double-clicking the "Usage" button resets it right above the button. Movement is clamped to the nearest fixed-position containing block (so themed docks using `transform`/`backdrop-filter` are handled correctly) and the panel always keeps at least 48px of itself on screen.

### Tests

- New `tests/panel-position.test.mjs`: persistence fallbacks (missing key, broken JSON, non-finite values, no `localStorage`) and boundary clamping (out-of-range offsets, oversized panels never bounce the other way).

## [1.1.7] - 2026-09-24

### Fixed
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG_ZH.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@
本文件记录本项目所有重要变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。英文版见 [CHANGELOG.md](./CHANGELOG.md)。

## [未发布]

### 新增

- **用量面板可以拖到不挡内容的位置**:面板顶部新增拖动把手,按住即可挪动(位置写入 `localStorage`,刷新或重启后保持);双击「用量」按钮复位到按钮正上方。拖动范围按最近的 fixed 定位包含块夹取(皮肤给 dock 加了 `transform`/`backdrop-filter` 时不会被算错),面板四条边至少保留 48px 在可视区内,拖不出屏幕。

### 测试

- 新增 `tests/panel-position.test.mjs`:持久化读取的容错(缺失/坏 JSON/非有限数值/无 `localStorage`)与边界夹取(越界夹取、超大面板不反向顶飞)。

## [1.1.7] - 2026-09-24

### 修复
Expand Down
91 changes: 89 additions & 2 deletions src/client/UsageIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -195,6 +202,76 @@ export function UsageIndicator(props: DockUsageProps): JSX.Element | null {
}
}, [expanded])

// 卸载时兜底恢复:拖动中途组件被卸载不该把整页文字设成不可选。
useEffect(() => () => { document.body.style.userSelect = '' }, [])

Copy link
Copy Markdown
Owner

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.


/** 当前包含块(祖先无 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())

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The 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
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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}
Expand Down
5 changes: 3 additions & 2 deletions src/client/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface UiCopy {
usage: string
expandUsage: string
collapseUsage: string
dragPanelTitle: string
retryBalanceTitle: string
officialBalanceTitle: string
usageDetails: string
Expand Down Expand Up @@ -136,7 +137,7 @@ export interface UiCopy {
const COPY: Record<UiLocale, UiCopy> = {
zh: {
input: '输入', output: '输出', cache: '缓存', cost: '成本', balance: '余额', usage: '用量',
expandUsage: '展开用量面板', collapseUsage: '收起用量面板', retryBalanceTitle: '点击重试余额查询', officialBalanceTitle: '余额来自官方接口',
expandUsage: '展开用量面板', collapseUsage: '收起用量面板', dragPanelTitle: '拖动可移动面板,双击「用量」复位', retryBalanceTitle: '点击重试余额查询', officialBalanceTitle: '余额来自官方接口',
usageDetails: '会话用量详情', sessionUsage: '会话用量', billedInput: '计费输入', cacheHit: '缓存命中', contextUsage: '上下文占用', unavailable: '暂无',
sessionEmpty: '发送消息后,这里会显示当前会话的 Token 用量。',
costEstimate: '成本估算', inputCost: '输入', outputCost: '输出',
Expand Down Expand Up @@ -205,7 +206,7 @@ const COPY: Record<UiLocale, UiCopy> = {
},
en: {
input: 'Input', output: 'Output', cache: 'Cache', cost: 'Cost', balance: 'Balance', usage: 'Usage',
expandUsage: 'Expand usage panel', collapseUsage: 'Collapse usage panel', retryBalanceTitle: 'Retry balance query', officialBalanceTitle: 'Balance from the official API',
expandUsage: 'Expand usage panel', collapseUsage: 'Collapse usage panel', dragPanelTitle: 'Drag to move the panel; double-click "Usage" to reset', retryBalanceTitle: 'Retry balance query', officialBalanceTitle: 'Balance from the official API',
usageDetails: 'Session usage details', sessionUsage: 'Session usage', billedInput: 'Billed input', cacheHit: 'Cache hit', contextUsage: 'Context used', unavailable: 'N/A',
sessionEmpty: 'Token usage will appear after you send a message.',
costEstimate: 'Estimated cost', inputCost: 'Input', outputCost: 'Output',
Expand Down
63 changes: 63 additions & 0 deletions src/client/panel-position.ts
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,

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The 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,
}
}
24 changes: 24 additions & 0 deletions src/client/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,30 @@ export const PLUGIN_CSS = `
}
body[data-ds-dark-theme] .duc-popover { box-shadow: 0 16px 40px rgba(0, 0, 0, 0.42); }

/* 拖动把手:sticky 在面板顶部(面板自身可滚动),横条只是视觉提示。 */
.duc-popover-handle {
position: sticky;
top: 0;
z-index: 1;
display: block;
width: 100%;
height: 14px;
border-radius: 10px 10px 0 0;
background: var(--dsw-alias-bg-layer-2, rgba(30, 31, 34, 0.92));
cursor: grab;
touch-action: none;
}
.duc-popover-handle::before {
content: '';
display: block;
width: 36px;
height: 4px;
margin: 5px auto 0;
border-radius: 2px;
background: var(--dsw-alias-border-l3, rgba(127, 127, 127, 0.4));
}
.duc-popover-handle:active { cursor: grabbing; }

.duc-panel {
width: 100%;
padding: 0 14px;
Expand Down
59 changes: 59 additions & 0 deletions tests/panel-position.test.mjs
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 })
})
Loading