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