Merge remote-tracking branch 'origin/master' into feat/todo-multi-in-progress

# Conflicts:
#	docs/core-data-structures/session.i18n.yaml
#	packages/client/ui-conversation/README.i18n.yaml
This commit is contained in:
Chinesezjc
2026-08-06 12:00:33 +08:00
90 changed files with 2789 additions and 440 deletions
@@ -330,7 +330,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.
@@ -30,6 +30,10 @@ export interface AssistantMarkdownProps {
/** Turn wall time in ms for the IconActions run-time label; omitted when the
* turn's triggering input is outside the loaded window. */
runMs?: number | undefined
/** Turn first-step TTFT in ms for the IconActions label; omitted when unrecorded. */
ttftMs?: number | undefined
/** Turn decode throughput for the IconActions label; omitted when unrecorded. */
tokensPerSecond?: number | undefined
/** Event sequence used as the fork boundary; omitted while streaming. */
seq?: number | undefined
/** Fork the session through this finalized message's completed turn when eligible. */
@@ -82,7 +86,7 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass
}
export const AssistantMarkdown = memo(function AssistantMarkdown({
blocks, streaming, interrupted, time, runMs, seq, onFork, forkUnavailable, t,
blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, t,
}: AssistantMarkdownProps) {
// Stable per locale revision (t identity changes on switch): a fresh object
// per render would rebuild MarkdownText's component table every chunk.
@@ -125,6 +129,8 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
text={copyText(blocks)}
time={time}
runMs={runMs}
ttftMs={ttftMs}
tokensPerSecond={tokensPerSecond}
clock="end"
onBranch={onFork === undefined || seq === undefined ? undefined : () => { onFork(seq) }}
branchUnavailable={forkUnavailable}
@@ -36,6 +36,7 @@ import { GenericCommandCard } from './GenericCommandCard.tsx'
import { GenericToolCard } from './GenericToolCard.tsx'
import { MessageItem, PendingSteeringBubble } from './MessageItem.tsx'
import { formatRunDuration } from './message-chrome.ts'
import { deriveTurnMetrics } from './turn-metrics.ts'
import css from './ChatView.module.css'
const FOLLOW_THRESHOLD = 24
@@ -362,6 +363,7 @@ export function ChatView({
const actionSeqs = useMemo(() => assistantActionsSeqs(nodes), [nodes])
const branchSeqs = useMemo(() => messageBranchSeqs(nodes, turnEnds), [nodes, turnEnds])
const runningTurnStart = useMemo(() => runningTurnStartTime(turnTimings), [turnTimings])
const turnMetrics = useMemo(() => deriveTurnMetrics(nodes), [nodes])
const listRef = useRef<HTMLDivElement | null>(null)
const columnRef = useRef<HTMLDivElement | null>(null)
@@ -599,6 +601,9 @@ export function ChatView({
const node: ConversationNode = item.node
if (node.kind === 'assistant') {
const timing = actionSeqs.has(node.seq) ? turnTimings.get(node.turn) : undefined
// Metrics gate on the settled in-window timing: turn/start loaded means
// every step of the turn is loaded, so first-step TTFT is genuine.
const metrics = timing?.endTime === undefined ? undefined : turnMetrics.get(node.turn)
return (
<AssistantMarkdown
blocks={node.blocks}
@@ -608,6 +613,8 @@ export function ChatView({
runMs={timing?.endTime === undefined
? undefined
: Math.max(0, timing.endTime - timing.startTime)}
ttftMs={metrics?.ttftMs}
tokensPerSecond={metrics?.tokensPerSecond}
seq={node.seq}
onFork={forkAt}
forkUnavailable={!branchSeqs.has(node.seq)}
@@ -6,7 +6,7 @@ import {
IconBranchOutline16, IconCheckOutline16, IconCopyOutline16, Tooltip, writeClipboard,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { formatMessageClock, formatRunDuration } from './message-chrome.ts'
import { formatLatencySeconds, formatMessageClock, formatRunDuration, formatTokensPerSecond } from './message-chrome.ts'
import { useCalendarDay } from './use-calendar-day.ts'
import css from './MessageIconActions.module.css'
@@ -17,6 +17,10 @@ export interface MessageIconActionsProps {
time?: number | undefined
/** Turn wall time in ms, appended to the clock as `· Ran for 15s`; omitted when the turn's start is unknown. */
runMs?: number | undefined
/** Turn first-step TTFT in ms, appended as `· TTFT 1.2s`; omitted when unrecorded. */
ttftMs?: number | undefined
/** Turn decode throughput, appended as `· 34 tok/s`; omitted when unrecorded. */
tokensPerSecond?: number | undefined
/** Clock before icons (user) or after (assistant). */
clock: 'start' | 'end'
/** Fork the session at this message; omission hides the branch action. */
@@ -37,7 +41,7 @@ export interface MessageIconActionsProps {
* @returns The actions row element.
*/
export function MessageIconActions({
text, time, runMs, clock, onBranch, branchUnavailable = false, showBranch = true, className, t,
text, time, runMs, ttftMs, tokensPerSecond, clock, onBranch, branchUnavailable = false, showBranch = true, className, t,
}: MessageIconActionsProps) {
const day = useCalendarDay()
const reasonId = useId()
@@ -67,15 +71,36 @@ export function MessageIconActions({
}, 1000)
})
}, [copied, text])
// The dot is decorative and stays hidden, but its margins separate the
// readings only on screen: without the flanking spaces a reader hears one
// run-on string ("Ran for 13sTTFT 0.2s12 tok/s") instead of three facts.
const clockEl = time === undefined ? null : (
<span className={clock === 'start' ? css.timeStart : css.timeEnd}>
{formatMessageClock(time, t, day)}
{runMs !== undefined && (
<>
{' '}
<span className={css.runTimeDot} aria-hidden>·</span>
{' '}
{t('message.ranFor', { duration: formatRunDuration(runMs, t) })}
</>
)}
{ttftMs !== undefined && (
<>
{' '}
<span className={css.runTimeDot} aria-hidden>·</span>
{' '}
{t('message.ttft', { seconds: formatLatencySeconds(ttftMs) })}
</>
)}
{tokensPerSecond !== undefined && (
<>
{' '}
<span className={css.runTimeDot} aria-hidden>·</span>
{' '}
{t('message.tokensPerSecond', { tps: formatTokensPerSecond(tokensPerSecond) })}
</>
)}
</span>
)
return (
@@ -2,10 +2,14 @@
// Mounted on 'conversation.composer.dock' so it sticks with the composer in the
// active conversation scrollport (see ConversationRoot data-conversation-scroll).
import { Fragment, memo, useMemo } from 'react'
import { Fragment, memo, useLayoutEffect, useMemo, useRef, useState } from 'react'
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'
interface WindowStats {
@@ -15,6 +19,14 @@ interface WindowStats {
llmMs: number
/** Summed tool wall time (tool/call → tool/result); 0 when no pair is in-window. */
toolMs: number
/** Summed first-token latency over `ttftSteps`; 0 when no step records it. */
ttftMs: number
/** Steps carrying a recorded TTFT. */
ttftSteps: number
/** Summed decode wall time over steps that also report output tokens. */
decodeMs: number
/** Summed output tokens over the same decode-timed steps. */
decodeTokens: number
}
/**
@@ -32,6 +44,10 @@ export function deriveStats(nodes: ConversationSnapshot['nodes']): WindowStats {
let steps = 0
let llmMs = 0
let toolMs = 0
let ttftMs = 0
let ttftSteps = 0
let decodeMs = 0
let decodeTokens = 0
for (const node of nodes) {
if (node.kind === 'tool-result') {
if (node.callTime !== null) toolMs += Math.max(0, node.time - node.callTime)
@@ -43,8 +59,17 @@ export function deriveStats(nodes: ConversationSnapshot['nodes']): WindowStats {
if (node.timing !== undefined && node.timing.stepStartTime !== null) {
llmMs += Math.max(0, node.timing.completedTime - node.timing.stepStartTime)
}
const reading = assistantStepReading(node)
if (reading.ttftMs !== null) {
ttftMs += reading.ttftMs
ttftSteps += 1
}
if (reading.decodeMs !== null && reading.outputTokens !== null) {
decodeMs += reading.decodeMs
decodeTokens += reading.outputTokens
}
}
return { turns: turns.size, steps, llmMs, toolMs }
return { turns: turns.size, steps, llmMs, toolMs, ttftMs, ttftSteps, decodeMs, decodeTokens }
}
/**
@@ -84,30 +109,41 @@ export function cacheHitPercent(usage: TokenUsageProjection): number | null {
: Math.round(usage.cacheReadTokens / denominator * 100)
}
/** Sum the three disjoint prompt-side billing buckets. */
function billedInputTokens(usage: TokenUsageProjection): number {
/**
* Sum the three disjoint prompt-side billing buckets.
* @param usage - the session's token-usage projection value.
* @returns billed input tokens.
*/
export function billedInputTokens(usage: TokenUsageProjection): number {
return usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens
}
interface ContextOccupancy {
percent: number
usedTokens: number
contextWindow: number
}
/**
* Approximate context occupancy, using the TUI's integer rounding and upper
* clamp. The numerator and capacity are independent last-wins projection
* fields, so this is a reference figure rather than an exact measurement of one
* request (see the token-meter README).
* clamp. The numerator is `projectedTokens` — the provider sample carried
* forward over the surface's movement since — so compaction shows immediately
* instead of waiting for the next request to report usage; it falls back to the
* bare sample only for a log whose projection predates that field. Numerator
* and capacity remain independent last-wins projection fields, so this is a
* reference figure rather than an exact measurement of one request (see the
* token-meter README).
* @param pressure - the session's context-pressure projection value.
* @returns occupancy and its denominator, or null until both values are known.
* @returns occupancy with its numerator and denominator, or null until both values are known.
*/
export function contextOccupancy(
pressure: ContextPressureProjection | undefined,
): ContextOccupancy | null {
if (pressure?.pressureTokens === undefined || pressure.contextWindow === undefined) return null
const usedTokens = pressure?.projectedTokens ?? pressure?.pressureTokens
if (usedTokens === undefined || pressure?.contextWindow === undefined) return null
return {
percent: Math.min(100, Math.round(pressure.pressureTokens / pressure.contextWindow * 100)),
percent: Math.min(100, Math.round(usedTokens / pressure.contextWindow * 100)),
usedTokens,
contextWindow: pressure.contextWindow,
}
}
@@ -116,46 +152,73 @@ 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 pressure = useProjection('contextPressure')
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(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(' · '))
}
const context = contextOccupancy(pressure)
if (context !== null) {
groups.push(`Context ${context.percent}% of ${formatTokens(context.contextWindow)}`)
}
// Context occupancy deliberately lives on the composer's ContextMeter ring,
// not here — one home per fact.
// Billing rides the durable projection, so these survive paging and
// compaction. Suppress the empty projection on a brand-new session.
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
// the full line, enabled only while content is actually clipped.
const rootRef = useRef<HTMLDivElement | null>(null)
const [truncated, setTruncated] = useState(false)
useLayoutEffect(() => {
const el = rootRef.current
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() }
}, [line])
if (groups.length === 0) return null
return (
<div className={css.root}>
{groups.map((group, i) => (
<Fragment key={group}>
{i > 0 && <><span className={css.sep} aria-hidden>|</span>{' '}</>}
<span>{group}</span>
</Fragment>
))}
</div>
<Tooltip label={line} side="top" delayMs={500} disabled={!truncated}>
<div ref={rootRef} className={css.root}>
{groups.map((group, i) => (
<Fragment key={group}>
{i > 0 && <><span className={css.sep} aria-hidden>|</span>{' '}</>}
<span>{group}</span>
</Fragment>
))}
</div>
</Tooltip>
)
})
@@ -48,6 +48,27 @@ export function formatRunDuration(ms: number, t: RunDurationTranslate): string {
: t('duration.seconds', { seconds })
}
/**
* Sub-turn latency figure: one decimal under ten seconds, whole seconds
* beyond. Unit-less so the locale template owns the second suffix.
* @param ms - Latency in milliseconds (negatives clamp to zero).
* @returns Display number in seconds without unit.
*/
export function formatLatencySeconds(ms: number): string {
const s = Math.max(0, ms) / 1000
return s < 10 ? String(Math.round(s * 10) / 10) : String(Math.round(s))
}
/**
* Decode-throughput figure: whole tokens from ten up, one decimal below.
* @param tps - Tokens per second.
* @returns Display number without unit.
*/
export function formatTokensPerSecond(tps: number): string {
const clamped = Math.max(0, tps)
return clamped >= 10 ? String(Math.round(clamped)) : String(Math.round(clamped * 10) / 10)
}
/**
* Compact local timestamp for message IconActions. Same calendar day →
* `HH:mm`; earlier this year → the `clock.md` date template + clock; other
@@ -0,0 +1,97 @@
// Latency/throughput folds shared by the settled turn footer and StatsLine.
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
/** Latency and decode-throughput readings for one turn's footer. */
export interface TurnMetrics {
/** First-step TTFT in ms; absent when that step carries no recorded timing. */
ttftMs?: number
/** Decode throughput over steps carrying both timing and provider usage. */
tokensPerSecond?: number
}
/** One assistant step's derivable latency facts; null marks an unrecorded part. */
export interface StepReading {
/** step/start → first token delta, in ms. */
ttftMs: number | null
/** First token delta → final message, in ms. */
decodeMs: number | null
/** Provider-reported completion tokens. */
outputTokens: number | null
}
interface UsageLike {
outputTokens?: number
}
type AssistantNode = Extract<ConversationSnapshot['nodes'][number], { kind: 'assistant' }>
function usageOutputTokens(usage: unknown): number | null {
if (typeof usage !== 'object' || usage === null) return null
const value = (usage as UsageLike).outputTokens
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null
}
/**
* Read one assistant node's TTFT, decode wall time, and output tokens.
* @param node - A settled assistant node.
* @returns Per-part readings with `null` for unrecorded values.
*/
export function assistantStepReading(node: AssistantNode): StepReading {
const timing = node.timing
const ttftMs = timing !== undefined && timing.stepStartTime !== null && timing.firstTokenTime !== null
? Math.max(0, timing.firstTokenTime - timing.stepStartTime)
: null
const decodeMs = timing !== undefined && timing.firstTokenTime !== null
? Math.max(0, timing.completedTime - timing.firstTokenTime)
: null
return { ttftMs, decodeMs, outputTokens: usageOutputTokens(node.usage) }
}
interface TurnFold {
firstStep: number
firstStepTtftMs: number | null
decodeMs: number
outputTokens: number
sampled: boolean
}
/**
* Fold assistant nodes into per-turn footer metrics.
*
* TTFT is the turn's lowest-step request-dispatch-to-first-token reading, so
* it is only meaningful when the turn's start is inside
* the loaded window (the caller gates on `turnTimings`, which shares that
* window). Throughput divides summed output tokens by summed decode wall time,
* counting only steps that carry both.
* @param nodes - Snapshot nodes of the loaded window.
* @returns Turn number → available metrics; turns with none are absent.
*/
export function deriveTurnMetrics(nodes: ConversationSnapshot['nodes']): Map<number, TurnMetrics> {
const folds = new Map<number, TurnFold>()
for (const node of nodes) {
if (node.kind !== 'assistant') continue
const reading = assistantStepReading(node)
let fold = folds.get(node.turn)
if (fold === undefined) {
fold = { firstStep: node.step, firstStepTtftMs: reading.ttftMs, decodeMs: 0, outputTokens: 0, sampled: false }
folds.set(node.turn, fold)
} else if (node.step < fold.firstStep) {
fold.firstStep = node.step
fold.firstStepTtftMs = reading.ttftMs
}
if (reading.decodeMs !== null && reading.outputTokens !== null) {
fold.decodeMs += reading.decodeMs
fold.outputTokens += reading.outputTokens
fold.sampled = true
}
}
const metrics = new Map<number, TurnMetrics>()
for (const [turn, fold] of folds) {
const entry: TurnMetrics = {}
if (fold.firstStepTtftMs !== null) entry.ttftMs = fold.firstStepTtftMs
if (fold.sampled && fold.decodeMs > 0) entry.tokensPerSecond = fold.outputTokens / (fold.decodeMs / 1000)
if (entry.ttftMs !== undefined || entry.tokensPerSecond !== undefined) metrics.set(turn, entry)
}
return metrics
}
@@ -23,6 +23,18 @@ export const zh = {
'input.stop': '停止生成',
'input.send': '发送消息',
'input.accessMode': '访问模式,当前:{name}',
'context.aria': '上下文已用 {percent}',
'context.used': '上下文已用',
'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': '排队发送',
@@ -71,6 +83,8 @@ export const zh = {
'message.retry.failure': '失败原因:',
'message.turnError': '本轮运行失败',
'message.ranFor': '用时 {duration}',
'message.ttft': '首 token {seconds}秒',
'message.tokensPerSecond': '{tps} tok/s',
'duration.seconds': '{seconds}秒',
'duration.minutes': '{minutes}分{seconds}秒',
'command.running': '执行中…',
@@ -136,6 +150,18 @@ export const en = {
'input.stop': 'Stop generating',
'input.send': 'Send message',
'input.accessMode': 'Access mode, current: {name}',
'context.aria': '{percent} of context used',
'context.used': 'of context used',
'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',
@@ -184,6 +210,8 @@ export const en = {
'message.retry.failure': 'Failure reason: ',
'message.turnError': 'This turn failed',
'message.ranFor': 'Ran for {duration}',
'message.ttft': 'TTFT {seconds}s',
'message.tokensPerSecond': '{tps} tok/s',
'duration.seconds': '{seconds}s',
'duration.minutes': '{minutes}m {seconds}s',
'command.running': 'Running…',
@@ -0,0 +1,147 @@
/* Context-occupancy ring beside the send button plus its click-open breakdown
panel (menu surface: r12, inverted hairline, shadow-lv3). */
.root {
position: relative;
display: inline-flex;
}
/* Same 28px circular hit target family as the composer's attach button. */
.trigger {
display: grid;
place-items: center;
flex: none;
width: 28px;
height: 28px;
border: none;
border-radius: 999px;
background: transparent;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
}
.trigger:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.track {
fill: none;
stroke: var(--dsw-alias-border-l3);
stroke-width: 2;
}
.fill {
fill: none;
stroke: var(--dsw-alias-label-tertiary);
stroke-width: 2;
stroke-linecap: round;
}
.panel {
position: absolute;
bottom: calc(100% + 8px);
right: 0;
z-index: 100;
box-sizing: border-box;
width: 264px;
padding: 12px;
border: 1px solid var(--dsw-alias-border-inverted);
border-radius: 12px;
background: var(--dsw-specific-menu);
box-shadow: var(--dsw-shadow-lv3);
font-size: 12px;
line-height: 20px;
color: var(--dsw-alias-label-secondary);
cursor: default;
}
.header {
display: flex;
align-items: center;
gap: 6px;
}
.figures {
margin-left: auto;
font-weight: 500;
font-variant-numeric: tabular-nums;
color: var(--dsw-alias-label-primary);
}
.percent {
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
.headline {
color: var(--dsw-alias-label-tertiary);
}
/* The headline brackets the reading, so the side a locale leaves empty must
drop out of the flex row rather than spend a gap. */
.headline:empty {
display: none;
}
.bar {
display: flex;
gap: 1px;
margin: 10px 0 12px;
height: 4px;
border-radius: 999px;
background: var(--dsw-alias-interactive-bg-hover);
overflow: hidden;
}
.segment {
flex: none;
min-width: 2px;
height: 100%;
border-radius: 1px;
background: var(--meter-tint, var(--dsw-alias-label-tertiary));
}
.swatch {
display: inline-block;
margin-right: 6px;
width: 8px;
height: 8px;
border-radius: 2px;
background: var(--meter-tint);
vertical-align: baseline;
}
.colorSystem {
--meter-tint: var(--dsw-static-neutral-bluish-400);
}
.colorTools {
/* The design platform ships no purple static token; violet-400 literal. */
--meter-tint: rgb(167, 139, 250);
}
.colorMessages {
--meter-tint: var(--dsw-static-blue-450);
}
.rows {
margin: 6px 0 0;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 2px 0;
}
.row dt {
color: var(--dsw-alias-label-secondary);
}
.row dd {
margin: 0;
font-variant-numeric: tabular-nums;
color: var(--dsw-alias-label-primary);
}
@@ -0,0 +1,153 @@
/** Composer context-occupancy meter: a ring beside the send button fed by the
* `contextPressure` projection, with a click-open panel of the heuristic
* `contextBreakdown` composition (system prompt, tools, conversation).
* Renders nothing until a provider reports both pressure and a route capacity
* (same gate as the stats row used). */
import { useEffect, useRef, useState } from 'react'
import type { UseProjection } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: the `contextPressure` / `contextBreakdown` projection key merges.
import type {} from '@deepseek-ai/dsh-token-meter/client'
import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ComposerBarProps } from '../contract/slots.ts'
import { contextOccupancy, formatTokens } from '../chat/StatsLine.tsx'
import css from './ContextMeter.module.css'
/** Ring geometry: 14px viewBox, 2px stroke. */
const RADIUS = 5.5
const CIRCUMFERENCE = 2 * Math.PI * RADIUS
/**
* Marker the localized occupancy sentence is split on, so the panel headline
* keeps the reading in its own tone while each locale still owns the word
* order (`45% of context used` / `上下文已用 45%`).
*/
const READING_SLOT = '\u0000'
/** Panel legend rows, in bar-segment order; each color class carries the shared swatch/segment tint. */
const ROWS = [
{ key: 'systemTokens', label: 'context.system', color: css.colorSystem },
{ key: 'toolsTokens', label: 'context.tools', color: css.colorTools },
{ key: 'messageTokens', label: 'context.messages', color: css.colorMessages },
] as const
export interface ContextMeterProps {
useProjection: UseProjection
/** The owning bar's locale seat, passed down as a plain prop. */
t: ComposerBarProps['t']
}
export function ContextMeter({ useProjection, t }: ContextMeterProps) {
const pressure = useProjection('contextPressure')
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 || !available) return
const onPointerDown = (e: PointerEvent): void => {
if (e.target instanceof Node && rootRef.current?.contains(e.target) === true) return
setOpen(false)
}
const onKeyDown = (e: KeyboardEvent): void => {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('pointerdown', onPointerDown)
document.addEventListener('keydown', onKeyDown)
return () => {
document.removeEventListener('pointerdown', onPointerDown)
document.removeEventListener('keydown', onKeyDown)
}
}, [available, open])
if (context === null) return null
const percent = context.percent
const reading = `${percent}%`
const [headBefore = '', headAfter = ''] = t('context.aria', { percent: READING_SLOT })
.split(READING_SLOT)
.map(part => part.trim())
// The bar's overall length stays the provider-exact percent; the heuristic
// breakdown only proportions its colored parts. A zero-width part is dropped
// instead of rendered: `.segment`'s min-width keeps a hairline part visible,
// which at 0% occupancy would draw a filled bar over an empty context.
const breakdownTotal = breakdown === undefined
? 0
: breakdown.systemTokens + breakdown.toolsTokens + breakdown.messageTokens
const parts = breakdown === undefined || breakdownTotal === 0
? [{ key: 'total', color: undefined, width: percent }]
: ROWS.map(row => ({ key: row.key, color: row.color, width: percent * breakdown[row.key] / breakdownTotal }))
const segments = parts.filter(part => part.width > 0)
return (
<span ref={rootRef} className={css.root}>
<Tooltip label={t('context.aria', { percent: reading })} side="top" delayMs={200} disabled={open}>
<button
type="button"
className={css.trigger}
aria-label={t('context.aria', { percent: reading })}
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => { setOpen(!open) }}
>
<svg viewBox="0 0 14 14" width="14" height="14" aria-hidden>
<circle className={css.track} cx="7" cy="7" r={RADIUS} />
<circle
className={css.fill}
cx="7"
cy="7"
r={RADIUS}
strokeDasharray={`${CIRCUMFERENCE * percent / 100} ${CIRCUMFERENCE}`}
transform="rotate(-90 7 7)"
/>
</svg>
</button>
</Tooltip>
{open && (
<div className={css.panel} role="dialog" aria-label={t('context.used')}>
<div className={css.header}>
{/* Empty sides collapse through `.headline:empty` so the locale that
needs no leading (or trailing) text spends no header gap. */}
<span className={css.headline}>{headBefore}</span>
<span className={css.percent}>{reading}</span>
<span className={css.headline}>{headAfter}</span>
<span className={css.figures}>
{`~${formatTokens(context.usedTokens)} / ${formatTokens(context.contextWindow)}`}
</span>
</div>
<div className={css.bar}>
{segments.map(segment => (
<div
key={segment.key}
className={segment.color === undefined ? css.segment : `${css.segment} ${segment.color}`}
style={{ width: `${segment.width}%` }}
/>
))}
</div>
{breakdown !== undefined && (
<dl className={css.rows}>
{ROWS.map(row => (
<div key={row.key} className={css.row}>
<dt>
<span className={`${css.swatch} ${row.color}`} aria-hidden />
{t(row.label)}
</dt>
<dd>{`~${formatTokens(breakdown[row.key])}`}</dd>
</div>
))}
</dl>
)}
</div>
)}
</span>
)
}
@@ -19,6 +19,7 @@ import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
import type { ComposerBarProps } from '../contract/slots.ts'
import { deriveDecorations } from '../input/decorations.ts'
import type { DraftDecorations } from '../input/decorations.ts'
import { ContextMeter } from './ContextMeter.tsx'
import { PermissionSelect } from './PermissionSelect.tsx'
import css from './InputBar.module.css'
@@ -512,6 +513,7 @@ export function InputBar({
<div className={css.trailing}>
{rightItems}
{renderSlot('conversation.input.model', { locked })}
<ContextMeter useProjection={useProjection} t={t} />
{/* {machineBusy && <span className={css.pending} data-input-pending aria-label="处理中" />} */}
<Tooltip label={primaryLabel} side="top" delayMs={500}>
<button