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