|
|
@@ -1,27 +1,17 @@
|
|
|
-// Single-row Turn chrome: the finalized footer's whole meta line (clock,
|
|
|
-// duration, turn total, throughput, TTFT, cache-hit) as one inline trigger
|
|
|
-// whose click opens the per-Turn details dialog.
|
|
|
+// Icon-row Turn-usage action: a data-icon pill labelled with the turn total
|
|
|
+// (and the cache-hit rate when known) that click-opens the per-Turn details
|
|
|
+// dialog. Sits right of the branch action in the tail's IconActions row.
|
|
|
|
|
|
-import { Fragment, useEffect, useRef, useState } from 'react'
|
|
|
+import { useEffect, useRef, useState, type CSSProperties } from 'react'
|
|
|
+import { createPortal } from 'react-dom'
|
|
|
+import { IconDataOutline16, useAnchoredPosition } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
|
import type { TurnTokenUsage } from '../contract/chat-nodes.ts'
|
|
|
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
|
|
-import {
|
|
|
- formatLatencySeconds, formatMessageClock, formatRunDuration, formatTokensPerSecond,
|
|
|
-} from './message-chrome.ts'
|
|
|
import { formatCacheHitPercent, formatExactTokens, formatTokens } from './token-format.ts'
|
|
|
-import { useCalendarDay } from './use-calendar-day.ts'
|
|
|
import css from './TurnUsagePanel.module.css'
|
|
|
|
|
|
export interface TurnUsagePanelProps {
|
|
|
usage: TurnTokenUsage
|
|
|
- /** Turn-close instant (unix epoch ms) shown as the line's leading clock. */
|
|
|
- time: number
|
|
|
- /** Turn elapsed run time in ms, shown as `用时 6秒`; omitted when unrecorded. */
|
|
|
- runMs?: number | undefined
|
|
|
- /** Turn first-step TTFT in ms, shown as `TTFT 1.2s`; omitted when unrecorded. */
|
|
|
- ttftMs?: number | undefined
|
|
|
- /** Turn decode throughput, shown as `34 tok/s`; omitted when unrecorded. */
|
|
|
- tokensPerSecond?: number | undefined
|
|
|
/** The owning view's locale seat, passed down as a plain prop. */
|
|
|
t: ChatViewSlotProps['t']
|
|
|
}
|
|
|
@@ -34,21 +24,46 @@ function formatExactCount(value: number, t: ChatViewSlotProps['t']): string {
|
|
|
return t('message.turnUsage.count', { count: formatExactTokens(value, t) })
|
|
|
}
|
|
|
|
|
|
+/** Viewport margin the placement clamp keeps (the Menu portal margin). */
|
|
|
+const PANEL_MARGIN = 12
|
|
|
+
|
|
|
+/** Distance between the trigger's top edge and the panel's bottom. */
|
|
|
+const PANEL_GAP = 8
|
|
|
+
|
|
|
+/**
|
|
|
+ * Unplaced portal panel: hidden but laid out so the clamp measures real
|
|
|
+ * dimensions (the `useAnchoredPosition` measure pass).
|
|
|
+ */
|
|
|
+const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
|
|
|
+
|
|
|
/**
|
|
|
- * Whole-line clickable meta cluster with a click-open Turn-details dialog.
|
|
|
- * @param props - Turn usage buckets, close time, optional timing metrics, locale seat.
|
|
|
- * @returns The trigger and, while open, its anchored dialog.
|
|
|
+ * Turn-usage IconActions pill with a click-open Turn-details dialog.
|
|
|
+ * @param props - Turn usage buckets and locale seat.
|
|
|
+ * @returns The icon-and-total trigger and, while open, its portaled dialog anchored above the trigger.
|
|
|
*/
|
|
|
-export function TurnUsagePanel({ usage, time, runMs, ttftMs, tokensPerSecond, t }: TurnUsagePanelProps) {
|
|
|
+export function TurnUsagePanel({ usage, t }: TurnUsagePanelProps) {
|
|
|
const [open, setOpen] = useState(false)
|
|
|
const rootRef = useRef<HTMLSpanElement | null>(null)
|
|
|
- const day = useCalendarDay()
|
|
|
+ const panelRef = useRef<HTMLDivElement | null>(null)
|
|
|
+
|
|
|
+ // Portal placement: the dialog is fixed above the trigger and clamped inside
|
|
|
+ // the viewport, so a trigger near the window edge cannot push it off-screen.
|
|
|
+ const pos = useAnchoredPosition({
|
|
|
+ open,
|
|
|
+ anchorRef: rootRef,
|
|
|
+ panelRef,
|
|
|
+ side: 'top',
|
|
|
+ gap: PANEL_GAP,
|
|
|
+ margin: PANEL_MARGIN,
|
|
|
+ })
|
|
|
|
|
|
// Outside click / Escape close, one document listener while open (ContextMeter's pattern).
|
|
|
useEffect(() => {
|
|
|
if (!open) return
|
|
|
const onPointerDown = (e: PointerEvent): void => {
|
|
|
- if (e.target instanceof Node && rootRef.current?.contains(e.target) === true) return
|
|
|
+ if (!(e.target instanceof Node)) return
|
|
|
+ if (rootRef.current?.contains(e.target) === true) return
|
|
|
+ if (panelRef.current?.contains(e.target) === true) return
|
|
|
setOpen(false)
|
|
|
}
|
|
|
const onKeyDown = (e: KeyboardEvent): void => {
|
|
|
@@ -67,15 +82,6 @@ export function TurnUsagePanel({ usage, time, runMs, ttftMs, tokensPerSecond, t
|
|
|
: formatCacheHitPercent(usage.cacheReadTokens, usage.totalTokens - usage.outputTokens, 1)
|
|
|
const total = formatCompactCount(usage.totalTokens, t)
|
|
|
const routes = usage.routes?.map(route => `${route.provider}/${route.model}`).join(', ') ?? ''
|
|
|
- const clock = formatMessageClock(time, t, day)
|
|
|
- const metrics: string[] = [clock]
|
|
|
- if (runMs !== undefined) metrics.push(t('message.ranFor', { duration: formatRunDuration(runMs, t) }))
|
|
|
- metrics.push(t('message.turnUsage.consumed', { total }))
|
|
|
- if (cacheHit !== null) metrics.push(t('message.turnUsage.cacheHitRate', { percent: cacheHit }))
|
|
|
- if (tokensPerSecond !== undefined) {
|
|
|
- metrics.push(t('message.turnUsage.speed', { tps: formatTokensPerSecond(tokensPerSecond) }))
|
|
|
- }
|
|
|
- if (ttftMs !== undefined) metrics.push(t('message.ttft', { seconds: formatLatencySeconds(ttftMs) }))
|
|
|
|
|
|
return (
|
|
|
<span ref={rootRef} className={css.root}>
|
|
|
@@ -86,21 +92,22 @@ export function TurnUsagePanel({ usage, time, runMs, ttftMs, tokensPerSecond, t
|
|
|
aria-expanded={open}
|
|
|
onClick={() => { setOpen(!open) }}
|
|
|
>
|
|
|
- {metrics.map((metric, index) => (
|
|
|
- <Fragment key={metric}>
|
|
|
- {index > 0 && (
|
|
|
- <>
|
|
|
- {' '}
|
|
|
- <span className={css.dot} aria-hidden>·</span>
|
|
|
- {' '}
|
|
|
- </>
|
|
|
- )}
|
|
|
- {metric}
|
|
|
- </Fragment>
|
|
|
- ))}
|
|
|
+ <IconDataOutline16 />
|
|
|
+ <span className={css.label}>
|
|
|
+ {cacheHit === null
|
|
|
+ ? t('message.turnUsage.consumed', { total })
|
|
|
+ : `${t('message.turnUsage.consumed', { total })} · ${t('message.turnUsage.cacheHitRate', { percent: cacheHit })}`}
|
|
|
+ </span>
|
|
|
</button>
|
|
|
- {open && (
|
|
|
- <div className={css.panel} role="dialog" aria-label={t('message.turnUsage.title')}>
|
|
|
+ {open && createPortal(
|
|
|
+ <div
|
|
|
+ ref={panelRef}
|
|
|
+ className={css.panel}
|
|
|
+ role="dialog"
|
|
|
+ aria-label={t('message.turnUsage.title')}
|
|
|
+ style={pos ?? MEASURE_STYLE}
|
|
|
+ >
|
|
|
+ <div className={css.title}>{t('message.turnUsage.title')}</div>
|
|
|
<dl className={css.details} data-turn-usage-details>
|
|
|
{routes !== '' && (
|
|
|
<>
|
|
|
@@ -108,6 +115,12 @@ export function TurnUsagePanel({ usage, time, runMs, ttftMs, tokensPerSecond, t
|
|
|
<dd className={css.route}>{routes}</dd>
|
|
|
</>
|
|
|
)}
|
|
|
+ {cacheHit !== null && (
|
|
|
+ <>
|
|
|
+ <dt>{t('message.turnUsage.cacheHit')}</dt>
|
|
|
+ <dd>{`${cacheHit}%`}</dd>
|
|
|
+ </>
|
|
|
+ )}
|
|
|
<dt>{t('message.turnUsage.input')}</dt>
|
|
|
<dd>{formatExactCount(usage.uncachedInputTokens, t)}</dd>
|
|
|
{usage.cacheReadTokens !== undefined && (
|
|
|
@@ -131,16 +144,12 @@ export function TurnUsagePanel({ usage, time, runMs, ttftMs, tokensPerSecond, t
|
|
|
</span>
|
|
|
)}
|
|
|
</dd>
|
|
|
- {cacheHit !== null && (
|
|
|
- <>
|
|
|
- <dt>{t('message.turnDetails.cacheHit')}</dt>
|
|
|
- <dd>{`${cacheHit}%`}</dd>
|
|
|
- </>
|
|
|
- )}
|
|
|
+ <div className={css.separator} aria-hidden />
|
|
|
<dt className={css.totalLabel}>{t('message.turnUsage.total')}</dt>
|
|
|
<dd className={css.totalValue}>{formatExactCount(usage.totalTokens, t)}</dd>
|
|
|
</dl>
|
|
|
- </div>
|
|
|
+ </div>,
|
|
|
+ document.body,
|
|
|
)}
|
|
|
</span>
|
|
|
)
|