返回 DeepSeek-Reasonix
StatusBar.tsx
根目录 / desktop / frontend / src / components / StatusBar.tsx
1 import { useEffect, useRef, useState, type ReactNode } from "react";
2 import { Activity, CircleDollarSign, CircleGauge, Database, FileOutput, Folder, Gauge, GitBranch, HardDrive, Layers, Percent, Puzzle, RefreshCw, Server, Settings, Square, Unplug, Wallet, Zap } from "lucide-react";
3 import { AnchoredPopover } from "./AnchoredPopover";
4 import { RemoteConnectionErrorDialog } from "./RemoteConnectionErrorDialog";
5 import { Tooltip } from "./Tooltip";
6 import { contextWindowPercentages } from "../lib/contextWindow";
7 import { formatTps } from "../lib/format";
8 import { useI18n, type Translator } from "../lib/i18n";
9 import { formatMoneyLocalized } from "../lib/money";
10 import { normalizeStatusBarItems, type StatusBarItemId } from "../lib/statusBarItems";
11 import { appendRateBand, rateBandLabel } from "../lib/costRateBand";
12 import { isRemoteDegradedWarning, isRemoteHostKeyMismatch, isRemoteTerminalFailure, remoteConnectionErrorSummaryKey } from "../lib/remoteErrors";
13 import type { ExtensionStatusEntry } from "../lib/useController";
14 import { type BackgroundRuntimeView, type BalanceInfo, type ContextInfo, type JobView, type RemoteConnectionStatus, type RemoteHostView, type UsageSourceStats, type WireUsage } from "../lib/types";
15 import { useRemoteStore } from "../store/remote";
16
17 type StatusBarLabelStyle = "icon" | "text";
18
19 function formatRate(hit: number, denom: number): string | null {
20 return denom > 0 ? ((hit / denom) * 100).toFixed(2) : null;
21 }
22
23 // nowRate is the SINGLE-TURN prompt cache-hit % (latest turn) — the higher,
24 // steeper number on a non-compacting DeepSeek session. null when nothing yet.
25 function nowRate(u?: WireUsage): string | null {
26 if (!u) return null;
27 const denom = u.cacheHitTokens + u.cacheMissTokens;
28 return formatRate(u.cacheHitTokens, denom);
29 }
30
31 // avgRate is the SESSION-AGGREGATE cache-hit % — Σhit/Σ(hit+miss) across every
32 // turn — but scoped to the EXECUTOR agent only: the wire session counters come
33 // from the main agent and exclude subagent/planner/auxiliary requests. It is
34 // only the pre-first-refresh fallback; the authoritative all-sources number is
35 // contextAvgRate below, so the "session average" label reports one scope.
36 function avgRate(u?: WireUsage): string | null {
37 if (!u) return null;
38 const denom = u.sessionCacheHitTokens + u.sessionCacheMissTokens;
39 return formatRate(u.sessionCacheHitTokens, denom);
40 }
41
42 // contextAvgRate computes the session-aggregate cache-hit % from ContextInfo
43 // cache tokens — the tab telemetry that accumulates ALL request sources
44 // (executor, subagents, planner, auxiliary calls), refreshed at turn
45 // boundaries. Preferred over avgRate: it matches the 会话费用 tooltip's
46 // "includes main model, subagents and auxiliary calls" scope.
47 function contextAvgRate(ctx: ContextInfo): string | null {
48 const hit = ctx.cacheHitTokens ?? 0;
49 const miss = ctx.cacheMissTokens ?? 0;
50 return formatRate(hit, hit + miss);
51 }
52
53 function rateValueClass(rate: string | null): string {
54 if (rate === null) return "stat__value--empty";
55 const pct = Number.parseFloat(rate);
56 if (!Number.isFinite(pct)) return "";
57 if (pct >= 80) return "statusbar__rate-value--good";
58 if (pct >= 50) return "statusbar__rate-value--notice";
59 return "statusbar__rate-value--critical";
60 }
61
62 function formatTokenCount(tokens?: number): string {
63 if (typeof tokens !== "number" || tokens <= 0) return "-";
64 return tokens.toLocaleString();
65 }
66
67 function formatTurnCount(turns: number | undefined, t: Translator): string {
68 if (typeof turns !== "number" || turns < 0) return "-";
69 return t(turns === 1 ? "history.turnOne" : "history.turnOther", { n: turns });
70 }
71
72 const STATUS_SOURCE_ORDER = ["executor", "planner", "subagent", "compaction", "classifier", "title"];
73
74 function sourceLabel(source: string, t: Translator): string {
75 switch (source) {
76 case "executor": return t("context.sourceExecutor");
77 case "planner": return t("context.sourcePlanner");
78 case "subagent": return t("context.sourceSubagent");
79 case "compaction": return t("context.sourceCompaction");
80 case "classifier": return t("context.sourceClassifier");
81 case "title": return t("context.sourceTitle");
82 default: return source;
83 }
84 }
85
86 function sourceRows(sources?: Record<string, UsageSourceStats>): Array<{ source: string; stats: UsageSourceStats }> {
87 return Object.entries(sources ?? {})
88 .filter(([, stats]) =>
89 (stats.requestCount ?? 0) > 0 ||
90 (stats.promptTokens ?? 0) > 0 ||
91 (stats.completionTokens ?? 0) > 0 ||
92 (stats.cacheHitTokens ?? 0) > 0 ||
93 (stats.cacheMissTokens ?? 0) > 0
94 )
95 .sort(([a], [b]) => {
96 const ia = STATUS_SOURCE_ORDER.indexOf(a);
97 const ib = STATUS_SOURCE_ORDER.indexOf(b);
98 if (ia >= 0 || ib >= 0) return (ia >= 0 ? ia : STATUS_SOURCE_ORDER.length) - (ib >= 0 ? ib : STATUS_SOURCE_ORDER.length);
99 return a.localeCompare(b);
100 })
101 .map(([source, stats]) => ({ source, stats }));
102 }
103
104 function sourceCacheTooltip(t: Translator, title: string, context: ContextInfo): ReactNode {
105 const rows = sourceRows(context.sources);
106 if (rows.length === 0) return title;
107 return (
108 <span className="statusbar__tooltip-stack">
109 <span>{title}</span>
110 {rows.map(({ source, stats }) => {
111 const denom = stats.cacheHitTokens + stats.cacheMissTokens;
112 const rate = denom > 0 ? `${formatRate(stats.cacheHitTokens, denom)}%` : t("context.cacheNotReported");
113 return (
114 <span key={source}>
115 {sourceLabel(source, t)}: {rate} · {t("context.sourceInput")} {formatTokenCount(stats.promptTokens)}
116 {" · "}{t("context.sourceOutput")} {formatTokenCount(stats.completionTokens)}
117 {" · "}{t("context.sourceRequests", { count: stats.requestCount ?? 0 })}
118 </span>
119 );
120 })}
121 </span>
122 );
123 }
124
125 function MetricLabel({ style, icon, label }: { style: StatusBarLabelStyle; icon: ReactNode; label: string }) {
126 return (
127 <span className={`stat__label stat__label--${style}`} aria-hidden={style === "icon" ? "true" : undefined}>
128 {style === "icon" ? icon : label}
129 </span>
130 );
131 }
132
133 function compactPath(path?: string, fallback?: string): string {
134 const value = (path || fallback || "").trim();
135 if (!value) return "";
136 const normalized = value.replace(/\\/g, "/");
137 const homeMatch = normalized.match(/^~\/?(.+)?$/);
138 const parts = (homeMatch ? homeMatch[1] ?? "" : normalized).split("/").filter(Boolean);
139 if (parts.length === 0) return normalized;
140 if (parts.length === 1) return parts[0];
141 return `…/${parts.slice(-2).join("/")}`;
142 }
143
144 function workspaceTooltip(t: Translator, displayPath: string, workspacePath?: string, gitBranch?: string) {
145 const workspace = (workspacePath || displayPath).trim();
146 const branch = (gitBranch || "").trim();
147 if (branch) {
148 return (
149 <span className="statusbar__tooltip-stack">
150 {workspace && <span>{t("status.workspaceTitle")}: {workspace}</span>}
151 {branch && <span>{t("status.gitBranchTitle")}: {branch}</span>}
152 </span>
153 );
154 }
155 return `${t("status.workspaceTitle")}: ${workspace}`;
156 }
157
158 export function StatusBar({
159 context,
160 usage,
161 balance,
162 sessionTurns,
163 sessionTokens,
164 turnTokens,
165 lastTurnOutputTokens,
166 lastTurnModelMs,
167 lastTurnOutputEstimated = false,
168 lastRequestTps,
169 turnCost,
170 turnRateBand,
171 cost,
172 currency,
173 labelStyle = "icon",
174 items,
175 workspacePath,
176 workspaceName,
177 gitBranch,
178 onConnectRemote,
179 onDisconnectRemote,
180 onManageRemote,
181 onOpenRemote,
182 onOpenRemoteWorkspace,
183 remoteHosts = [],
184 remoteStatuses = {},
185 jobs = [],
186 onCancelJob,
187 backgroundRuntimes = [],
188 onCancelRuntimeJob,
189 onRevealRuntime,
190 extensionStatuses = [],
191 }: {
192 context: ContextInfo;
193 usage?: WireUsage;
194 balance?: BalanceInfo;
195 running: boolean;
196 sessionTurns?: number;
197 sessionTokens?: number;
198 turnTokens?: number;
199 lastTurnOutputTokens?: number;
200 lastTurnModelMs?: number;
201 lastTurnOutputEstimated?: boolean;
202 lastRequestTps?: number | null; // Null means the latest request was not measurable.
203 turnCost?: number;
204 turnRateBand?: string;
205 cost?: number;
206 currency?: string;
207 modelLabel?: string;
208 labelStyle?: StatusBarLabelStyle;
209 items?: readonly string[];
210 workspacePath?: string;
211 workspaceName?: string;
212 gitBranch?: string;
213 onConnectRemote?: (host: RemoteHostView) => void;
214 onDisconnectRemote?: (hostId: string) => void;
215 onManageRemote?: () => void;
216 onOpenRemote?: (hostId: string) => void;
217 onOpenRemoteWorkspace?: (host: RemoteHostView) => void;
218 remoteHosts?: RemoteHostView[];
219 remoteStatuses?: Record<string, RemoteConnectionStatus>;
220 jobs?: JobView[];
221 onCancelJob?: (jobID: string) => Promise<boolean>;
222 backgroundRuntimes?: BackgroundRuntimeView[];
223 onCancelRuntimeJob?: (tabID: string, jobID: string) => Promise<boolean>;
224 onRevealRuntime?: (tabID: string) => Promise<void>;
225 // Extension-published status surfaces (stage 8b2), one per surface key.
226 extensionStatuses?: ExtensionStatusEntry[];
227 }) {
228 const { locale, t } = useI18n();
229 const pct = context.window > 0 ? contextWindowPercentages(context.used, context.window).raw : null;
230 const compactPct = context.compactRatio ? Math.round(context.compactRatio * 100) : null;
231 const compactNear = pct !== null && compactPct !== null && pct >= Math.max(0, compactPct - 10);
232 const compactReached = pct !== null && compactPct !== null && pct >= compactPct;
233 const nowPct = nowRate(usage);
234 // All-sources telemetry first; the executor-only live counters only bridge
235 // the gap before the first ContextInfo refresh of a fresh session.
236 const avgPct = contextAvgRate(context) ?? avgRate(usage);
237 const turnEstimated = usage?.estimated === true;
238 const sessionEstimated = context.estimated === true;
239 const markEstimated = (value: string, estimated: boolean) => estimated && value !== "-" ? `≈${value}` : value;
240 const turnCostLabel = appendRateBand(markEstimated(formatMoneyLocalized(turnCost, currency, { locale, fractionDigits: 2 }), turnEstimated), turnRateBand, t);
241 const costLabel = markEstimated(formatMoneyLocalized(cost, currency, { locale }), sessionEstimated);
242 const displayWorkspacePath = (workspacePath || workspaceName || "").trim();
243 const workspaceLabel = compactPath(displayWorkspacePath, workspaceName);
244 const branchLabel = (gitBranch || "").trim();
245 const workspaceTitle = workspaceTooltip(t, displayWorkspacePath, workspacePath, branchLabel);
246 const turnLabel = formatTurnCount(sessionTurns, t);
247 const tokenLabel = markEstimated(formatTokenCount(sessionTokens), sessionEstimated);
248 const turnTokenLabel = markEstimated(formatTokenCount(turnTokens), turnEstimated);
249 const statusQuote = context.sessionCostQuote;
250 const statusBucketed = statusQuote?.displayStatus === "bucketed" || statusQuote?.aggregateMode === "currency_buckets";
251 const statusUnavailable = context.sessionCostComplete === false || statusQuote?.displayStatus === "unavailable" || statusQuote?.costComplete === false;
252 const statusSelectedAmount = statusQuote?.selected?.amount ? Number(statusQuote.selected.amount) : NaN;
253 const rawStatusCostLabel = statusBucketed
254 ? t("context.sessionCostBucketed")
255 : statusUnavailable
256 ? "-"
257 : Number.isFinite(statusSelectedAmount) && statusSelectedAmount > 0
258 ? markEstimated(formatMoneyLocalized(statusSelectedAmount, statusQuote?.selected?.currency || context.sessionCurrency || currency, { locale }), statusQuote?.estimated !== false)
259 : costLabel;
260 const statusCostLabel = appendRateBand(rawStatusCostLabel, statusQuote?.rateBand, t);
261 const rateBandTooltip = t("billing.rateBand.tooltip");
262 const turnCostTooltip = rateBandLabel(turnRateBand, t) ? `${t("status.turnCostTitle")} ${rateBandTooltip}` : t("status.turnCostTitle");
263 const sessionCostTooltip = rateBandLabel(statusQuote?.rateBand, t) ? `${t("status.spendTitle")} ${rateBandTooltip}` : t("status.spendTitle");
264 const balanceLabel = balance?.available && balance.display ? balance.display : "-";
265 const balanceTitle = balance?.available
266 ? (balance.detail
267 ? `${t("status.balanceTitle")}: ${balance.detail}`
268 : t("status.balanceTitle"))
269 : t("status.balanceTitle");
270 const tpsLabel = lastRequestTps === undefined
271 ? formatTps(lastTurnOutputTokens && lastTurnModelMs ? lastTurnOutputTokens / (lastTurnModelMs / 1_000) : null, lastTurnOutputEstimated)
272 : formatTps(lastRequestTps);
273 const formatUsageToken = (value: number) => `${usage?.estimated ? "≈" : ""}${value.toLocaleString()}`;
274 const outputTokensLabel = usage && typeof usage.completionTokens === "number"
275 ? formatUsageToken(usage.completionTokens)
276 : "-";
277 const cacheHit = usage?.cacheHitTokens;
278 const cacheMiss = usage?.cacheMissTokens;
279 const hasCacheTokens = typeof cacheHit === "number" || typeof cacheMiss === "number";
280 const metricLabelStyle = labelStyle === "text" ? "text" : "icon";
281 const visibleItems = normalizeStatusBarItems(items);
282 const cacheTooltip = sourceCacheTooltip(t, t("status.cacheTitle"), context);
283 const avgCacheTooltip = sourceCacheTooltip(t, t("status.cacheAvgTitle"), context);
284 const itemRenderers: Record<StatusBarItemId, ReactNode> = {
285 workspace: branchLabel || workspaceLabel ? (
286 <Tooltip label={workspaceTitle} className="statusbar__metric statusbar__metric--workspace">
287 <span className="stat statusbar__workspace">
288 <span className="stat__label stat__label--icon" aria-hidden="true">{branchLabel ? <GitBranch size={12} /> : <Folder size={12} />}</span>
289 <b>{branchLabel || workspaceLabel}</b>
290 </span>
291 </Tooltip>
292 ) : null,
293 cache: (
294 <Tooltip label={cacheTooltip} className="statusbar__metric statusbar__metric--cache">
295 <span className="stat statusbar__cache">
296 <MetricLabel style={metricLabelStyle} icon={<Percent size={12} />} label={t("status.cacheLabel")} />
297 <b className={rateValueClass(nowPct) || undefined}>{nowPct !== null ? `${nowPct}%` : "-"}</b>
298 </span>
299 </Tooltip>
300 ),
301 cache_avg: (
302 <Tooltip label={avgCacheTooltip} className="statusbar__metric statusbar__metric--avg">
303 <span className="stat statusbar__avg">
304 <MetricLabel style={metricLabelStyle} icon={<Activity size={12} />} label={t("status.cacheAvgLabel")} />
305 <b className={rateValueClass(avgPct) || undefined}>{avgPct !== null ? `${avgPct}%` : "-"}</b>
306 </span>
307 </Tooltip>
308 ),
309 session_tokens: (
310 <Tooltip label={t("status.sessionTokensTitle")} className="statusbar__metric statusbar__metric--tokens">
311 <span className="stat statusbar__tokens">
312 <MetricLabel style={metricLabelStyle} icon={<Database size={12} />} label={t("status.sessionTokensLabel")} />
313 <b className={tokenLabel === "-" ? "stat__value--empty" : undefined}>{tokenLabel}</b>
314 </span>
315 </Tooltip>
316 ),
317 turn_tokens: (
318 <Tooltip label={t("status.turnTokensTitle")} className="statusbar__metric statusbar__metric--turn-tokens">
319 <span className="stat statusbar__turn-tokens">
320 <MetricLabel style={metricLabelStyle} icon={<Zap size={12} />} label={t("status.turnTokensLabel")} />
321 <b className={turnTokenLabel === "-" ? "stat__value--empty" : undefined}>{turnTokenLabel}</b>
322 </span>
323 </Tooltip>
324 ),
325 turn_cost: (
326 <Tooltip label={turnCostTooltip} className="statusbar__metric statusbar__metric--turn-cost">
327 <span className="stat statusbar__turn-cost">
328 <MetricLabel style={metricLabelStyle} icon={<CircleDollarSign size={12} />} label={t("status.turnCostLabel")} />
329 <b>{turnCostLabel}</b>
330 </span>
331 </Tooltip>
332 ),
333 session_turns: (
334 <Tooltip label={t("status.sessionTurnsTitle")} className="statusbar__metric statusbar__metric--turns">
335 <span className="stat statusbar__turns">
336 <MetricLabel style={metricLabelStyle} icon={<RefreshCw size={12} />} label={t("status.sessionTurnsLabel")} />
337 <b className={turnLabel === "-" ? "stat__value--empty" : undefined}>{turnLabel}</b>
338 </span>
339 </Tooltip>
340 ),
341 turn_tps: (
342 <Tooltip label={t("status.tpsTitle")} className="statusbar__metric statusbar__metric--tps">
343 <span className="stat statusbar__tps">
344 <MetricLabel style={metricLabelStyle} icon={<Gauge size={12} />} label={t("status.tpsLabel")} />
345 <b className={tpsLabel === null ? "stat__value--empty" : undefined}>{tpsLabel ?? "-"}</b>
346 </span>
347 </Tooltip>
348 ),
349 turn_output_tokens: (
350 <Tooltip label={t("status.outputTokensTitle")} className="statusbar__metric statusbar__metric--output-tokens">
351 <span className="stat statusbar__output-tokens">
352 <MetricLabel style={metricLabelStyle} icon={<FileOutput size={12} />} label={t("status.outputTokensLabel")} />
353 <b className={outputTokensLabel === "-" ? "stat__value--empty" : undefined}>{outputTokensLabel}</b>
354 </span>
355 </Tooltip>
356 ),
357 turn_cache_tokens: (
358 <Tooltip label={t("status.cacheTokensTitle")} className="statusbar__metric statusbar__metric--cache-tokens">
359 <span className="stat statusbar__cache-tokens">
360 <MetricLabel style={metricLabelStyle} icon={<HardDrive size={12} />} label={t("status.cacheTokensLabel")} />
361 {hasCacheTokens ? (
362 <>
363 <span>{t("status.cacheHitShort")} </span><b>{formatUsageToken(typeof cacheHit === "number" && cacheHit > 0 ? cacheHit : 0)}</b>
364 <span className="statusbar__cache-sep">|</span>
365 <span>{t("status.cacheMissShort")} </span><b>{formatUsageToken(typeof cacheMiss === "number" && cacheMiss > 0 ? cacheMiss : 0)}</b>
366 </>
367 ) : (
368 <b className="stat__value--empty">-</b>
369 )}
370 </span>
371 </Tooltip>
372 ),
373 context: (
374 <Tooltip label={t("status.ctxTitle")} className="statusbar__metric statusbar__metric--ctx">
375 <span className="stat statusbar__ctx">
376 <MetricLabel style={metricLabelStyle} icon={<CircleGauge size={12} />} label={t("status.ctxLabel")} />
377 <b className={pct === null ? "stat__value--empty" : undefined}>{pct !== null ? `${pct}%` : "-"}</b>
378 </span>
379 </Tooltip>
380 ),
381 compact: (
382 <Tooltip label={t("status.compactTitle")} className="statusbar__metric statusbar__metric--compact">
383 <span className="stat statusbar__compact">
384 <MetricLabel style={metricLabelStyle} icon={<Layers size={12} />} label={t("status.compactLabel")} />
385 <b
386 className={[
387 compactPct === null ? "stat__value--empty" : undefined,
388 compactReached ? "statusbar__compact-value--critical" : compactNear ? "statusbar__compact-value--warn" : undefined,
389 ].filter(Boolean).join(" ") || undefined}
390 >
391 {compactPct !== null ? `${compactPct}%` : "-"}
392 </b>
393 </span>
394 </Tooltip>
395 ),
396 cost: (
397 <Tooltip label={sessionCostTooltip} className="statusbar__metric statusbar__metric--cost">
398 <span className="stat statusbar__cost">
399 <MetricLabel style={metricLabelStyle} icon={<CircleDollarSign size={12} />} label={t("status.costLabel")} />
400 <b>{statusCostLabel}</b>
401 </span>
402 </Tooltip>
403 ),
404 balance: (
405 <Tooltip label={balanceTitle} className="statusbar__metric statusbar__metric--balance">
406 <span className="stat stat--balance statusbar__balance">
407 <MetricLabel style={metricLabelStyle} icon={<Wallet size={12} />} label={t("status.balanceLabel")} />
408 <b className={balanceLabel === "-" ? "stat__value--empty" : undefined}>{balanceLabel}</b>
409 </span>
410 </Tooltip>
411 ),
412 };
413 const renderedItems = visibleItems
414 .map((id) => ({ id, node: itemRenderers[id] }))
415 .filter(({ node }) => node !== null && node !== undefined && node !== false);
416 const statusbarRef = useRef<HTMLDivElement>(null);
417 // React's onWheel is a passive listener, so preventDefault() there would be
418 // a no-op (plus a console warning). Register a native non-passive listener
419 // instead so wheel-panning the status bar also stops page scroll.
420 useEffect(() => {
421 const el = statusbarRef.current;
422 if (!el) return;
423 const onWheel = (e: WheelEvent) => {
424 if (el.scrollWidth <= el.clientWidth) return;
425 e.preventDefault();
426 el.scrollLeft += e.deltaY;
427 };
428 el.addEventListener("wheel", onWheel, { passive: false });
429 return () => el.removeEventListener("wheel", onWheel);
430 }, []);
431 return (
432 <div
433 className={`statusbar statusbar--${metricLabelStyle}`}
434 ref={statusbarRef}
435 >
436 <div className="statusbar__group statusbar__group--items">
437 <RemoteStatusBarChip
438 hosts={remoteHosts}
439 statuses={remoteStatuses}
440 onOpen={onOpenRemote}
441 onOpenWorkspace={onOpenRemoteWorkspace}
442 onConnect={onConnectRemote}
443 onDisconnect={onDisconnectRemote}
444 onManage={onManageRemote}
445 />
446 <JobsStatusBarChip
447 jobs={jobs}
448 activeJobsRemote={false}
449 onCancelJob={onCancelJob}
450 runtimes={backgroundRuntimes}
451 onCancelRuntimeJob={onCancelRuntimeJob}
452 onRevealRuntime={onRevealRuntime}
453 />
454 <ExtensionStatusBarChips statuses={extensionStatuses} />
455 {renderedItems.map(({ id, node }) => (
456 <span className="statusbar__item" data-statusbar-item={id} key={id}>
457 {node}
458 </span>
459 ))}
460 </div>
461 </div>
462 );
463 }
464
465 // ExtensionStatusBarChips renders extension-published status surfaces next to
466 // the built-in chips. A surface persists until the owning sidecar replaces it
467 // (same surface key) or the runtime rebuilds; severity drives the accent color.
468 function ExtensionStatusBarChips({ statuses }: { statuses: ExtensionStatusEntry[] }) {
469 const { t } = useI18n();
470 if (statuses.length === 0) return null;
471 return (
472 <>
473 {statuses.map((status) => {
474 const severity = status.severity === "error" ? "error" : status.severity === "warn" ? "warn" : "info";
475 const pct = typeof status.progress === "number" ? Math.round(Math.max(0, Math.min(1, status.progress)) * 100) : undefined;
476 return (
477 <span className="statusbar__item" data-statusbar-item="extension" key={`${status.pluginId}:${status.surfaceId}`}>
478 <Tooltip
479 label={
480 <span className="statusbar__tooltip-stack">
481 <span>{t("status.extensionTitle")}: {status.pluginId}</span>
482 {status.detail ? <span>{status.detail}</span> : null}
483 {pct !== undefined ? <span>{t("ext.card.progress")}: {pct}%</span> : null}
484 </span>
485 }
486 >
487 <span className={`stat statusbar__extension statusbar__extension--${severity}`}>
488 <Puzzle size={12} aria-hidden="true" />
489 <span className="statusbar__extension-label">{status.label}</span>
490 {pct !== undefined ? <b>{pct}%</b> : null}
491 </span>
492 </Tooltip>
493 </span>
494 );
495 })}
496 </>
497 );
498 }
499
500 function JobsStatusBarChip({
501 jobs,
502 activeJobsRemote,
503 onCancelJob,
504 runtimes,
505 onCancelRuntimeJob,
506 onRevealRuntime,
507 }: {
508 jobs: JobView[];
509 activeJobsRemote: boolean;
510 onCancelJob?: (jobID: string) => Promise<boolean>;
511 runtimes: BackgroundRuntimeView[];
512 onCancelRuntimeJob?: (tabID: string, jobID: string) => Promise<boolean>;
513 onRevealRuntime?: (tabID: string) => Promise<void>;
514 }) {
515 const { t } = useI18n();
516 const [open, setOpen] = useState(false);
517 const [stopping, setStopping] = useState<Set<string>>(() => new Set());
518 const triggerRef = useRef<HTMLButtonElement>(null);
519 const groups = runtimes.filter((runtime) => runtime.running || runtime.pendingPrompt || runtime.jobs.length > 0);
520 // BackgroundRuntimes is process-local, while jobs from the active controller
521 // snapshot may come from another runtime. Keep both sources visible.
522 if (jobs.length > 0 && (activeJobsRemote || !groups.some((runtime) => runtime.jobs.length > 0))) {
523 groups.push({ tabId: "", title: "", detached: false, running: false, pendingPrompt: false, jobs });
524 }
525 const totalActivity = groups.reduce(
526 (total, runtime) => total + Math.max(1, runtime.jobs.length),
527 0,
528 );
529
530 useEffect(() => {
531 if (totalActivity === 0) setOpen(false);
532 }, [totalActivity]);
533 if (totalActivity === 0) return null;
534
535 const stop = async (tabID: string, jobID: string) => {
536 const key = `${tabID}:${jobID}`;
537 const handler = tabID ? onCancelRuntimeJob : onCancelJob;
538 if (!handler || stopping.has(key)) return;
539 setStopping((current) => new Set(current).add(key));
540 try {
541 if (tabID && onCancelRuntimeJob) await onCancelRuntimeJob(tabID, jobID);
542 else if (onCancelJob) await onCancelJob(jobID);
543 } finally {
544 setStopping((current) => {
545 const next = new Set(current);
546 next.delete(key);
547 return next;
548 });
549 }
550 };
551
552 return (
553 <span className="statusbar__jobs">
554 <button
555 ref={triggerRef}
556 type="button"
557 className="statusbar__jobs-trigger"
558 aria-label={`${t("status.jobsTitle")}: ${t("status.jobs", { n: totalActivity })}`}
559 aria-expanded={open}
560 aria-haspopup="dialog"
561 title={t("status.jobsTitle")}
562 onClick={() => setOpen((value) => !value)}
563 >
564 <Activity size={12} aria-hidden="true" />
565 <b>{totalActivity}</b>
566 </button>
567 <AnchoredPopover open={open} anchorRef={triggerRef} onClose={() => setOpen(false)} className="jobs-popover" align="start">
568 <section role="dialog" aria-label={t("status.jobsTitle")}>
569 <header className="jobs-popover__header">{t("status.jobsTitle")}</header>
570 <div className="jobs-popover__list">
571 {groups.map((runtime) => (
572 <div className="jobs-popover__runtime" key={runtime.tabId || "active"}>
573 {runtime.tabId && (
574 <div className="jobs-popover__runtime-header">
575 <strong>{runtime.title || t("runtime.unknownTask")}</strong>
576 {onRevealRuntime && (
577 <button type="button" className="btn btn--small" onClick={() => void onRevealRuntime(runtime.tabId)}>
578 {t("status.jobOpenTask")}
579 </button>
580 )}
581 </div>
582 )}
583 {runtime.jobs.length === 0 && (
584 <div className="jobs-popover__job">
585 <span className="jobs-popover__copy">
586 <strong>{runtime.pendingPrompt ? t("status.runtimePendingPrompt") : t("status.runtimeRunning")}</strong>
587 </span>
588 </div>
589 )}
590 {runtime.jobs.map((job) => {
591 const pending = stopping.has(`${runtime.tabId}:${job.id}`);
592 const canStop = runtime.tabId ? Boolean(onCancelRuntimeJob) : Boolean(onCancelJob);
593 return (
594 <div className="jobs-popover__job" key={`${runtime.tabId}:${job.id}`}>
595 <span className="jobs-popover__copy">
596 <strong>{job.label || job.kind}</strong>
597 <small>{job.kind} · {job.status}</small>
598 </span>
599 <button
600 type="button"
601 className="btn btn--small jobs-popover__stop"
602 disabled={pending || !canStop}
603 onClick={() => void stop(runtime.tabId, job.id)}
604 >
605 <Square size={11} aria-hidden="true" />
606 {pending ? t("status.jobStopping") : t("status.jobStop")}
607 </button>
608 </div>
609 );
610 })}
611 </div>
612 ))}
613 </div>
614 </section>
615 </AnchoredPopover>
616 </span>
617 );
618 }
619
620 // This entry remains visible whenever an SSH host is configured. The popover
621 // owns quick connection actions; remote files and services live in the dock.
622 const REMOTE_STATE_SEVERITY: Record<string, number> = {
623 error: 5,
624 reconnecting: 4,
625 pending_hostkey: 4,
626 pending_secret: 4,
627 connecting: 3,
628 degraded: 2,
629 connected: 1,
630 stopped: 0,
631 };
632
633 function RemoteStatusBarChip({
634 hosts,
635 statuses,
636 onOpen,
637 onOpenWorkspace,
638 onConnect,
639 onDisconnect,
640 onManage,
641 }: {
642 hosts: RemoteHostView[];
643 statuses: Record<string, RemoteConnectionStatus>;
644 onOpen?: (hostId: string) => void;
645 onOpenWorkspace?: (host: RemoteHostView) => void;
646 onConnect?: (host: RemoteHostView) => void;
647 onDisconnect?: (hostId: string) => void;
648 onManage?: () => void;
649 }) {
650 const { t } = useI18n();
651 const [open, setOpen] = useState(false);
652 const [detailHostId, setDetailHostId] = useState<string | null>(null);
653 const triggerRef = useRef<HTMLButtonElement>(null);
654 const revealRequest = useRemoteStore((state) => state.statusPopoverRequest);
655 const clearRevealRequest = useRemoteStore((state) => state.clearStatusPopoverRequest);
656
657 useEffect(() => {
658 if (!revealRequest || !hosts.some((host) => host.id === revealRequest.hostId)) return;
659 setOpen(true);
660 clearRevealRequest(revealRequest);
661 }, [clearRevealRequest, hosts, revealRequest]);
662
663 if (hosts.length === 0) return null;
664
665 const entries = hosts.map((host) => statuses[host.id] ?? { hostId: host.id, state: "stopped" as const });
666 const worst = entries.reduce((a, b) => {
667 const aSeverity = isRemoteTerminalFailure(a) ? 6 : REMOTE_STATE_SEVERITY[a.state] ?? 0;
668 const bSeverity = isRemoteTerminalFailure(b) ? 6 : REMOTE_STATE_SEVERITY[b.state] ?? 0;
669 return bSeverity > aSeverity ? b : a;
670 });
671 const worstHost = hosts.find((host) => host.id === worst.hostId) ?? hosts[0];
672 const triggerState = isRemoteTerminalFailure(worst) ? "error" : worst.state;
673 const triggerStatus = isRemoteTerminalFailure(worst) ? t("remote.status.failed") : t(`remote.status.${worst.state}`);
674 const idleDisconnected = worst.state === "stopped" && !worst.error;
675 const triggerLabel = idleDisconnected ? t("remote.statusBar.disconnected") : t("remote.statusBar.summary", { host: worstHost.label, status: triggerStatus });
676 const triggerText = idleDisconnected ? "SSH" : triggerState === "connected" ? worstHost.label : triggerLabel;
677
678 return (
679 <span className="statusbar__remote-wrap">
680 <button
681 ref={triggerRef}
682 type="button"
683 className={`statusbar__remote remote-chip remote-chip--${triggerState}${idleDisconnected ? " statusbar__remote--idle" : ""}`}
684 onClick={() => setOpen((value) => !value)}
685 aria-label={triggerLabel}
686 aria-haspopup="dialog"
687 aria-expanded={open}
688 title={triggerLabel}
689 >
690 {triggerState === "connected" ? <span className="statusbar__remote-state-dot" aria-hidden="true" /> : <Server size={11} aria-hidden="true" />}
691 <span className="statusbar__remote-label">{triggerText}</span>
692 </button>
693 <AnchoredPopover
694 open={open}
695 anchorRef={triggerRef}
696 onClose={() => setOpen(false)}
697 className="remote-switcher"
698 align="start"
699 >
700 <section role="dialog" aria-label={t("remote.switcher.title")}>
701 <header className="remote-switcher__header">{t("remote.switcher.title")}</header>
702 <div className="remote-switcher__section-label">{t("remote.switcher.hosts")}</div>
703 <div className="remote-switcher__hosts">
704 {hosts.map((host) => {
705 const status = statuses[host.id] ?? { hostId: host.id, state: "stopped" as const };
706 const connected = status.state === "connected" || status.state === "degraded";
707 const busy = status.state === "connecting" || status.state === "reconnecting" || status.state === "pending_hostkey" || status.state === "pending_secret";
708 const terminalFailure = isRemoteTerminalFailure(status);
709 const degradedWarning = isRemoteDegradedWarning(status);
710 const stateClass = terminalFailure ? "error" : status.state;
711 const stateLabel = terminalFailure ? t("remote.status.failed") : t(`remote.status.${status.state}`);
712 const errorSummary = status.error ? t(remoteConnectionErrorSummaryKey(status), { host: host.label }) : "";
713 const target = `${host.user ? `${host.user}@` : ""}${host.host}${host.port && host.port !== 22 ? `:${host.port}` : ""}`;
714 return (
715 <div className={`remote-switcher__host remote-switcher__host--${stateClass}`} key={host.id}>
716 <button
717 type="button"
718 className="remote-switcher__host-main"
719 onClick={() => {
720 setOpen(false);
721 onOpen?.(host.id);
722 }}
723 >
724 <span className={`remote-switcher__state remote-switcher__state--${stateClass}`} aria-hidden="true" />
725 <span className="remote-switcher__copy">
726 <strong>{host.label}</strong>
727 <small>{stateLabel} · {host.defaultWorkspace || target}</small>
728 </span>
729 </button>
730 <span className="remote-switcher__actions">
731 <button
732 type="button"
733 className="btn btn--small btn--primary"
734 disabled={busy}
735 onClick={() => {
736 if (connected) {
737 setOpen(false);
738 onOpenWorkspace?.(host);
739 } else {
740 setOpen(false);
741 onConnect?.(host);
742 }
743 }}
744 >
745 {connected ? t("remote.openWorkspace") : busy ? stateLabel : terminalFailure ? t("remote.error.retry") : t("remote.connectAndOpen")}
746 </button>
747 {connected && (
748 <button
749 type="button"
750 className="remote-switcher__disconnect"
751 onClick={() => onDisconnect?.(host.id)}
752 aria-label={t("remote.disconnectHost", { host: host.label })}
753 title={t("remote.disconnect")}
754 >
755 <Unplug size={13} aria-hidden="true" />
756 </button>
757 )}
758 </span>
759 {(terminalFailure || degradedWarning) && (
760 <div className={`remote-switcher__error-card ${degradedWarning ? "remote-switcher__error-card--warning" : ""}`} role="alert">
761 <strong>{t(degradedWarning ? "remote.status.degraded" : "remote.status.failed")}</strong>
762 <span>{errorSummary}</span>
763 <div className="remote-switcher__error-actions">
764 <button
765 type="button"
766 className="btn btn--small"
767 onClick={() => {
768 setOpen(false);
769 setDetailHostId(host.id);
770 }}
771 >
772 {t(isRemoteHostKeyMismatch(status) ? "remote.error.hostKeyDetails" : "remote.error.details")}
773 </button>
774 <button
775 type="button"
776 className="btn btn--small"
777 onClick={() => {
778 setOpen(false);
779 onManage?.();
780 }}
781 >
782 {t("remote.error.manage")}
783 </button>
784 </div>
785 </div>
786 )}
787 </div>
788 );
789 })}
790 </div>
791 <button
792 type="button"
793 className="remote-switcher__manage"
794 onClick={() => {
795 setOpen(false);
796 onManage?.();
797 }}
798 >
799 <Settings size={13} aria-hidden="true" />
800 {t("remote.switcher.manage")}
801 </button>
802 </section>
803 </AnchoredPopover>
804 {detailHostId && (() => {
805 const host = hosts.find((item) => item.id === detailHostId);
806 const status = statuses[detailHostId];
807 if (!host || !status?.error) return null;
808 return (
809 <RemoteConnectionErrorDialog
810 host={host}
811 status={status}
812 onClose={() => setDetailHostId(null)}
813 onManage={onManage}
814 onRetry={() => onConnect?.(host)}
815 />
816 );
817 })()}
818 </span>
819 );
820 }
821
821 lines Plain Text