| 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 |