返回 DeepSeek-Reasonix
ContextPanel.tsx
根目录 / desktop / frontend / src / components / ContextPanel.tsx
1 // ContextPanel shows the active tab's context gauge and token usage.
2 // All visible text is routed through the i18n dictionary.
3 import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
4 import { asArray } from "../lib/array";
5 import { app } from "../lib/bridge";
6 import { useI18n, type Locale, type Translator } from "../lib/i18n";
7 import { formatMoneyLocalized } from "../lib/money";
8 import { formatTokens, formatOptionalTokens } from "../lib/format";
9 import type { DictKey } from "../locales/en";
10 import type { BalanceInfo, ContextInfo, ContextPanelInfo, UsageSourceStats, WireUsage } from "../lib/types";
11
12 interface ContextPanelProps {
13 tabId?: string;
14 context?: ContextInfo;
15 usage?: WireUsage;
16 sessionTokens?: number;
17 sessionCost?: number;
18 sessionCurrency?: string;
19 sessionTurns?: number;
20 turnTokens?: number;
21 turnCost?: number;
22 balance?: BalanceInfo;
23 sessionGen?: number;
24 refreshKey?: number;
25 // Monotonic counter bumped by EVERY usage event (executor and subagent).
26 // The executor-gated `usage` prop freezes during sub-agent runs, which used
27 // to pin 会话指标/用量分析 for minutes; this keeps the snapshot ticking.
28 usageSeq?: number;
29 }
30
31
32 function fmtDuration(ms: number, t: Translator): string {
33 if (ms <= 0) return "-";
34 const totalSeconds = Math.max(1, Math.round(ms / 1000));
35 const minutes = Math.floor(totalSeconds / 60);
36 const seconds = totalSeconds % 60;
37 if (minutes <= 0) return t("context.durationSeconds", { seconds });
38 return t("context.durationMinutesSeconds", { minutes, seconds });
39 }
40
41
42 interface MetricTokenDisplay {
43 display: string;
44 exact: string;
45 }
46
47 function numberLocale(locale: Locale | string): string {
48 if (locale === "zh") return "zh-CN";
49 if (locale === "zh-TW") return "zh-TW";
50 return "en";
51 }
52
53 export function formatMetricTokens(tokens: number | undefined, locale: Locale | string): MetricTokenDisplay {
54 if (typeof tokens !== "number" || tokens <= 0) {
55 return { display: "-", exact: "-" };
56 }
57 const tag = numberLocale(locale);
58 const exact = tokens.toLocaleString(tag);
59 return { display: exact, exact };
60 }
61
62 function fmtUsageCacheRate(usage?: WireUsage): string {
63 if (!usage) return "-";
64 const denom = usage.cacheHitTokens + usage.cacheMissTokens;
65 if (denom <= 0) return "-";
66 return `${((usage.cacheHitTokens / denom) * 100).toFixed(2)}%`;
67 }
68
69 export function formatCacheHitRate(hitTokens: number, missTokens: number): string {
70 const denom = hitTokens + missTokens;
71 if (denom <= 0) return "-";
72 return `${((hitTokens / denom) * 100).toFixed(2)}%`;
73 }
74
75 type MetricTone = "accent" | "good" | "notice" | "warn";
76 type UsageAnalysisView = "source" | "type";
77 type ContextUsageRefreshFields = Pick<
78 WireUsage,
79 "totalTokens" | "promptTokens" | "completionTokens" | "reasoningTokens" | "sessionCacheHitTokens" | "sessionCacheMissTokens"
80 >;
81
82 export function contextUsageRefreshKey(usage?: ContextUsageRefreshFields): string {
83 if (!usage) return "";
84 return [
85 usage.totalTokens ?? 0,
86 usage.promptTokens ?? 0,
87 usage.completionTokens ?? 0,
88 usage.reasoningTokens ?? 0,
89 usage.sessionCacheHitTokens ?? 0,
90 usage.sessionCacheMissTokens ?? 0,
91 ].join(":");
92 }
93
94 export function cacheHitTone(hitTokens: number, missTokens: number): MetricTone | undefined {
95 const denom = hitTokens + missTokens;
96 if (denom <= 0) return undefined;
97 const pct = (hitTokens / denom) * 100;
98 if (pct >= 80) return "good";
99 if (pct >= 60) return "notice";
100 return "warn";
101 }
102
103 function formatSharePercent(value: number, total: number): string {
104 if (total <= 0 || value <= 0) return "-";
105 const pct = (value / total) * 100;
106 if (pct > 0 && pct < 1) return "<1%";
107 return `${Math.round(pct)}%`;
108 }
109
110 interface ContextWindowStatus {
111 tone: "good" | "notice" | "warn";
112 key: DictKey;
113 }
114
115 export function contextCostDisplay({
116 info,
117 sessionCost,
118 sessionCurrency,
119 usage,
120 }: {
121 info?: Pick<ContextPanelInfo, "sessionCost" | "sessionCurrency" | "sessionCostUsd"> | null;
122 sessionCost?: number;
123 sessionCurrency?: string;
124 usage?: Pick<WireUsage, "cost" | "costUsd" | "currency">;
125 }): { amount: number; currency?: string } {
126 // Session-scoped sources only: this value renders under the 会话费用 label,
127 // and falling back to a single request's usage.cost silently displayed one
128 // turn's spend as the whole session's. usage now contributes currency only.
129 if (info?.sessionCost && info.sessionCost > 0) {
130 return { amount: info.sessionCost, currency: info.sessionCurrency || sessionCurrency || usage?.currency };
131 }
132 if (sessionCost && sessionCost > 0) {
133 return { amount: sessionCost, currency: sessionCurrency || info?.sessionCurrency || usage?.currency };
134 }
135 if (info?.sessionCostUsd && info.sessionCostUsd > 0) {
136 return { amount: info.sessionCostUsd, currency: info.sessionCurrency || sessionCurrency || usage?.currency };
137 }
138 return { amount: 0, currency: info?.sessionCurrency || sessionCurrency || usage?.currency };
139 }
140
141 // contextSessionCache picks the session-cumulative cache hit/miss pair for the
142 // panel's session average. The shared ContextInfo is refreshed after every
143 // usage event and also drives StatusBar, so prefer it over the panel's
144 // independently throttled snapshot. Panel telemetry remains the all-sources
145 // fallback for callers without live context; executor-only wire counters only
146 // bridge the pre-refresh gap. The pair always comes from one source so the
147 // computed rate never mixes scopes.
148 export function contextSessionCache(
149 info?: Pick<ContextPanelInfo, "sessionCacheHitTokens" | "sessionCacheMissTokens"> | null,
150 context?: Pick<ContextInfo, "cacheHitTokens" | "cacheMissTokens">,
151 usage?: Pick<WireUsage, "sessionCacheHitTokens" | "sessionCacheMissTokens">,
152 ): { hit: number; miss: number } {
153 const ctxHit = context?.cacheHitTokens ?? 0;
154 const ctxMiss = context?.cacheMissTokens ?? 0;
155 if (ctxHit + ctxMiss > 0) return { hit: ctxHit, miss: ctxMiss };
156 const infoHit = info?.sessionCacheHitTokens ?? 0;
157 const infoMiss = info?.sessionCacheMissTokens ?? 0;
158 if (infoHit + infoMiss > 0) return { hit: infoHit, miss: infoMiss };
159 return { hit: usage?.sessionCacheHitTokens ?? 0, miss: usage?.sessionCacheMissTokens ?? 0 };
160 }
161
162 interface ContextBreakdown {
163 promptTokens: number;
164 completionTokens: number;
165 reasoningTokens: number;
166 otherTokens: number;
167 promptPct: number;
168 completionPct: number;
169 reasoningPct: number;
170 otherPct: number;
171 }
172
173 function nonNegativeTokenCount(value: number): number {
174 return Number.isFinite(value) ? Math.max(0, value) : 0;
175 }
176
177 /** Prefer Context* (latest attempt) over billable aggregates for turn panels. */
178 export function liveTurnUsageBreakdown(
179 usage?: WireUsage | null,
180 info?: Pick<ContextPanelInfo, "promptTokens" | "completionTokens" | "reasoningTokens"> | null,
181 ): { promptTokens: number; completionTokens: number; reasoningTokens: number } {
182 if (usage) {
183 const hasContext =
184 (usage.contextPromptTokens ?? 0) > 0 || (usage.contextCompletionTokens ?? 0) > 0;
185 if (hasContext) {
186 return {
187 promptTokens: usage.contextPromptTokens ?? 0,
188 completionTokens: usage.contextCompletionTokens ?? 0,
189 reasoningTokens: usage.contextReasoningTokens ?? 0,
190 };
191 }
192 return {
193 promptTokens: usage.promptTokens ?? 0,
194 completionTokens: usage.completionTokens ?? 0,
195 reasoningTokens: usage.reasoningTokens ?? 0,
196 };
197 }
198 return {
199 promptTokens: info?.promptTokens ?? 0,
200 completionTokens: info?.completionTokens ?? 0,
201 reasoningTokens: info?.reasoningTokens ?? 0,
202 };
203 }
204
205 export function contextBreakdown(
206 usedTokens: number,
207 windowTokens: number,
208 promptTokens: number,
209 completionTokens: number,
210 reasoningTokens: number,
211 ): ContextBreakdown {
212 const used = nonNegativeTokenCount(usedTokens);
213 const window = nonNegativeTokenCount(windowTokens);
214 let prompt = nonNegativeTokenCount(promptTokens);
215 let reasoning = Math.min(nonNegativeTokenCount(reasoningTokens), nonNegativeTokenCount(completionTokens));
216 let completion = Math.max(0, nonNegativeTokenCount(completionTokens) - reasoning);
217 const known = prompt + completion + reasoning;
218
219 if (known > used && known > 0) {
220 const scale = used / known;
221 prompt *= scale;
222 completion *= scale;
223 reasoning *= scale;
224 }
225
226 const normalizedKnown = Math.min(used, prompt + completion + reasoning);
227 const other = Math.max(0, used - normalizedKnown);
228 const hasWindow = window > 0;
229 const promptPct = hasWindow ? Math.min(100, (prompt / window) * 100) : 0;
230 const completionPct = hasWindow ? Math.min(100, ((prompt + completion) / window) * 100) : 0;
231 const reasoningPct = hasWindow ? Math.min(100, ((prompt + completion + reasoning) / window) * 100) : 0;
232 const otherPct = hasWindow ? Math.min(100, (used / window) * 100) : 0;
233
234 return {
235 promptTokens: Math.round(prompt),
236 completionTokens: Math.round(completion),
237 reasoningTokens: Math.round(reasoning),
238 otherTokens: Math.round(other),
239 promptPct,
240 completionPct,
241 reasoningPct,
242 otherPct,
243 };
244 }
245
246 export function contextWindowStatus(usagePct: number, compactPct: number): ContextWindowStatus {
247 if (usagePct >= 90) return { tone: "warn", key: "context.windowStatusNearLimit" };
248 if (compactPct > 0 && usagePct >= compactPct) return { tone: "warn", key: "context.windowStatusPastCompact" };
249 if (compactPct > 0 && usagePct >= Math.max(0, compactPct - 10)) return { tone: "notice", key: "context.windowStatusWatch" };
250 return { tone: "good", key: "context.windowStatusHealthy" };
251 }
252
253 const SOURCE_ORDER = ["executor", "planner", "subagent", "compaction", "classifier", "title"];
254
255 function sourceTone(source: string): string {
256 switch (source) {
257 case "executor": return "teal";
258 case "planner": return "blue";
259 case "subagent": return "amber";
260 case "compaction": return "slate";
261 case "classifier": return "violet";
262 case "title": return "rose";
263 default: return "default";
264 }
265 }
266
267 function sourceLabel(source: string, t: Translator): string {
268 switch (source) {
269 case "executor": return t("context.sourceExecutor");
270 case "planner": return t("context.sourcePlanner");
271 case "subagent": return t("context.sourceSubagent");
272 case "compaction": return t("context.sourceCompaction");
273 case "classifier": return t("context.sourceClassifier");
274 case "title": return t("context.sourceTitle");
275 default: return source;
276 }
277 }
278
279 function sourceCost(stats: UsageSourceStats): number {
280 return stats.sessionCost && stats.sessionCost > 0 ? stats.sessionCost : stats.sessionCostUsd ?? 0;
281 }
282
283 function sourceTokenTotal(row: Pick<ContextSourceRow, "promptTokens" | "completionTokens" | "totalTokens">): number {
284 return row.totalTokens > 0 ? row.totalTokens : row.promptTokens + row.completionTokens;
285 }
286
287 export interface ContextSourceRow {
288 source: string;
289 label: string;
290 promptTokens: number;
291 completionTokens: number;
292 cacheHitTokens: number;
293 cacheMissTokens: number;
294 totalTokens: number;
295 cost: number;
296 currency?: string;
297 requests: number;
298 estimated: boolean;
299 }
300
301 export function contextSourceRows(info: ContextPanelInfo | null, sessionCurrency?: string): ContextSourceRow[] {
302 const entries = Object.entries(info?.sources ?? {});
303 if (entries.length === 0) return [];
304 return entries
305 .filter(([, stats]) =>
306 (stats.requestCount ?? 0) > 0 ||
307 (stats.promptTokens ?? 0) > 0 ||
308 (stats.completionTokens ?? 0) > 0 ||
309 (stats.cacheHitTokens ?? 0) > 0 ||
310 (stats.cacheMissTokens ?? 0) > 0 ||
311 sourceCost(stats) > 0
312 )
313 .sort(([a], [b]) => {
314 const ia = SOURCE_ORDER.indexOf(a);
315 const ib = SOURCE_ORDER.indexOf(b);
316 if (ia >= 0 || ib >= 0) return (ia >= 0 ? ia : SOURCE_ORDER.length) - (ib >= 0 ? ib : SOURCE_ORDER.length);
317 return a.localeCompare(b);
318 })
319 .map(([source, stats]) => ({
320 source,
321 label: source,
322 promptTokens: stats.promptTokens ?? 0,
323 completionTokens: stats.completionTokens ?? 0,
324 cacheHitTokens: stats.cacheHitTokens ?? 0,
325 cacheMissTokens: stats.cacheMissTokens ?? 0,
326 totalTokens: stats.totalTokens ?? 0,
327 cost: sourceCost(stats),
328 currency: stats.sessionCurrency || sessionCurrency || info?.sessionCurrency,
329 requests: stats.requestCount ?? 0,
330 estimated: stats.estimated === true,
331 }));
332 }
333
334 export function ContextPanel({
335 tabId,
336 context,
337 usage,
338 sessionTokens,
339 sessionCost,
340 sessionCurrency,
341 turnTokens,
342 turnCost,
343 balance,
344 sessionGen,
345 refreshKey,
346 usageSeq,
347 }: ContextPanelProps) {
348 const { locale, t } = useI18n();
349 const [info, setInfo] = useState<ContextPanelInfo | null>(null);
350 const [analysisView, setAnalysisView] = useState<UsageAnalysisView>("source");
351 const refreshSeq = useRef(0);
352 const lastRefreshTime = useRef(0);
353 const usageRefreshKey = contextUsageRefreshKey(usage);
354
355 const refresh = useCallback(async () => {
356 if (!tabId) return;
357 const seq = ++refreshSeq.current;
358 try {
359 const next = await app.ContextPanel(tabId);
360 if (refreshSeq.current === seq) {
361 setInfo(next);
362 }
363 } catch {
364 /* bridge unavailable */
365 }
366 }, [tabId]);
367
368 useEffect(() => {
369 refreshSeq.current += 1;
370 setInfo(null);
371 void refresh();
372 }, [refresh, sessionGen]);
373
374 useEffect(() => {
375 void refresh();
376 }, [refresh, refreshKey]);
377
378 // Refresh the panel snapshot while usage events stream — from any source:
379 // usageSeq covers sub-agent/title requests the executor-gated usage prop
380 // never reflects, and usageRefreshKey keeps ticking for providers whose
381 // events lack a seq. Throttled to once per second.
382 useEffect(() => {
383 if (!usageRefreshKey && !usageSeq) return;
384 const now = Date.now();
385 if (now - lastRefreshTime.current >= 1000) {
386 lastRefreshTime.current = now;
387 void refresh();
388 }
389 }, [usageRefreshKey, usageSeq, refresh]);
390
391 const usedTokens = context?.used && context.used > 0 ? context.used : info?.usedTokens ?? 0;
392 const windowTokens = context?.window && context.window > 0 ? context.window : info?.windowTokens ?? 0;
393 // Prefer live usage props (updated in real-time by the reducer during streaming)
394 // over the async-fetched info snapshot (only refreshed on turn_done). Multi-
395 // attempt stream recovery reports billable aggregates on prompt/completion
396 // and latest-attempt shape on Context* — use the latter for turn breakdown.
397 const turnBreakdown = liveTurnUsageBreakdown(usage, info);
398 const promptTokens = turnBreakdown.promptTokens;
399 const completionTokens = turnBreakdown.completionTokens;
400 const totalTokens = info?.totalTokens && info.totalTokens > 0
401 ? info.totalTokens
402 : sessionTokens && sessionTokens > 0
403 ? sessionTokens
404 : usage?.totalTokens && usage.totalTokens > 0
405 ? usage.totalTokens
406 : promptTokens + completionTokens;
407 const reasoningTokens = turnBreakdown.reasoningTokens;
408 // Session-cumulative cache tokens for the top summary: all-sources telemetry
409 // first (matching the session cost and per-source rows in this panel — the
410 // wire session counters are executor-only), with the live counters bridging
411 // only a fresh session's first turn before the telemetry refresh. Hit and
412 // miss come as a pair from one source so the rate cannot mix scopes.
413 const { hit: sessionCacheHit, miss: sessionCacheMiss } = contextSessionCache(info, context, usage);
414 const totalTokensMetric = formatMetricTokens(totalTokens, locale);
415 const cost = contextCostDisplay({ info, sessionCost, sessionCurrency, usage });
416 const sourceUsageRows = contextSourceRows(info, sessionCurrency);
417 const showSourceUsageRows = sourceUsageRows.length > 0;
418 const sourceTotalTokens = sourceUsageRows.reduce((sum, row) => sum + sourceTokenTotal(row), 0);
419 const visibleSourceRows = sourceUsageRows.slice(0, 3);
420 const hiddenSourceRows = sourceUsageRows.slice(3);
421 const readFiles = asArray(info?.readFiles);
422 const changedFiles = asArray(info?.changedFiles);
423
424 const usagePct = windowTokens > 0 ? Math.min(100, Math.round((usedTokens / windowTokens) * 100)) : 0;
425 const compactRatio = context?.compactRatio && context.compactRatio > 0 ? context.compactRatio : 0.8;
426 const compactPct = Math.round(compactRatio * 100);
427 const compactTokens = windowTokens > 0 ? Math.round(windowTokens * compactRatio) : 0;
428 const tokensUntilCompact = compactTokens > usedTokens ? compactTokens - usedTokens : 0;
429 const breakdown = contextBreakdown(usedTokens, windowTokens, promptTokens, completionTokens, reasoningTokens);
430 const eventTimes = [
431 ...readFiles.map((file) => file.time),
432 ...changedFiles.map((file) => file.latestTime ?? 0),
433 ].filter((time) => time > 0);
434 const derivedElapsed = eventTimes.length > 1 ? Math.max(...eventTimes) - Math.min(...eventTimes) : 0;
435 const elapsed = info?.elapsedMs && info.elapsedMs > 0 ? info.elapsedMs : derivedElapsed;
436 const derivedRequestCount = Math.max(readFiles.length + changedFiles.length, 0);
437 const requestCount = info?.requestCount && info.requestCount > 0 ? info.requestCount : derivedRequestCount;
438 const windowStatus = contextWindowStatus(usagePct, compactPct);
439 const balanceLabel = balance?.available && balance.display ? balance.display : "-";
440 const turnEstimated = usage?.estimated === true || info?.estimated === true;
441 const sessionEstimated = info?.sessionEstimated === true || context?.estimated === true;
442 const markEstimated = (value: string, estimated: boolean) => estimated && value !== "-" ? `≈${value}` : value;
443 const turnCostLabel = markEstimated(formatMoneyLocalized(turnCost, sessionCurrency, { locale, empty: "dash" }), turnEstimated);
444 const sessionCostLabel = markEstimated(formatMoneyLocalized(cost.amount, cost.currency, { locale, empty: "dash" }), sessionEstimated);
445 const totalTokensTitle = totalTokensMetric.exact === "-" ? "-" : t("context.tokensValue", { value: totalTokensMetric.exact });
446 const usedLabel = formatTokens(usedTokens);
447 const windowLabel = formatTokens(windowTokens);
448 const compactRemainingLabel = tokensUntilCompact > 0 ? formatTokens(tokensUntilCompact) : "0";
449 const compactMarkerPct = Math.max(0, Math.min(100, compactPct));
450 const usageMarkerPct = Math.max(6, Math.min(94, usagePct));
451 const compactLabelPct = Math.max(6, Math.min(94, compactMarkerPct));
452 const usageSummary = t("context.windowUsageSummary", { used: usedLabel, window: windowLabel, pct: usagePct });
453 const compactSummary = t("context.windowCompactRemaining", { used: usedLabel, window: windowLabel, tokens: compactRemainingLabel, pct: compactPct });
454 const activeAnalysisView: UsageAnalysisView = showSourceUsageRows ? analysisView : "type";
455 const tokenTypeRows = [
456 { key: "prompt", label: t("context.prompt"), value: breakdown.promptTokens },
457 { key: "completion", label: t("context.completion"), value: breakdown.completionTokens },
458 { key: "reasoning", label: t("context.reasoning"), value: breakdown.reasoningTokens },
459 { key: "other", label: t("context.other"), value: breakdown.otherTokens },
460 ];
461 const tokenCompositionTotal = tokenTypeRows.reduce((sum, row) => sum + row.value, 0);
462 const renderSourceRow = (row: ContextSourceRow) => {
463 const inputMetric = formatMetricTokens(row.promptTokens, locale);
464 const outputMetric = formatMetricTokens(row.completionTokens, locale);
465 const hitMetric = formatMetricTokens(row.cacheHitTokens, locale);
466 const missMetric = formatMetricTokens(row.cacheMissTokens, locale);
467 const totalMetric = formatMetricTokens(sourceTokenTotal(row), locale);
468 const cacheReported = row.cacheHitTokens + row.cacheMissTokens > 0;
469 const cacheRate = cacheReported ? formatCacheHitRate(row.cacheHitTokens, row.cacheMissTokens) : t("context.cacheNotReported");
470 const costLabel = markEstimated(formatMoneyLocalized(row.cost, row.currency, { locale, empty: "dash" }), row.estimated);
471 return (
472 <div className="context-panel__source-row" key={row.source}>
473 <div className="context-panel__source-head">
474 <span>
475 <i className={`context-panel__source-dot context-panel__source-tone--${sourceTone(row.source)}`} aria-hidden="true" />
476 {sourceLabel(row.label, t)}
477 </span>
478 <em>{t("context.sourceRequests", { count: row.requests })}</em>
479 </div>
480 <div className="context-panel__source-summary">
481 <SourceMetric label={t("context.total")} value={totalMetric.display} title={totalMetric.exact} />
482 <SourceMetric label={t("context.sourceCacheRate")} value={cacheRate} />
483 <SourceMetric label={t("context.sourceCost")} value={costLabel} />
484 </div>
485 <details className="context-panel__source-details">
486 <summary>{t("context.sourceDetails")}</summary>
487 <div className="context-panel__source-details-body">
488 <SourceSplitBar
489 label={`${t("context.sourceInput")}/${t("context.sourceOutput")}`}
490 segments={[
491 { label: t("context.sourceInput"), value: row.promptTokens, tone: "input" },
492 { label: t("context.sourceOutput"), value: row.completionTokens, tone: "output" },
493 ]}
494 />
495 {cacheReported ? (
496 <SourceSplitBar
497 label={`${t("context.sourceCacheHit")}/${t("context.sourceCacheMiss")}`}
498 segments={[
499 { label: t("context.sourceCacheHit"), value: row.cacheHitTokens, tone: "hit" },
500 { label: t("context.sourceCacheMiss"), value: row.cacheMissTokens, tone: "miss" },
501 ]}
502 compact
503 />
504 ) : (
505 <SourceSplitBar label={`${t("context.sourceCacheHit")}/${t("context.sourceCacheMiss")}`} segments={[]} compact />
506 )}
507 <div className="context-panel__source-metrics">
508 <SourceMetric label={t("context.sourceInput")} value={inputMetric.display} title={inputMetric.exact} />
509 <SourceMetric label={t("context.sourceOutput")} value={outputMetric.display} title={outputMetric.exact} />
510 <SourceMetric label={t("context.sourceCacheHit")} value={hitMetric.display} title={hitMetric.exact} />
511 <SourceMetric label={t("context.sourceCacheMiss")} value={missMetric.display} title={missMetric.exact} />
512 </div>
513 </div>
514 </details>
515 </div>
516 );
517 };
518
519 return (
520 <div className="context-panel">
521 <div className="context-panel__body">
522 <section className="context-panel__overview">
523 <section className="context-panel__usage">
524 <SectionHeading title={t("context.windowTitle")} />
525 <div className={`context-panel__capacity-card context-panel__capacity-card--${windowStatus.tone}`}>
526 <div className="context-panel__capacity-top">
527 <span className="context-panel__capacity-status">{t(windowStatus.key)}</span>
528 <strong>{usedLabel}/{windowLabel}</strong>
529 </div>
530 <div className="context-panel__usage-progress context-panel__capacity-meter" aria-label={`${t(windowStatus.key)}. ${usageSummary}. ${compactSummary}`}>
531 <div className="context-panel__capacity-scale" aria-hidden="true">
532 <span className="context-panel__capacity-pin context-panel__capacity-pin--used" style={{ left: `${usageMarkerPct}%` }}>{usagePct}%</span>
533 <span className="context-panel__capacity-pin context-panel__capacity-pin--compact" style={{ left: `${compactLabelPct}%` }}>{compactPct}%</span>
534 </div>
535 <div className="context-panel__progress-track" aria-hidden="true">
536 <span className="context-panel__progress-segment context-panel__progress-segment--prompt" style={{ width: `${breakdown.promptPct}%` }} />
537 <span className="context-panel__progress-segment context-panel__progress-segment--completion" style={{ width: `${Math.max(0, breakdown.completionPct - breakdown.promptPct)}%` }} />
538 <span className="context-panel__progress-segment context-panel__progress-segment--reasoning" style={{ width: `${Math.max(0, breakdown.reasoningPct - breakdown.completionPct)}%` }} />
539 <span className="context-panel__progress-segment context-panel__progress-segment--other" style={{ width: `${Math.max(0, breakdown.otherPct - breakdown.reasoningPct)}%` }} />
540 <span className="context-panel__compact-marker" style={{ left: `${compactMarkerPct}%` }} />
541 </div>
542 </div>
543 <div className="context-panel__capacity-foot">
544 <span>{t("context.windowUsedLabel")}</span>
545 <span className="context-panel__capacity-remaining">
546 <span>{t("context.windowCompactDistance")}</span>
547 <strong>{compactRemainingLabel}</strong>
548 </span>
549 </div>
550 </div>
551 </section>
552 <section className="context-panel__section context-panel__session-section">
553 <SectionHeading title={t("context.sessionMetrics")} />
554 <div className="context-panel__session-metrics">
555 <div className="context-panel__summary-rows">
556 <MiniStat label={t("status.cacheAvgLabel")} value={formatCacheHitRate(sessionCacheHit, sessionCacheMiss)} tone={cacheHitTone(sessionCacheHit, sessionCacheMiss)} />
557 <MiniStat label={t("context.sessionCost")} value={sessionCostLabel} />
558 <MiniStat label={t("context.time")} value={fmtDuration(elapsed, t)} />
559 <MiniStat label={t("context.requests")} value={requestCount > 0 ? String(requestCount) : "-"} />
560 <MiniStat label={t("context.sessionTokensShort")} value={markEstimated(totalTokensMetric.display, sessionEstimated)} title={totalTokensTitle} wide />
561 </div>
562 </div>
563 </section>
564 <section className="context-panel__creation-grid" aria-label={t("context.overview")}>
565 <MetricCard label={t("status.cacheLabel")} value={fmtUsageCacheRate(usage)} tone="accent" />
566 <MetricCard label={t("status.turnTokensLabel")} value={formatOptionalTokens(turnTokens)} />
567 <MetricCard label={t("status.turnCostLabel")} value={turnCostLabel} />
568 <MetricCard label={t("status.balanceLabel")} value={balanceLabel} tone="accent" />
569 </section>
570 <section className="context-panel__section context-panel__analysis">
571 <SectionHeading title={t("context.usageAnalysis")}>
572 {showSourceUsageRows && (
573 <div className="context-panel__view-switch" role="tablist" aria-label={t("context.usageAnalysisView")}>
574 <button
575 type="button"
576 className={`context-panel__view-tab${activeAnalysisView === "source" ? " context-panel__view-tab--active" : ""}`}
577 role="tab"
578 aria-selected={activeAnalysisView === "source"}
579 onClick={() => setAnalysisView("source")}
580 >
581 {t("context.usageAnalysisSource")}
582 </button>
583 <button
584 type="button"
585 className={`context-panel__view-tab${activeAnalysisView === "type" ? " context-panel__view-tab--active" : ""}`}
586 role="tab"
587 aria-selected={activeAnalysisView === "type"}
588 onClick={() => setAnalysisView("type")}
589 >
590 {t("context.usageAnalysisType")}
591 </button>
592 </div>
593 )}
594 </SectionHeading>
595 {activeAnalysisView === "source" ? (
596 <div className="context-panel__source-list" aria-label={t("context.sourceBreakdown")} role="tabpanel">
597 <div className="context-panel__source-overview">
598 <div className="context-panel__source-overview-head">
599 <strong>{t("context.sourceShareTitle")}</strong>
600 </div>
601 <div className="context-panel__source-sharebar" aria-hidden="true">
602 {sourceUsageRows.map((row) => {
603 const sharePct = sourceTotalTokens > 0 ? (sourceTokenTotal(row) / sourceTotalTokens) * 100 : 0;
604 if (sharePct <= 0) return null;
605 return (
606 <span
607 className={`context-panel__source-share context-panel__source-tone--${sourceTone(row.source)}`}
608 key={row.source}
609 style={{ width: `${sharePct}%` }}
610 />
611 );
612 })}
613 </div>
614 <div className="context-panel__source-legend">
615 {sourceUsageRows.map((row) => {
616 const sharePct = sourceTotalTokens > 0 ? (sourceTokenTotal(row) / sourceTotalTokens) * 100 : 0;
617 return (
618 <span key={row.source}>
619 <i className={`context-panel__source-dot context-panel__source-tone--${sourceTone(row.source)}`} aria-hidden="true" />
620 {sourceLabel(row.label, t)} {sharePct > 0 ? `${sharePct.toFixed(0)}%` : "-"}
621 </span>
622 );
623 })}
624 </div>
625 </div>
626 {visibleSourceRows.map(renderSourceRow)}
627 {hiddenSourceRows.length > 0 && (
628 <details className="context-panel__source-more">
629 <summary>{t("context.moreSources", { count: hiddenSourceRows.length })}</summary>
630 <div className="context-panel__source-more-list">
631 {hiddenSourceRows.map(renderSourceRow)}
632 </div>
633 </details>
634 )}
635 </div>
636 ) : (
637 <div className="context-panel__type-panel" aria-label={t("context.tokenBreakdown")} role="tabpanel">
638 <div className="context-panel__type-overview">
639 <div className="context-panel__type-overview-head">
640 <strong>{t("context.tokenBreakdown")}</strong>
641 </div>
642 <div className="context-panel__type-sharebar" aria-hidden="true">
643 {tokenTypeRows.map((row) => row.value > 0 ? (
644 <span
645 className={`context-panel__type-share context-panel__type-share--${row.key}`}
646 key={row.key}
647 style={{ width: `${(row.value / Math.max(1, tokenCompositionTotal)) * 100}%` }}
648 />
649 ) : null)}
650 </div>
651 <div className="context-panel__type-legend">
652 {tokenTypeRows.map((row) => (
653 <span key={row.key}>
654 <i className={`context-panel__type-dot context-panel__type-dot--${row.key}`} aria-hidden="true" />
655 {row.label} {formatSharePercent(row.value, tokenCompositionTotal)}
656 </span>
657 ))}
658 </div>
659 </div>
660 <details className="context-panel__breakdown-details">
661 <summary>{t("context.sourceDetails")}</summary>
662 <div className="context-panel__breakdown">
663 {tokenTypeRows.map((row) => (
664 <TokenLegend key={row.key} label={row.label} value={row.value} color={row.key} />
665 ))}
666 </div>
667 </details>
668 </div>
669 )}
670 </section>
671 </section>
672 </div>
673
674 </div>
675 );
676 }
677
678 function SectionHeading({ title, meta, children }: { title: string; meta?: string; children?: ReactNode }) {
679 return (
680 <header className="context-panel__section-head">
681 <h3>{title}</h3>
682 {meta && <span>{meta}</span>}
683 {children}
684 </header>
685 );
686 }
687
688 function TokenLegend({ label, value, color }: { label: string; value: number; color: string }) {
689 return (
690 <div className="context-panel__legend-row">
691 <span className={`context-panel__legend-dot context-panel__legend-dot--${color}`} />
692 <span>{label}</span>
693 <strong>{value.toLocaleString()}</strong>
694 </div>
695 );
696 }
697
698 function MiniStat({ label, value, title, tone, wide }: { label: string; value: string; title?: string; tone?: MetricTone; wide?: boolean }) {
699 const toneClass = tone ? ` context-panel__mini-stat--${tone}` : "";
700 const wideClass = wide ? " context-panel__mini-stat--wide" : "";
701 const exactTitle = title && title !== value ? title : undefined;
702 return (
703 <div className={`context-panel__mini-stat${toneClass}${wideClass}`} aria-label={exactTitle ? `${label}: ${exactTitle}` : undefined}>
704 <span>{label}</span>
705 <strong title={exactTitle}>{value}</strong>
706 </div>
707 );
708 }
709
710 function MetricCard({ label, value, valueTitle, tone, wide }: { label: string; value: string; valueTitle?: string; tone?: "accent" | "good" | "notice" | "warn"; wide?: boolean }) {
711 const toneClass = tone ? ` context-panel__metric--${tone}` : "";
712 const wideClass = wide ? " context-panel__metric--wide" : "";
713 const exactTitle = valueTitle && valueTitle !== value ? valueTitle : undefined;
714 return (
715 <div className={`context-panel__metric${toneClass}${wideClass}`} aria-label={exactTitle ? `${label}: ${exactTitle}` : undefined}>
716 <span>{label}</span>
717 <strong title={exactTitle}>{value}</strong>
718 </div>
719 );
720 }
721
722 function SourceMetric({ label, value, title }: { label: string; value: string; title?: string }) {
723 const exactTitle = title && title !== value ? title : undefined;
724 return (
725 <div className="context-panel__source-metric" aria-label={exactTitle ? `${label}: ${exactTitle}` : undefined}>
726 <span>{label}</span>
727 <strong title={exactTitle}>{value}</strong>
728 </div>
729 );
730 }
731
732 function SourceSplitBar({ label, segments, compact }: { label: string; segments: Array<{ label: string; value: number; tone: string }>; compact?: boolean }) {
733 const total = segments.reduce((sum, segment) => sum + Math.max(0, segment.value), 0);
734 const visible = segments.filter((segment) => segment.value > 0);
735 const compactClass = compact ? " context-panel__source-bar--compact" : "";
736 if (total <= 0 || visible.length === 0) {
737 return (
738 <div className="context-panel__source-bar-row">
739 <span>{label}</span>
740 <div className={`context-panel__source-bar context-panel__source-bar--empty${compactClass}`} aria-hidden="true" />
741 </div>
742 );
743 }
744 return (
745 <div className="context-panel__source-bar-row">
746 <span>{label}</span>
747 <div className={`context-panel__source-bar${compactClass}`}>
748 {visible.map((segment) => {
749 const width = (segment.value / total) * 100;
750 return (
751 <span
752 className={`context-panel__source-bar-segment context-panel__source-bar-segment--${segment.tone}`}
753 key={segment.tone}
754 style={{ width: `${width}%` }}
755 title={`${segment.label}: ${segment.value.toLocaleString()}`}
756 />
757 );
758 })}
759 </div>
760 </div>
761 );
762 }
763
763 lines Plain Text