@skyhook-io/radar-app 1.8.6 → 1.8.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +7 -1
  2. package/package.json +11 -9
  3. package/src/App.tsx +221 -181
  4. package/src/RadarApp.tsx +91 -20
  5. package/src/api/client.delta.test.ts +89 -0
  6. package/src/api/client.deltaSync.test.ts +216 -0
  7. package/src/api/client.ts +262 -34
  8. package/src/api/diagnose.ts +288 -0
  9. package/src/api/timelineSource.test.ts +217 -0
  10. package/src/api/timelineSource.ts +580 -0
  11. package/src/components/ConnectionErrorView.tsx +174 -70
  12. package/src/components/ContextSwitcher.tsx +13 -5
  13. package/src/components/applications/ApplicationsView.tsx +327 -26
  14. package/src/components/audit/AuditSettingsDialog.tsx +2 -2
  15. package/src/components/curl/ServiceCurlButton.tsx +2 -2
  16. package/src/components/diagnose/AISettings.tsx +121 -0
  17. package/src/components/diagnose/DiagnoseContext.tsx +491 -0
  18. package/src/components/diagnose/DiagnoseSurface.tsx +385 -0
  19. package/src/components/diagnose/Home.tsx +163 -0
  20. package/src/components/diagnose/InvestigationView.tsx +604 -0
  21. package/src/components/diagnose/LocalDiagnoseAction.tsx +150 -0
  22. package/src/components/diagnose/launch.ts +65 -0
  23. package/src/components/diagnose/parts.tsx +1689 -0
  24. package/src/components/dock/BottomDock.tsx +2 -3
  25. package/src/components/dock/WorkloadLogsTab.tsx +21 -5
  26. package/src/components/execution/BatchExecutionView.test.ts +170 -0
  27. package/src/components/execution/BatchExecutionView.tsx +1329 -0
  28. package/src/components/execution/batch-run-actions.test.ts +48 -0
  29. package/src/components/execution/batch-run-actions.ts +24 -0
  30. package/src/components/execution/batch-timeline.test.ts +57 -0
  31. package/src/components/execution/batch-timeline.ts +46 -0
  32. package/src/components/execution/execution-definition.test.ts +208 -0
  33. package/src/components/execution/execution-definition.ts +245 -0
  34. package/src/components/helm/ChartBrowser.tsx +2 -3
  35. package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
  36. package/src/components/helm/HelmReleaseDrawer.tsx +376 -43
  37. package/src/components/helm/HelmView.tsx +2 -3
  38. package/src/components/helm/InstallWizard.tsx +3 -5
  39. package/src/components/helm/TrackChartSourceDialog.tsx +48 -4
  40. package/src/components/helm/ValuesDiffPreview.tsx +15 -4
  41. package/src/components/home/HomeView.tsx +17 -15
  42. package/src/components/home/MCPSetupDialog.tsx +1 -1
  43. package/src/components/home/mcpToolCatalog.ts +2 -2
  44. package/src/components/issues/IssuesPane.tsx +9 -1
  45. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
  46. package/src/components/portforward/PortForwardButton.tsx +2 -2
  47. package/src/components/portforward/PortForwardManager.tsx +19 -14
  48. package/src/components/resource/PrometheusChartsGrid.tsx +6 -80
  49. package/src/components/resource/RightsizingStrip.tsx +0 -3
  50. package/src/components/resources/ImageFilesystemModal.tsx +2 -2
  51. package/src/components/resources/PodFilesystemModal.tsx +2 -3
  52. package/src/components/resources/ResourceDetailDrawer.tsx +2 -0
  53. package/src/components/resources/ResourcesView.tsx +13 -4
  54. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
  55. package/src/components/resources/renderers/index.ts +1 -0
  56. package/src/components/settings/SettingsDialog.tsx +80 -23
  57. package/src/components/shared/LargeClusterNamespacePicker.tsx +2 -2
  58. package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
  59. package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
  60. package/src/components/timeline/TimelineList.tsx +51 -10
  61. package/src/components/timeline/TimelineView.tsx +701 -15
  62. package/src/components/timeline/TimelineView.urlparams.test.ts +294 -0
  63. package/src/components/traffic/TrafficFilterSidebar.tsx +2 -2
  64. package/src/components/traffic/TrafficView.tsx +8 -4
  65. package/src/components/ui/CommandPalette.tsx +2 -2
  66. package/src/components/workload/WorkloadView.tsx +319 -35
  67. package/src/context/ConnectionContext.tsx +109 -11
  68. package/src/context/DiagnoseCustomization.tsx +42 -0
  69. package/src/context/TimelineSource.tsx +50 -0
  70. package/src/hooks/useClusterLoadState.ts +73 -0
  71. package/src/hooks/useEventSource.ts +6 -0
  72. package/src/index.css +157 -0
  73. package/src/index.ts +12 -0
  74. package/src/types/clusterLoadState.ts +33 -0
  75. package/src/utils/navigation.ts +10 -0
