fix(web): localize stats and close stale context meter
This commit is contained in:
@@ -334,7 +334,7 @@ export function apply(ctx: Context): void {
|
||||
}, ChatView)
|
||||
|
||||
// Session stats stick with the composer (composer.dock = stats-line family).
|
||||
slots.register({ name: 'conversation.composer.dock', id: 'stats', order: 0 }, StatsLine)
|
||||
slots.register({ name: 'conversation.composer.dock', id: 'stats', order: 0, locale: NS }, StatsLine)
|
||||
|
||||
// Class-plugin mount (packages/AGENTS.md service form): the service
|
||||
// registers itself as `conversation` and lives on its own child fiber.
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ConversationSnapshot, UseProjection } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ContextPressureProjection, TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client'
|
||||
import type { ComposerBarProps } from '../contract/slots.ts'
|
||||
import { formatTokensPerSecond } from './message-chrome.ts'
|
||||
import { assistantStepReading } from './turn-metrics.ts'
|
||||
import css from './StatsLine.module.css'
|
||||
@@ -151,24 +152,32 @@ export function contextOccupancy(
|
||||
export interface StatsLineProps {
|
||||
useSession: SnapshotSelectorHook<ConversationSnapshot>
|
||||
useProjection: UseProjection
|
||||
/** The owning dock's locale seat. */
|
||||
t: ComposerBarProps['t']
|
||||
}
|
||||
|
||||
export const StatsLine = memo(function StatsLine({ useSession, useProjection }: StatsLineProps) {
|
||||
export const StatsLine = memo(function StatsLine({ useSession, useProjection, t }: StatsLineProps) {
|
||||
const nodes = useSession(s => s.nodes)
|
||||
const usage = useProjection('tokenUsage')
|
||||
const stats = useMemo(() => deriveStats(nodes), [nodes])
|
||||
// Pipe-separated groups (figma stats strip); a group with no data drops out whole.
|
||||
const groups: string[] = []
|
||||
if (stats.steps > 0) {
|
||||
groups.push(`${stats.turns} turns · ${stats.steps} steps`)
|
||||
groups.push(t('stats.counts', { turns: stats.turns, steps: stats.steps }))
|
||||
const durations: string[] = []
|
||||
if (stats.llmMs > 0) durations.push(`LLM ${formatDuration(stats.llmMs)}`)
|
||||
if (stats.toolMs > 0) durations.push(`Tool call ${formatDuration(stats.toolMs)}`)
|
||||
if (stats.llmMs > 0) durations.push(t('stats.llm', { duration: formatDuration(stats.llmMs) }))
|
||||
if (stats.toolMs > 0) durations.push(t('stats.toolCall', { duration: formatDuration(stats.toolMs) }))
|
||||
if (durations.length > 0) groups.push(durations.join(' · '))
|
||||
// Window-scoped like the wall times above: averages describe loaded steps.
|
||||
const speeds: string[] = []
|
||||
if (stats.ttftSteps > 0) speeds.push(`TTFT avg ${formatDuration(stats.ttftMs / stats.ttftSteps)}`)
|
||||
if (stats.decodeMs > 0) speeds.push(`${formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000))} tok/s`)
|
||||
if (stats.ttftSteps > 0) {
|
||||
speeds.push(t('stats.ttftAverage', { duration: formatDuration(stats.ttftMs / stats.ttftSteps) }))
|
||||
}
|
||||
if (stats.decodeMs > 0) {
|
||||
speeds.push(t('stats.tokensPerSecond', {
|
||||
throughput: formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)),
|
||||
}))
|
||||
}
|
||||
if (speeds.length > 0) groups.push(speeds.join(' · '))
|
||||
}
|
||||
// Context occupancy deliberately lives on the composer's ContextMeter ring,
|
||||
@@ -178,11 +187,11 @@ export const StatsLine = memo(function StatsLine({ useSession, useProjection }:
|
||||
if (usage !== undefined
|
||||
&& (stats.steps > 0 || billedInputTokens(usage) > 0 || usage.outputTokens > 0)) {
|
||||
const cacheHit = cacheHitPercent(usage)
|
||||
if (cacheHit !== null) groups.push(`Cache hit ${cacheHit}%`)
|
||||
groups.push(
|
||||
`Input ${formatTokens(billedInputTokens(usage))} tok`
|
||||
+ ` · Output ${formatTokens(usage.outputTokens)} tok`,
|
||||
)
|
||||
if (cacheHit !== null) groups.push(t('stats.cacheHit', { percent: cacheHit }))
|
||||
groups.push(t('stats.tokens', {
|
||||
input: formatTokens(billedInputTokens(usage)),
|
||||
output: formatTokens(usage.outputTokens),
|
||||
}))
|
||||
}
|
||||
const line = groups.join(' | ')
|
||||
// The row elides with ellipsis when overlong; a delayed hover tooltip carries
|
||||
@@ -194,6 +203,7 @@ export const StatsLine = memo(function StatsLine({ useSession, useProjection }:
|
||||
if (el === null) return
|
||||
const measure = () => { setTruncated(el.scrollWidth > el.clientWidth) }
|
||||
measure()
|
||||
if (typeof ResizeObserver === 'undefined') return
|
||||
const observer = new ResizeObserver(measure)
|
||||
observer.observe(el)
|
||||
return () => { observer.disconnect() }
|
||||
|
||||
@@ -28,6 +28,13 @@ export const zh = {
|
||||
'context.system': '系统提示词',
|
||||
'context.tools': '工具',
|
||||
'context.messages': '对话消息',
|
||||
'stats.counts': '{turns} 轮 · {steps} 步',
|
||||
'stats.llm': 'LLM {duration}',
|
||||
'stats.toolCall': '工具调用 {duration}',
|
||||
'stats.ttftAverage': '首 token 平均 {duration}',
|
||||
'stats.tokensPerSecond': '{throughput} tok/s',
|
||||
'stats.cacheHit': '缓存命中 {percent}%',
|
||||
'stats.tokens': '输入 {input} tok · 输出 {output} tok',
|
||||
'settings.enter.title': '繁忙时 Enter 键行为',
|
||||
'settings.enter.description': '仅在智能体运行时生效;Cmd/Ctrl+Enter 使用另一行为',
|
||||
'settings.enter.queue': '排队发送',
|
||||
@@ -148,6 +155,13 @@ export const en = {
|
||||
'context.system': 'System prompt',
|
||||
'context.tools': 'Tools',
|
||||
'context.messages': 'Messages',
|
||||
'stats.counts': '{turns} turns · {steps} steps',
|
||||
'stats.llm': 'LLM {duration}',
|
||||
'stats.toolCall': 'Tool call {duration}',
|
||||
'stats.ttftAverage': 'TTFT avg {duration}',
|
||||
'stats.tokensPerSecond': '{throughput} tok/s',
|
||||
'stats.cacheHit': 'Cache hit {percent}%',
|
||||
'stats.tokens': 'Input {input} tok · Output {output} tok',
|
||||
'settings.enter.title': 'Enter behavior while busy',
|
||||
'settings.enter.description': 'Busy only; Cmd/Ctrl+Enter uses the other behavior',
|
||||
'settings.enter.queue': 'Queue',
|
||||
|
||||
@@ -42,10 +42,18 @@ export function ContextMeter({ useProjection, t }: ContextMeterProps) {
|
||||
const breakdown = useProjection('contextBreakdown')
|
||||
const [open, setOpen] = useState(false)
|
||||
const rootRef = useRef<HTMLSpanElement | null>(null)
|
||||
const context = contextOccupancy(pressure)
|
||||
const available = context !== null
|
||||
|
||||
// A model switch can temporarily remove capacity while this component stays
|
||||
// mounted. Close the now-unavailable panel instead of preserving stale UI.
|
||||
useEffect(() => {
|
||||
if (!available && open) setOpen(false)
|
||||
}, [available, open])
|
||||
|
||||
// Outside click / Escape close, one document listener while open (Menu's pattern).
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (!open || !available) return
|
||||
const onPointerDown = (e: PointerEvent): void => {
|
||||
if (e.target instanceof Node && rootRef.current?.contains(e.target) === true) return
|
||||
setOpen(false)
|
||||
@@ -59,9 +67,8 @@ export function ContextMeter({ useProjection, t }: ContextMeterProps) {
|
||||
document.removeEventListener('pointerdown', onPointerDown)
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
}
|
||||
}, [open])
|
||||
}, [available, open])
|
||||
|
||||
const context = contextOccupancy(pressure)
|
||||
if (context === null) return null
|
||||
const percent = context.percent
|
||||
const reading = `${percent}%`
|
||||
|
||||
Reference in New Issue
Block a user