| 1 | import { useSyncExternalStore } from "react"; |
| 2 | import { runtimeStateStore, selectRuntime } from "../lib/runtimeStateStore"; |
| 3 | import { useT } from "../lib/i18n"; |
| 4 | import { asArray } from "../lib/array"; |
| 5 | import type { ProjectNode } from "../lib/types"; |
| 6 | |
| 7 | /** |
| 8 | * Workspace identity the runtime store is matched against. Sidebars differ in |
| 9 | * how they list sessions, so the activity rule lives here instead of being |
| 10 | * reimplemented per surface; `topics` only supplies human labels and |
| 11 | * `fallbackActive` only covers surfaces with no runtime snapshot yet. |
| 12 | */ |
| 13 | export type RuntimeActivityTarget = { |
| 14 | scope: "global" | "project"; |
| 15 | root: string; |
| 16 | remote?: { hostId: string; workspace: string }; |
| 17 | topics?: ProjectNode[]; |
| 18 | fallbackActive?: boolean; |
| 19 | }; |
| 20 | |
| 21 | export default function RuntimeActivityIndicator({ target }: { target: RuntimeActivityTarget }) { |
| 22 | const snapshot = useSyncExternalStore(runtimeStateStore.subscribe, runtimeStateStore.getSnapshot); |
| 23 | const failed = useSyncExternalStore(runtimeStateStore.subscribe, runtimeStateStore.getFailed); |
| 24 | const t = useT(); |
| 25 | const matched = snapshot?.sessions.filter(session => target.remote |
| 26 | ? session.remote && session.hostId === target.remote.hostId && session.workspaceRoot === target.remote.workspace |
| 27 | : session.scope === target.scope && (target.scope === "global" || session.workspaceRoot === target.root)); |
| 28 | const sessions = matched && [...new Map(matched.map(session => [`${session.hostId ?? "local"}\0${session.workspaceRoot}\0${session.sessionPath}`, session])).values()]; |
| 29 | if (sessions) { |
| 30 | const active = sessions.filter(session => { |
| 31 | const view = selectRuntime(session, failed); |
| 32 | return view.unknown || (view.known && (view.kind !== "idle" || session.state.backgroundJobs > 0)); |
| 33 | }); |
| 34 | if (!active.length) return null; |
| 35 | const details = active.map(session => { |
| 36 | const state = session.state; |
| 37 | const topic = asArray(target.topics).find(node => node.topicId === session.topicId || (session.sessionPath && node.sessionPath === session.sessionPath)); |
| 38 | const labels: string[] = []; |
| 39 | if (failed || session.freshness !== "synced") labels.push(t("runtime.unknown")); |
| 40 | if (state.phase === "finishing") labels.push(t("runtime.finishing")); |
| 41 | if (state.cancelRequested) labels.push(t("status.jobStopping")); |
| 42 | if (state.pendingPrompt) labels.push(t("projectTree.status.waitingConfirmation")); |
| 43 | if (state.phase === "executing") labels.push(t(state.activity === "streaming" ? "projectTree.status.streaming" : "projectTree.status.thinking")); |
| 44 | if (state.backgroundJobs) labels.push(t("runtime.background", { count: state.backgroundJobs })); |
| 45 | return `${topic?.label || session.tabId}: ${labels.join(" · ")}`; |
| 46 | }).join("; "); |
| 47 | const spinning = active.some(session => selectRuntime(session, failed).spinning); |
| 48 | return <span className={`runtime-activity-indicator${spinning ? "" : " runtime-activity-indicator--static"}`} style={spinning ? undefined : { animation: "none" }} role="status" aria-label={details} title={details} />; |
| 49 | } |
| 50 | if (!target.fallbackActive) return null; |
| 51 | return <span className="runtime-activity-indicator" role="status" aria-label={t("projectTree.status.thinking")} />; |
| 52 | } |
| 53 |