返回 DeepSeek-Reasonix
ContextWindowRing.tsx
根目录 / desktop / frontend / src / components / ContextWindowRing.tsx
1 import { useCallback, useEffect, useRef, useState } from "react";
2 import { useSessionRuntimeMs } from "../lib/useSessionRuntime";
3 import { app } from "../lib/bridge";
4 import { contextWindowPercentages } from "../lib/contextWindow";
5 import { useI18n } from "../lib/i18n";
6 import { formatMoneyLocalized } from "../lib/money";
7 import { appendRateBand, rateBandLabel } from "../lib/costRateBand";
8 import type { BalanceInfo, ContextInfo, ContextPanelInfo } from "../lib/types";
9 import { AnchoredPopover } from "./AnchoredPopover";
10 import { contextWindowStatus, formatCacheHitRate } from "../lib/contextPanelUtils";
11
12 interface ContextWindowRingProps {
13 turnMetrics?: { elapsed: string; tokens: string | null; tps: string | null };
14 enabled?: boolean;
15 context?: ContextInfo;
16 tabId?: string;
17 turnCost?: number;
18 turnRateBand?: string;
19 currency?: string;
20 cacheHitTokens?: number;
21 cacheMissTokens?: number;
22 balance?: BalanceInfo;
23 dismissSignal?: number;
24 }
25
26 const RING = 18;
27 const RING_R = (RING - 2) / 2;
28 const RING_C = 2 * Math.PI * RING_R;
29
30 function fmtCompact(n: number): string {
31 if (n <= 0) return "0";
32 if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M";
33 if (n >= 1_000) return (n / 1_000).toFixed(1).replace(/\.0$/, "") + "k";
34 return String(Math.round(n));
35 }
36
37 function fmtDuration(ms: number, t: ReturnType<typeof useI18n>['t']): string {
38 if (ms <= 0) return "-";
39 const totalSeconds = Math.max(1, Math.round(ms / 1000));
40 const minutes = Math.floor(totalSeconds / 60);
41 const seconds = totalSeconds % 60;
42 if (minutes <= 0) return t("context.durationSeconds", { seconds });
43 return t("context.durationMinutesSeconds", { minutes, seconds });
44 }
45
46 export function ContextWindowRing({ enabled = true, context, tabId, turnCost, turnRateBand, currency, cacheHitTokens, cacheMissTokens, balance, turnMetrics, dismissSignal }: ContextWindowRingProps) {
47 const { locale, t } = useI18n();
48 const [open, setOpen] = useState(false);
49 const [info, setInfo] = useState<ContextPanelInfo | null>(null);
50 const triggerRef = useRef<HTMLButtonElement>(null);
51 const loadingTabRef = useRef<string | null>(null);
52 const requestSeq = useRef(0);
53 const enterTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
54 const leaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
55
56 const used = context?.used ?? 0;
57 const windowTokens = context?.window ?? 0;
58 const usagePercentages = contextWindowPercentages(used, windowTokens);
59 const rawUsagePct = usagePercentages.raw;
60 const usagePct = usagePercentages.display;
61 const compactRatio = context?.compactRatio && context.compactRatio > 0 ? context.compactRatio : 0.80;
62 const compactPct = Math.round(compactRatio * 100);
63 const status = contextWindowStatus(rawUsagePct, compactPct);
64
65 const loadInfo = useCallback(() => {
66 if (!enabled || !tabId) return;
67 if (loadingTabRef.current === tabId) return;
68 const requestTab = tabId;
69 const seq = requestSeq.current + 1;
70 requestSeq.current = seq;
71 loadingTabRef.current = requestTab;
72 app.ContextPanel(requestTab).then((next) => {
73 if (requestSeq.current === seq) setInfo(next);
74 }).catch(() => {}).finally(() => {
75 if (requestSeq.current === seq) loadingTabRef.current = null;
76 });
77 }, [enabled, tabId]);
78
79 // Reset when tabId changes so an older panel request cannot paint a new session.
80 useEffect(() => {
81 requestSeq.current += 1;
82 loadingTabRef.current = null;
83 setInfo(null);
84 if (enterTimer.current != null) clearTimeout(enterTimer.current);
85 if (leaveTimer.current != null) clearTimeout(leaveTimer.current);
86 setOpen(false);
87 }, [dismissSignal, enabled, tabId]);
88
89 useEffect(() => () => {
90 requestSeq.current += 1;
91 if (enterTimer.current != null) clearTimeout(enterTimer.current);
92 if (leaveTimer.current != null) clearTimeout(leaveTimer.current);
93 }, []);
94
95 const onEnter = useCallback(() => {
96 if (enterTimer.current != null) clearTimeout(enterTimer.current);
97 if (leaveTimer.current != null) clearTimeout(leaveTimer.current);
98 loadInfo();
99 enterTimer.current = setTimeout(() => setOpen(true), 200);
100 }, [loadInfo]);
101
102 const onLeave = useCallback(() => {
103 if (enterTimer.current != null) clearTimeout(enterTimer.current);
104 leaveTimer.current = setTimeout(() => setOpen(false), 120);
105 }, []);
106
107 const onPopoverEnter = useCallback(() => {
108 if (leaveTimer.current != null) clearTimeout(leaveTimer.current);
109 }, []);
110
111 const onPopoverLeave = useCallback(() => {
112 setOpen(false);
113 }, []);
114
115 const runtimeMs = useSessionRuntimeMs(open ? info : null);
116 if (!enabled) return null;
117
118 const turnCacheHit = cacheHitTokens ?? info?.cacheHitTokens ?? 0;
119 const turnCacheMiss = cacheMissTokens ?? info?.cacheMissTokens ?? 0;
120 const turnCacheRate = formatCacheHitRate(turnCacheHit, turnCacheMiss);
121 const compactTokens = windowTokens > 0 ? Math.round(windowTokens * compactRatio) : 0;
122 const tokensToCompact = compactTokens > used ? compactTokens - used : 0;
123 const ringOffset = RING_C * (1 - usagePct / 100);
124 const elapsed = runtimeMs > 0 ? fmtDuration(runtimeMs, t) : undefined;
125 const quoteStatus = info?.sessionCostQuote?.displayStatus;
126 const sessionCostBucketed = quoteStatus === "bucketed" || info?.sessionCostQuote?.aggregateMode === "currency_buckets";
127 const sessionCostFallback = quoteStatus === "fallback_original";
128 const sessionCostComplete = (sessionCostFallback || info?.sessionCostComplete !== false) && quoteStatus !== "unavailable";
129 const sessionCostRaw = info?.sessionCostQuote?.selected
130 ? Number(info.sessionCostQuote.selected.amount)
131 : info?.sessionCost;
132 const sessionCostCurrency = info?.sessionCostQuote?.selected?.currency || info?.sessionCurrency;
133 const sessionCost =
134 !sessionCostBucketed && sessionCostComplete && typeof sessionCostRaw === "number" && sessionCostRaw > 0
135 ? `≈${formatMoneyLocalized(sessionCostRaw, sessionCostCurrency, { locale, empty: "dash" }).replace(/^≈/, "")}`
136 : undefined;
137 const sessionCostHint =
138 info?.sessionBillingMode === "subscription_equivalent"
139 ? "payg_equivalent"
140 : sessionCostFallback
141 ? "fallback_original"
142 : sessionCost
143 ? "estimated"
144 : undefined;
145 const turnCostLabel = appendRateBand(formatMoneyLocalized(turnCost, info?.sessionCurrency || currency, { locale, empty: "dash" }), turnRateBand, t);
146 const sessionCostLabel = sessionCost ? appendRateBand(sessionCost, info?.sessionCostQuote?.rateBand, t) : undefined;
147 const turnRateBandTitle = rateBandLabel(turnRateBand, t) ? t("billing.rateBand.tooltip") : undefined;
148 const sessionRateBandTitle = rateBandLabel(info?.sessionCostQuote?.rateBand, t) ? t("billing.rateBand.tooltip") : undefined;
149
150 return (
151 <>
152 <button
153 ref={triggerRef}
154 type="button"
155 className={`context-ring${open ? " context-ring--open" : ""} context-ring--${status.tone}`}
156 onMouseEnter={onEnter}
157 onMouseLeave={onLeave}
158 onFocus={onEnter}
159 onBlur={onLeave}
160 onClick={() => {
161 if (enterTimer.current != null) clearTimeout(enterTimer.current);
162 if (leaveTimer.current != null) clearTimeout(leaveTimer.current);
163 loadInfo();
164 setOpen(true);
165 }}
166 aria-haspopup="dialog"
167 aria-expanded={open}
168 aria-label={t("context.windowUsageSummary", { used: String(used), window: String(windowTokens), pct: rawUsagePct })}
169 >
170 <svg width={RING} height={RING} viewBox={`0 0 ${RING} ${RING}`} className="context-ring__svg">
171 <circle className="context-ring__track" cx={RING / 2} cy={RING / 2} r={RING_R} fill="none" strokeWidth={2} />
172 <circle
173 className="context-ring__arc"
174 cx={RING / 2} cy={RING / 2} r={RING_R}
175 fill="none" strokeWidth={2}
176 strokeLinecap="round"
177 strokeDasharray={RING_C}
178 strokeDashoffset={ringOffset}
179 transform={`rotate(-90 ${RING / 2} ${RING / 2})`}
180 />
181 </svg>
182 <span className="context-ring__percent">{rawUsagePct}%</span>
183 </button>
184 <AnchoredPopover
185 open={open}
186 anchorRef={triggerRef}
187 onClose={() => {
188 if (enterTimer.current != null) clearTimeout(enterTimer.current);
189 setOpen(false);
190 }}
191 className={`context-ring-popover context-ring-popover--${status.tone} composer-menu-surface`}
192 align="end"
193 placement="auto"
194 >
195 <div className="context-ring-popover__inner" role="dialog" aria-label={t("context.windowUsageSummary", { used: String(used), window: String(windowTokens), pct: rawUsagePct })} onMouseEnter={onPopoverEnter} onMouseLeave={onPopoverLeave}>
196 <div className="context-ring-popover__header">
197 <span className="context-ring-popover__title">
198 {fmtCompact(used)} / {fmtCompact(windowTokens)}
199 </span>
200 <span className="context-ring-popover__pct">{rawUsagePct}%</span>
201 </div>
202 <div className="context-ring-popover__gauge">
203 <div className="context-ring-popover__bar">
204 <span className="context-ring-popover__fill" style={{ width: `${usagePct}%` }} />
205 <span className="context-ring-popover__mark context-ring-popover__mark--compact" style={{ left: `${compactPct}%` }} />
206 <span className="context-ring-popover__mark context-ring-popover__mark--attention" style={{ left: `30%` }} />
207 </div>
208 </div>
209 <div className="context-ring-popover__rows">
210 <div className="context-ring-popover__row">
211 <span className="context-ring-popover__label">{t("context.windowCompactDistance")}</span>
212 <span className="context-ring-popover__value">{fmtCompact(tokensToCompact)}</span>
213 </div>
214 {info?.requestCount != null && info.requestCount > 0 && (
215 <div className="context-ring-popover__row">
216 <span className="context-ring-popover__label">{t("context.requests")}</span>
217 <span className="context-ring-popover__value">{info.requestCount}</span>
218 </div>
219 )}
220 {elapsed && (
221 <div className="context-ring-popover__row">
222 <span className="context-ring-popover__label">{t("context.sessionTime")}</span>
223 <span className="context-ring-popover__value">{elapsed}</span>
224 </div>
225 )}
226 {turnMetrics && <div className="context-ring-popover__turn-metrics">
227 {([
228 [t("context.turnTime"), t("context.turnTimeTitle"), turnMetrics.elapsed],
229 [t("status.turnTpsLabel"), t("status.turnTpsTitle"), turnMetrics.tps],
230 [t("status.turnOutputTokensLabel"), t("status.turnOutputTokensTitle"), turnMetrics.tokens],
231 ] as const).map(([label, hint, value]) => value && (
232 <div className="context-ring-popover__row" key={label} title={hint}>
233 <span className="context-ring-popover__label">{label}</span>
234 <span className="context-ring-popover__value">{value}</span>
235 </div>
236 ))}
237 </div>}
238 <div className="context-ring-popover__row">
239 <span className="context-ring-popover__label">{t("status.cacheLabel")}</span>
240 <span className="context-ring-popover__value">{turnCacheRate}</span>
241 </div>
242 {turnCost != null && turnCost > 0 && (
243 <div className="context-ring-popover__row">
244 <span className="context-ring-popover__label">{t("status.turnCostLabel")}</span>
245 <span className="context-ring-popover__value" title={turnRateBandTitle}>{turnCostLabel}</span>
246 </div>
247 )}
248 {sessionCostLabel && (
249 <div className="context-ring-popover__row">
250 <span className="context-ring-popover__label">
251 {sessionCostHint === "payg_equivalent"
252 ? t("context.sessionCostPaygEquivalent")
253 : sessionCostHint === "fallback_original"
254 ? t("context.sessionCostFallback")
255 : t("context.sessionCostEstimated")}
256 </span>
257 <span className="context-ring-popover__value" title={sessionRateBandTitle}>{sessionCostLabel}</span>
258 </div>
259 )}
260 {!sessionCost && sessionCostBucketed && (
261 <div className="context-ring-popover__row">
262 <span className="context-ring-popover__label">{t("context.sessionCost")}</span>
263 <span className="context-ring-popover__value">{t("context.sessionCostBucketed")}</span>
264 </div>
265 )}
266 {!sessionCost && !sessionCostBucketed && info?.sessionCostComplete === false && (
267 <div className="context-ring-popover__row">
268 <span className="context-ring-popover__label">{t("context.sessionCostEstimated")}</span>
269 <span className="context-ring-popover__value">—</span>
270 </div>
271 )}
272 {balance?.available && balance.display && (
273 <div className="context-ring-popover__row">
274 <span className="context-ring-popover__label">{t("status.balanceLabel")}</span>
275 <span className="context-ring-popover__value context-ring-popover__value--accent">{balance.display}</span>
276 </div>
277 )}
278 </div>
279 </div>
280 </AnchoredPopover>
281 </>
282 );
283 }
284
284 lines Plain Text