@@ -0,0 +1,385 @@
1
+ // The right-docked shell of the AI surface. Two layouts:
2
+ // - docked: a single-pane right column (app reflows left via the provider's push)
3
+ // - expanded: a master-detail workspace that fills ONLY the content area (does
4
+ // not cover the left nav rail or top bar) — recent list on the left, the
5
+ // selected investigation/report on the right.
6
+ import { useState } from "react";
7
+ import {
8
+ Sparkles,
9
+ X,
10
+ Maximize2,
11
+ Minimize2,
12
+ ChevronLeft,
13
+ Settings2,
14
+ MoreVertical,
15
+ TerminalSquare,
16
+ Copy,
17
+ Check,
18
+ } from "lucide-react";
19
+ import { Tooltip } from "../ui/Tooltip";
20
+ import {
21
+ useDiagnose,
22
+ useDiagnoseLayout,
23
+ agentLabelFor,
24
+ openDiagnoseSettings,
25
+ } from "./DiagnoseContext";
26
+ import { InvestigationView } from "./InvestigationView";
27
+ import { RecentList } from "./Home";
28
+ import { ConsentCard } from "./parts";
29
+ import { buildLaunchCommand, launchAgentLabel, openInTerminal } from "./launch";
30
+ import { type RunSummary } from "../../api/diagnose";
31
+
32
+ function capWord(s: string): string {
33
+ return s ? s[0].toUpperCase() + s.slice(1) : s;
34
+ }
35
+
36
+ // buildConfigLine renders the active AI config as the header subtitle. Codex shows
37
+ // its isolation mode + effective reasoning effort (Default → medium); a model
38
+ // override is shown for either agent. Reflects a run's recorded settings, or the
39
+ // current defaults on Home.
40
+ function buildConfigLine(cfg: {
41
+ agent?: string;
42
+ isolated?: boolean;
43
+ model?: string;
44
+ effort?: string;
45
+ }): string {
46
+ const parts = [agentLabelFor(cfg.agent ?? "")];
47
+ if (cfg.agent === "codex") {
48
+ parts.push(cfg.isolated === false ? "My setup" : "Isolated");
49
+ parts.push(`${capWord(cfg.effort || "medium")} effort`);
50
+ }
51
+ if (cfg.model) parts.push(capWord(cfg.model));
52
+ return "via " + parts.join(" · ");
53
+ }
54
+
55
+ // InvestigationMenu hands an investigation off to the user's own full agent. The
56
+ // PRIMARY action copies a resume command they can paste wherever they actually
57
+ // work (terminal, tmux, IDE) — destination-agnostic. Running it in Radar's built-in
58
+ // terminal is the secondary "run it here" convenience.
59
+ function InvestigationMenu({ run }: { run: RunSummary }) {
60
+ const [open, setOpen] = useState(false);
61
+ const [copied, setCopied] = useState(false);
62
+ const label = launchAgentLabel(run);
63
+ const command = buildLaunchCommand(run, `${window.location.origin}/mcp`);
64
+ // No resumable session yet (or stale run) → nothing to hand off.
65
+ if (!command) return null;
66
+
67
+ const toggle = () => {
68
+ setCopied(false);
69
+ setOpen((v) => !v);
70
+ };
71
+ const copy = () => {
72
+ void navigator.clipboard?.writeText(command);
73
+ setCopied(true);
74
+ setTimeout(() => {
75
+ setCopied(false);
76
+ setOpen(false);
77
+ }, 1100);
78
+ };
79
+
80
+ return (
81
+ <div className="relative flex items-center">
82
+ <Tooltip content="More" position="bottom">
83
+ <button
84
+ onClick={toggle}
85
+ className="rounded-md p-1 text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary"
86
+ aria-label="More actions"
87
+ aria-haspopup="menu"
88
+ aria-expanded={open}
89
+ >
90
+ <MoreVertical className="h-4 w-4" />
91
+ </button>
92
+ </Tooltip>
93
+ {open && (
94
+ <>
95
+ <div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
96
+ <div className="absolute right-0 top-full z-20 mt-1 w-64 rounded-lg border border-theme-border bg-theme-surface py-1 shadow-theme-lg">
97
+ <button
98
+ onClick={copy}
99
+ className="flex w-full items-start gap-2 px-3 py-1.5 text-left text-sm text-theme-text-primary hover:bg-theme-hover"
100
+ >
101
+ {copied ? (
102
+ <Check className="mt-0.5 h-4 w-4 shrink-0 text-emerald-500" />
103
+ ) : (
104
+ <Copy className="mt-0.5 h-4 w-4 shrink-0 text-theme-text-tertiary" />
105
+ )}
106
+ <span>
107
+ {copied ? "Copied ✓" : `Copy command to continue in ${label}`}
108
+ {!copied && (
109
+ <span className="block text-[11px] text-theme-text-tertiary">
110
+ Paste it wherever you run {label} — resumes this exact
111
+ session.
112
+ </span>
113
+ )}
114
+ </span>
115
+ </button>
116
+ <button
117
+ onClick={() => {
118
+ openInTerminal(command, "Diagnose");
119
+ setOpen(false);
120
+ }}
121
+ className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-theme-text-primary hover:bg-theme-hover"
122
+ >
123
+ <TerminalSquare className="h-4 w-4 shrink-0 text-theme-text-tertiary" />
124
+ Run in a Radar terminal
125
+ </button>
126
+ </div>
127
+ </>
128
+ )}
129
+ </div>
130
+ );
131
+ }
132
+
133
+ // The panel is an ABSOLUTE slot inside the app's body frame (the column under the
134
+ // header, right of the nav rail) — App renders it there and passes topInset (the
135
+ // header height; 0 in chromeless embeds). It shares that frame with the resource/
136
+ // Helm drawers, so it no longer floats viewport-fixed or DOM-measures the chrome.
137
+ export function DiagnoseSurface({ topInset = 0 }: { topInset?: number }) {
138
+ const d = useDiagnose();
139
+ const {
140
+ maximized,
141
+ setMaximized,
142
+ panelWidth: width,
143
+ setPanelWidth: setWidth,
144
+ panelNarrow: narrow,
145
+ panelBounds: { min: minW, max: maxW },
146
+ panelWidthKey: widthKey,
147
+ } = useDiagnoseLayout();
148
+
149
+ const startResize = (e: React.MouseEvent) => {
150
+ e.preventDefault();
151
+ const onMove = (m: MouseEvent) =>
152
+ setWidth(() =>
153
+ Math.min(maxW, Math.max(minW, window.innerWidth - m.clientX)),
154
+ );
155
+ const onUp = () => {
156
+ document.removeEventListener("mousemove", onMove);
157
+ document.removeEventListener("mouseup", onUp);
158
+ setWidth((w) => {
159
+ try {
160
+ localStorage.setItem(widthKey, String(w));
161
+ } catch {
162
+ /* storage disabled — width just won't persist */
163
+ }
164
+ return w;
165
+ });
166
+ };
167
+ document.addEventListener("mousemove", onMove);
168
+ document.addEventListener("mouseup", onUp);
169
+ };
170
+
171
+ const activeRun = d.runs.find((r) => r.id === d.activeRunId) ?? null;
172
+ // A focused run shows the agent it actually ran with; Home reflects the current pick.
173
+ const activeAgentLabel = activeRun?.agent
174
+ ? agentLabelFor(activeRun.agent)
175
+ : d.agentLabel;
176
+ // Header subtitle: the config a focused run actually used (it records agent /
177
+ // isolation / model / effort), or the current defaults on Home. Codex shows mode
178
+ // + reasoning effort; model is shown only when overridden. Clicking opens Settings.
179
+ const configLine = buildConfigLine(
180
+ activeRun ?? {
181
+ agent: d.selectedAgent,
182
+ isolated: d.isolated,
183
+ model: d.model,
184
+ effort: d.effort,
185
+ },
186
+ );
187
+ const detailTitle = activeRun
188
+ ? `${activeRun.kind} ${activeRun.namespace ? `${activeRun.namespace}/` : ""}${activeRun.name}`
189
+ : "AI investigations";
190
+
191
+ // Absolute within the body frame: maximized fills it; docked is a right slot.
192
+ // topInset clears the header (the frame spans the full column incl. the header).
193
+ const positionStyle: React.CSSProperties = maximized
194
+ ? { top: topInset, left: 0, right: 0, bottom: 0 }
195
+ : { top: topInset, right: 0, bottom: 0, width, maxWidth: "100%" };
196
+
197
+ // The detail pane (right side when expanded; the whole body when docked).
198
+ // Keyed by run id so toggling Expand doesn't remount a focused run's view.
199
+ const detail = d.needsConsent ? (
200
+ <div className="flex-1 overflow-y-auto px-4 py-3">
201
+ <div className={maximized ? "mx-auto max-w-3xl" : ""}>
202
+ <ConsentCard
203
+ agentName={d.agentLabel}
204
+ agent={d.selectedAgent}
205
+ isolated={d.isolated}
206
+ onOpenSettings={openDiagnoseSettings}
207
+ onApprove={d.approveConsent}
208
+ onCancel={d.cancelConsent}
209
+ />
210
+ </div>
211
+ </div>
212
+ ) : activeRun ? (
213
+ <InvestigationView
214
+ key={activeRun.id}
215
+ run={activeRun}
216
+ agentLabel={activeAgentLabel}
217
+ maximized={maximized}
218
+ />
219
+ ) : d.activeRunId && !d.runsLoaded ? (
220
+ // Deep-linked to a run before the list has ever loaded: show the load
221
+ // state (the 4s poll retries while the panel is open) — never the generic
222
+ // placeholder, and never a premature "no longer available".
223
+ <div className="flex flex-1 items-center justify-center px-6 text-center text-sm text-theme-text-tertiary">
224
+ {d.runsLoadFailed
225
+ ? "Couldn't load investigations — retrying…"
226
+ : "Loading investigations…"}
227
+ </div>
228
+ ) : d.activeRunId && d.runsLoaded ? (
229
+ // A focused id that isn't in the loaded list — a deep link (?ai-run=…) to a
230
+ // cleared/evicted/unknown run. Say so; the generic "select an
231
+ // investigation" placeholder would read as a broken link.
232
+ <div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
233
+ <p className="text-sm text-theme-text-secondary">
234
+ This investigation is no longer available — history keeps the most
235
+ recent investigations, and this one has been cleared.
236
+ </p>
237
+ <button
238
+ onClick={d.goHome}
239
+ className="rounded-lg border border-theme-border px-3 py-1.5 text-sm text-theme-text-secondary hover:bg-theme-hover"
240
+ >
241
+ View investigations
242
+ </button>
243
+ </div>
244
+ ) : d.startError ? (
245
+ <div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
246
+ <p className="text-sm text-theme-text-secondary">{d.startError}</p>
247
+ <button
248
+ onClick={d.dismissError}
249
+ className="rounded-lg border border-theme-border px-3 py-1.5 text-sm text-theme-text-secondary hover:bg-theme-hover"
250
+ >
251
+ Dismiss
252
+ </button>
253
+ </div>
254
+ ) : (
255
+ <div className="flex flex-1 items-center justify-center px-6 text-center text-sm text-theme-text-tertiary">
256
+ Select an investigation, or open a resource and click Diagnose.
257
+ </div>
258
+ );
259
+
260
+ const showBreadcrumb = !maximized && d.view !== "home";
261
+ // The maximized workspace always shows the recent-investigations list (there's
262
+ // room for it in full-wide) — it's the master pane of the master-detail layout.
263
+ const showHistory = maximized;
264
+
265
+ return (
266
+ <div
267
+ role="dialog"
268
+ aria-label="AI investigations"
269
+ className="absolute z-40 flex flex-col border-l border-theme-border bg-theme-surface shadow-drawer"
270
+ style={{
271
+ ...positionStyle,
272
+ animation: "slide-in-from-right 0.22s cubic-bezier(0.32,0.72,0,1)",
273
+ }}
274
+ >
275
+ {!maximized && !narrow && (
276
+ <div
277
+ onMouseDown={startResize}
278
+ className="absolute left-0 top-0 z-10 h-full w-1.5 cursor-ew-resize bg-theme-border/40 transition-colors hover:bg-accent/50"
279
+ role="separator"
280
+ aria-orientation="vertical"
281
+ aria-label="Resize panel"
282
+ />
283
+ )}
284
+
285
+ {/* Header */}
286
+ <div className="flex items-center justify-between border-b border-theme-border px-4 py-2.5">
287
+ <div className="flex min-w-0 items-center gap-2">
288
+ {!showBreadcrumb && (
289
+ <Sparkles className="h-4 w-4 shrink-0 text-accent" />
290
+ )}
291
+ <div className="min-w-0">
292
+ {showBreadcrumb && (
293
+ <button
294
+ onClick={d.goHome}
295
+ className="-ml-1 mb-0.5 flex items-center gap-0.5 rounded px-1 text-[11px] text-theme-text-tertiary hover:text-theme-text-primary"
296
+ >
297
+ <ChevronLeft className="h-3 w-3" />
298
+ Investigations
299
+ </button>
300
+ )}
301
+ <div className="truncate text-sm font-medium text-theme-text-primary">
302
+ {detailTitle}
303
+ </div>
304
+ <div className="flex items-center gap-1 text-xs text-theme-text-tertiary">
305
+ <span className="truncate">{configLine}</span>
306
+ <Tooltip content="AI settings" position="bottom">
307
+ <button
308
+ onClick={openDiagnoseSettings}
309
+ className="shrink-0 rounded p-0.5 text-theme-text-tertiary hover:text-theme-text-primary"
310
+ aria-label="AI settings"
311
+ >
312
+ <Settings2 className="h-3 w-3" />
313
+ </button>
314
+ </Tooltip>
315
+ </div>
316
+ </div>
317
+ </div>
318
+ <div className="flex shrink-0 items-center gap-0.5">
319
+ {activeRun && <InvestigationMenu run={activeRun} />}
320
+ <Tooltip content={maximized ? "Restore" : "Expand"} position="bottom">
321
+ <button
322
+ onClick={() => setMaximized((v) => !v)}
323
+ className="rounded-md p-1 text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary"
324
+ aria-label={maximized ? "Restore" : "Expand"}
325
+ >
326
+ {maximized ? (
327
+ <Minimize2 className="h-4 w-4" />
328
+ ) : (
329
+ <Maximize2 className="h-4 w-4" />
330
+ )}
331
+ </button>
332
+ </Tooltip>
333
+ <Tooltip content="Close" position="bottom">
334
+ <button
335
+ onClick={d.close}
336
+ className="rounded-md p-1 text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary"
337
+ aria-label="Close"
338
+ >
339
+ <X className="h-4 w-4" />
340
+ </button>
341
+ </Tooltip>
342
+ </div>
343
+ </div>
344
+
345
+ {/* Body. The detail wrapper keeps a stable position + key across both
346
+ layouts so toggling Expand doesn't remount a live InvestigationView
347
+ (which would discard its transcript and re-run the agent). The aside
348
+ only appears when expanded; keys keep the detail node identity-stable
349
+ as it comes and goes. */}
350
+ <div className="flex min-h-0 flex-1">
351
+ {showHistory && (
352
+ <aside
353
+ key="recent"
354
+ className="w-72 shrink-0 overflow-y-auto border-r border-theme-border px-3 py-3"
355
+ >
356
+ <RecentList
357
+ agentLabel={d.agentLabel}
358
+ runs={d.runs}
359
+ selectedId={d.activeRunId}
360
+ onSelect={d.openRun}
361
+ historyDegraded={d.historyDegraded}
362
+ />
363
+ </aside>
364
+ )}
365
+ {!maximized && d.view === "home" ? (
366
+ <div
367
+ key="main"
368
+ className="flex-1 overflow-y-auto overflow-x-hidden px-4 py-3"
369
+ >
370
+ <RecentList
371
+ agentLabel={d.agentLabel}
372
+ runs={d.runs}
373
+ onSelect={d.openRun}
374
+ historyDegraded={d.historyDegraded}
375
+ />
376
+ </div>
377
+ ) : (
378
+ <div key="main" className="flex min-h-0 min-w-0 flex-1 flex-col">
379
+ {detail}
380
+ </div>
381
+ )}
382
+ </div>
383
+ </div>
384
+ );
385
+ }
@@ -0,0 +1,163 @@
1
+ // The recent-investigations list — now backed by server-side runs (the source of
2
+ // truth), so background/running investigations appear here live. Used both as the
3
+ // docked Home view and the master pane of the maximized workspace.
4
+ import { Loader2, Sparkles } from "lucide-react";
5
+ import { StatusDot, type StatusTone } from "@skyhook-io/k8s-ui";
6
+ import { type RunSummary } from "../../api/diagnose";
7
+
8
+ // Compact "3m ago" / "2h ago" / date label.
9
+ function relativeTime(ts: number, now: number): string {
10
+ const s = Math.max(0, Math.round((now - ts) / 1000));
11
+ if (s < 60) return "just now";
12
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
13
+ if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
14
+ if (s < 7 * 86400) return `${Math.floor(s / 86400)}d ago`;
15
+ return new Date(ts).toLocaleDateString();
16
+ }
17
+
18
+ // Map a run status to the design-system status tone (StatusDot). stopped is
19
+ // user-initiated → neutral/unknown, NOT a failure (distinct from error).
20
+ function runTone(status: RunSummary["status"]): StatusTone {
21
+ switch (status) {
22
+ case "error":
23
+ return "unhealthy";
24
+ case "stale":
25
+ return "degraded";
26
+ case "done":
27
+ return "healthy";
28
+ default: // stopped
29
+ return "unknown";
30
+ }
31
+ }
32
+
33
+ function statusDot(status: RunSummary["status"]) {
34
+ if (status === "running")
35
+ return <Loader2 className="h-3 w-3 shrink-0 animate-spin text-accent" />;
36
+ return <StatusDot tone={runTone(status)} className="shrink-0" />;
37
+ }
38
+
39
+ // A short text status for terminal non-done states, so the run's outcome doesn't
40
+ // rely on decoding a 6px colored dot (and so "I stopped it" reads differently from
41
+ // "it failed"). Done/running are conveyed by the dot + time already.
42
+ function statusWord(
43
+ status: RunSummary["status"],
44
+ ): { text: string; cls: string } | null {
45
+ switch (status) {
46
+ case "error":
47
+ return { text: "Failed", cls: "text-red-400" };
48
+ case "stopped":
49
+ return { text: "Stopped", cls: "text-theme-text-tertiary" };
50
+ case "stale":
51
+ // Plain words, not the internal status name: "stale" means the run was
52
+ // about a cluster that's no longer connected.
53
+ return { text: "Different cluster", cls: "text-amber-500" };
54
+ default:
55
+ return null;
56
+ }
57
+ }
58
+
59
+ export function RecentList({
60
+ agentLabel,
61
+ runs,
62
+ onSelect,
63
+ selectedId,
64
+ historyDegraded = false,
65
+ }: {
66
+ agentLabel: string;
67
+ runs: RunSummary[];
68
+ onSelect: (id: string) => void;
69
+ selectedId?: string | null;
70
+ historyDegraded?: boolean;
71
+ }) {
72
+ const now = Date.now();
73
+
74
+ // Persistence broke (disk error) — without this the user reasonably assumes
75
+ // their history survives a restart, and it won't.
76
+ const degradedNote = historyDegraded ? (
77
+ <div className="mb-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-2.5 py-1.5 text-[11px] leading-snug text-theme-text-secondary">
78
+ History isn&apos;t being saved right now (disk error) — investigations
79
+ won&apos;t survive a restart.
80
+ </div>
81
+ ) : null;
82
+
83
+ if (runs.length === 0) {
84
+ return (
85
+ <div>
86
+ {degradedNote}
87
+ <div className="flex flex-col items-center px-4 py-12 text-center">
88
+ <Sparkles className="mb-3 h-7 w-7 text-accent" />
89
+ <div className="text-sm font-medium text-theme-text-primary">
90
+ No investigations yet
91
+ </div>
92
+ <p className="mt-1 max-w-xs text-sm text-theme-text-tertiary">
93
+ Open a resource and use its{" "}
94
+ <Sparkles className="inline h-3.5 w-3.5 align-text-bottom text-accent" />{" "}
95
+ action to investigate it with {agentLabel} —{" "}
96
+ <span className="font-medium text-theme-text-secondary">Diagnose</span>{" "}
97
+ a problem, or just ask about it. Investigations run in the background
98
+ and are kept in your history here.
99
+ </p>
100
+ </div>
101
+ </div>
102
+ );
103
+ }
104
+
105
+ return (
106
+ <div className="space-y-2">
107
+ {degradedNote}
108
+ <div className="text-[11px] font-medium uppercase tracking-wide text-theme-text-tertiary">
109
+ Investigations
110
+ </div>
111
+ {runs.map((r) => (
112
+ <button
113
+ key={r.id}
114
+ onClick={() => onSelect(r.id)}
115
+ className={`flex w-full flex-col gap-0.5 rounded-md border px-2.5 py-2 text-left ${
116
+ r.id === selectedId
117
+ ? "border-accent/50 bg-accent/10"
118
+ : "border-theme-border/60 bg-theme-base/40 hover:bg-theme-hover"
119
+ }`}
120
+ >
121
+ <div className="flex items-center gap-2">
122
+ {statusDot(r.status)}
123
+ <span className="min-w-0 flex-1 truncate text-sm text-theme-text-primary">
124
+ {r.kind} {r.namespace ? `${r.namespace}/` : ""}
125
+ {r.name}
126
+ </span>
127
+ <span className="shrink-0 text-[11px] text-theme-text-tertiary">
128
+ {r.status === "running" ? (
129
+ "running…"
130
+ ) : (
131
+ <>
132
+ {(() => {
133
+ const w = statusWord(r.status);
134
+ return w ? (
135
+ <span className={`font-medium ${w.cls}`}>
136
+ {w.text} ·{" "}
137
+ </span>
138
+ ) : null;
139
+ })()}
140
+ {relativeTime(new Date(r.updatedAt).getTime(), now)}
141
+ </>
142
+ )}
143
+ </span>
144
+ </div>
145
+ {(r.status === "stale" && r.context) || r.preview ? (
146
+ <div className="truncate pl-3.5 text-xs text-theme-text-tertiary">
147
+ {/* A foreign-cluster run names its cluster — in mixed multi-
148
+ context history, identical-looking rows otherwise give no way
149
+ to tell WHICH cluster an investigation was about. */}
150
+ {r.status === "stale" && r.context ? (
151
+ <span className="text-amber-600/80 dark:text-amber-500/80">
152
+ {r.context}
153
+ </span>
154
+ ) : null}
155
+ {r.status === "stale" && r.context && r.preview ? " · " : ""}
156
+ {r.preview}
157
+ </div>
158
+ ) : null}
159
+ </button>
160
+ ))}
161
+ </div>
162
+ );
163
+ }