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