@skyhook-io/radar-app 1.13.0 → 1.13.1

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.
@@ -1,5 +1,9 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { canStartNewInvestigation } from "./DiagnoseSurface";
2
+ import { canCopyRunLink, canStartNewInvestigation } from "./DiagnoseSurface";
3
+ import {
4
+ canContinueInvestigation,
5
+ canStopInvestigation,
6
+ } from "./InvestigationView";
3
7
  import type { RunSummary } from "../../api/diagnose";
4
8
 
5
9
  // The "new investigation" button dispatches an agent and spends the user's own
@@ -34,9 +38,25 @@ describe("canStartNewInvestigation", () => {
34
38
 
35
39
  it("stays hidden while a turn is in flight", () => {
36
40
  // A start would be handed back the live run, so the button does nothing.
37
- expect(canStartNewInvestigation("investigation", run("running"), false)).toBe(
38
- false,
39
- );
41
+ expect(
42
+ canStartNewInvestigation("investigation", run("running"), false),
43
+ ).toBe(false);
44
+ });
45
+
46
+ it("stays hidden while a stopped turn is draining", () => {
47
+ expect(
48
+ canStartNewInvestigation("investigation", run("stopping"), false),
49
+ ).toBe(false);
50
+ });
51
+
52
+ it("offers a separate human investigation while an automatic run is in flight", () => {
53
+ expect(
54
+ canStartNewInvestigation(
55
+ "investigation",
56
+ { ...run("running"), trigger: "background" },
57
+ false,
58
+ ),
59
+ ).toBe(true);
40
60
  });
41
61
 
42
62
  it("stays hidden on a stale run", () => {
@@ -63,8 +83,101 @@ describe("canStartNewInvestigation", () => {
63
83
  expect(canStartNewInvestigation("investigation", run("error"), false)).toBe(
64
84
  true,
65
85
  );
66
- expect(canStartNewInvestigation("investigation", run("stopped"), false)).toBe(
86
+ expect(
87
+ canStartNewInvestigation("investigation", run("stopped"), false),
88
+ ).toBe(true);
89
+ });
90
+ });
91
+
92
+ describe("canStopInvestigation", () => {
93
+ it("lets the terminal transcript outrank a lagging running summary", () => {
94
+ expect(canStopInvestigation(run("running"), false, false, "done")).toBe(
95
+ false,
96
+ );
97
+ expect(canStopInvestigation(run("running"), false, false, "error")).toBe(
98
+ false,
99
+ );
100
+ });
101
+
102
+ it("keeps Stop available for an active human turn", () => {
103
+ expect(canStopInvestigation(run("running"), true, false, "running")).toBe(
67
104
  true,
68
105
  );
69
106
  });
107
+
108
+ it("does not offer another Stop while the server drains the turn", () => {
109
+ expect(canStopInvestigation(run("stopping"), true, false, "running")).toBe(
110
+ false,
111
+ );
112
+ });
113
+
114
+ it("never lets a missing or automatic run expose Stop", () => {
115
+ expect(canStopInvestigation(run("running"), true, true, "running")).toBe(
116
+ false,
117
+ );
118
+ expect(
119
+ canStopInvestigation(
120
+ { ...run("running"), trigger: "background" },
121
+ true,
122
+ false,
123
+ "running",
124
+ ),
125
+ ).toBe(false);
126
+ });
127
+ });
128
+
129
+ describe("canContinueInvestigation", () => {
130
+ it("lets a terminal transcript outrank only a lagging running human summary", () => {
131
+ expect(
132
+ canContinueInvestigation(
133
+ { ...run("running"), canContinue: false },
134
+ "done",
135
+ ),
136
+ ).toBe(true);
137
+ });
138
+
139
+ it("keeps genuinely read-only and sessionless investigations read-only", () => {
140
+ expect(
141
+ canContinueInvestigation(
142
+ {
143
+ ...run("running"),
144
+ trigger: "background",
145
+ canContinue: false,
146
+ },
147
+ "done",
148
+ ),
149
+ ).toBe(false);
150
+ expect(
151
+ canContinueInvestigation({ ...run("done"), canContinue: false }, "done"),
152
+ ).toBe(false);
153
+ });
154
+
155
+ it("does not offer a follow-up after the retained run disappears", () => {
156
+ expect(
157
+ canContinueInvestigation(
158
+ { ...run("running"), canContinue: false },
159
+ "error",
160
+ true,
161
+ ),
162
+ ).toBe(false);
163
+ });
164
+ });
165
+
166
+ describe("canCopyRunLink", () => {
167
+ it("does not expose collaboration UI for an OSS run", () => {
168
+ expect(canCopyRunLink(run("done"))).toBe(false);
169
+ });
170
+
171
+ it("exposes the copy action only for a canonical hosted URL", () => {
172
+ expect(
173
+ canCopyRunLink({
174
+ ...run("done"),
175
+ radarUrl: "/c/cluster-1?org=org-1&ai-run=r1",
176
+ }),
177
+ ).toBe(true);
178
+ });
179
+
180
+ it("treats an empty hosted URL as unavailable", () => {
181
+ expect(canCopyRunLink({ ...run("done"), radarUrl: "" })).toBe(false);
182
+ });
70
183
  });
@@ -16,8 +16,13 @@ import {
16
16
  Copy,
17
17
  Check,
18
18
  Plus,
19
+ Link,
20
+ Lock,
21
+ Users,
19
22
  } from "lucide-react";
20
23
  import { Tooltip } from "../ui/Tooltip";
24
+ import { ConfirmDialog } from "../ui/ConfirmDialog";
25
+ import { Badge } from "@skyhook-io/k8s-ui/components/ui/Badge";
21
26
  import {
22
27
  useDiagnose,
23
28
  useDiagnoseLayout,
@@ -31,7 +36,11 @@ import { RecentList } from "./Home";
31
36
  import { AgentSetupNotice } from "./AgentSetupNotice";
32
37
  import { ConsentCard } from "./parts";
33
38
  import { buildLaunchCommand, launchAgentLabel, openInTerminal } from "./launch";
34
- import { type RunSummary, type ExecutionProfile } from "../../api/diagnose";
39
+ import {
40
+ updateRunVisibility,
41
+ type RunSummary,
42
+ type ExecutionProfile,
43
+ } from "../../api/diagnose";
35
44
  import { routePath } from "../../api/config";
36
45
  import { useCapabilitiesContext } from "../../contexts/CapabilitiesContext";
37
46
 
@@ -71,7 +80,10 @@ function InvestigationMenu({ run }: { run: RunSummary }) {
71
80
  const [copied, setCopied] = useState(false);
72
81
  const { localTerminal } = useCapabilitiesContext();
73
82
  const label = launchAgentLabel(run);
74
- const command = buildLaunchCommand(run, `${window.location.origin}${routePath('/mcp')}`);
83
+ const command = buildLaunchCommand(
84
+ run,
85
+ `${window.location.origin}${routePath("/mcp")}`,
86
+ );
75
87
  // No resumable session yet (or stale run) → nothing to hand off.
76
88
  if (!command) return null;
77
89
 
@@ -143,6 +155,135 @@ function InvestigationMenu({ run }: { run: RunSummary }) {
143
155
  );
144
156
  }
145
157
 
158
+ export function canCopyRunLink(
159
+ run: RunSummary | null | undefined,
160
+ ): run is RunSummary & { radarUrl: string } {
161
+ return typeof run?.radarUrl === "string" && run.radarUrl.length > 0;
162
+ }
163
+
164
+ function CopyRunLink({ radarUrl, visibility }: { radarUrl: string; visibility: RunSummary["visibility"] }) {
165
+ const label = visibility === "private"
166
+ ? "Copy private link (only you can open it)"
167
+ : "Copy investigation link";
168
+ const [copyState, setCopyState] = useState<"idle" | "copied" | "error">(
169
+ "idle",
170
+ );
171
+ const copy = async () => {
172
+ try {
173
+ if (!navigator.clipboard) throw new Error("clipboard unavailable");
174
+ await navigator.clipboard.writeText(
175
+ new URL(radarUrl, window.location.origin).href,
176
+ );
177
+ setCopyState("copied");
178
+ } catch {
179
+ setCopyState("error");
180
+ }
181
+ setTimeout(() => setCopyState("idle"), 1500);
182
+ };
183
+ return (
184
+ <Tooltip
185
+ content={
186
+ copyState === "copied"
187
+ ? "Link copied"
188
+ : copyState === "error"
189
+ ? "Couldn’t copy link"
190
+ : label
191
+ }
192
+ position="bottom"
193
+ >
194
+ <button
195
+ onClick={copy}
196
+ className="rounded-md p-1 text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary"
197
+ aria-label={label}
198
+ >
199
+ {copyState === "copied" ? (
200
+ <Check className="h-4 w-4 text-emerald-500" />
201
+ ) : (
202
+ <Link className="h-4 w-4" />
203
+ )}
204
+ </button>
205
+ </Tooltip>
206
+ );
207
+ }
208
+
209
+ function VisibilityControl({
210
+ run,
211
+ onChanged,
212
+ }: {
213
+ run: RunSummary;
214
+ onChanged: (run: RunSummary) => void;
215
+ }) {
216
+ const [busy, setBusy] = useState(false);
217
+ const [error, setError] = useState(false);
218
+ const [confirmShare, setConfirmShare] = useState(false);
219
+ if (!run.canManageVisibility) {
220
+ return run.visibility === "organization" ? (
221
+ <Tooltip content="Shared with your organization" position="bottom">
222
+ <Badge severity="neutral" size="sm" className="shrink-0">
223
+ <Users className="h-3 w-3" />
224
+ Organization
225
+ </Badge>
226
+ </Tooltip>
227
+ ) : null;
228
+ }
229
+ const shared = run.visibility === "organization";
230
+ const update = () => {
231
+ if (busy) return;
232
+ setBusy(true);
233
+ setError(false);
234
+ updateRunVisibility(run.id, shared ? "private" : "organization")
235
+ .then((updated) => {
236
+ onChanged(updated);
237
+ setConfirmShare(false);
238
+ })
239
+ .catch(() => setError(true))
240
+ .finally(() => setBusy(false));
241
+ };
242
+ const label = shared ? "Organization" : "Private";
243
+ return (
244
+ <>
245
+ <Tooltip
246
+ content={
247
+ error
248
+ ? "Couldn't change sharing"
249
+ : shared
250
+ ? "Shared with your organization — make private"
251
+ : "Private — click to let organization members access this investigation"
252
+ }
253
+ position="bottom"
254
+ >
255
+ <button
256
+ onClick={() => (shared ? update() : setConfirmShare(true))}
257
+ disabled={busy}
258
+ className="flex items-center gap-1 rounded-md border border-theme-border/70 px-1.5 py-1 text-[11px] font-medium text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary disabled:opacity-50"
259
+ aria-label={
260
+ shared
261
+ ? "Shared with your organization — make private"
262
+ : "Private — click to let organization members access this investigation"
263
+ }
264
+ >
265
+ {shared ? (
266
+ <Users className="h-3.5 w-3.5" />
267
+ ) : (
268
+ <Lock className="h-3.5 w-3.5" />
269
+ )}
270
+ {label}
271
+ </button>
272
+ </Tooltip>
273
+ <ConfirmDialog
274
+ open={confirmShare}
275
+ onClose={() => !busy && setConfirmShare(false)}
276
+ onConfirm={update}
277
+ title="Share this investigation?"
278
+ message="Everyone in your organization can read this entire investigation—including your questions and the logs and manifests Radar read—and can continue or stop it."
279
+ confirmLabel="Share with organization"
280
+ variant="warning"
281
+ isLoading={busy}
282
+ />
283
+ </>
284
+ );
285
+ }
286
+
146
287
  // The panel is an ABSOLUTE slot inside the app's body frame (the column under the
147
288
  // header, right of the nav rail) — App renders it there and passes topInset (the
148
289
  // header height; 0 in chromeless embeds). It shares that frame with the resource/
@@ -154,8 +295,9 @@ function InvestigationMenu({ run }: { run: RunSummary }) {
154
295
  // the last focused run. Without this the button dispatches an
155
296
  // agent — real tokens — from a screen showing an unrelated list.
156
297
  // run nothing to take a resource from.
157
- // running a start is handed back the live run, so the click does nothing
158
- // and the button reads as broken.
298
+ // running/stopping a human start is handed back the live run, so the click does
299
+ // nothing. An automatic run is a different, immutable session,
300
+ // so it deliberately keeps the fresh human escape hatch.
159
301
  // stale the body already offers "Re-run on current cluster" WITH the
160
302
  // warning that the context changed; a bare + carries none of it,
161
303
  // and the resource may not exist in the context it'd run against.
@@ -168,7 +310,8 @@ export function canStartNewInvestigation(
168
310
  return (
169
311
  view === "investigation" &&
170
312
  !!run &&
171
- run.status !== "running" &&
313
+ ((run.status !== "running" && run.status !== "stopping") ||
314
+ run.trigger === "background") &&
172
315
  run.status !== "stale" &&
173
316
  !needsConsent
174
317
  );
@@ -220,23 +363,28 @@ export function DiagnoseSurface({ topInset = 0 }: { topInset?: number }) {
220
363
  d.setupState === "needs-install" || d.setupState === "needs-restart";
221
364
 
222
365
  const activeRun = d.runs.find((r) => r.id === d.activeRunId) ?? null;
366
+ // Docked Home is a list, not the previously focused run. Scope every header
367
+ // label/action to what is visibly on screen so Copy/Share cannot act on a run
368
+ // the user has navigated away from. Expanded mode remains master-detail and
369
+ // therefore keeps the selected run active beside its history list.
370
+ const headerRun = !maximized && d.view === "home" ? null : activeRun;
223
371
  // A focused run shows the agent it actually ran with; Home reflects the current pick.
224
- const activeAgentLabel = activeRun?.agent
225
- ? agentLabelFor(activeRun.agent)
372
+ const activeAgentLabel = headerRun?.agent
373
+ ? agentLabelFor(headerRun.agent)
226
374
  : d.agentLabel;
227
375
  // Header subtitle: the config a focused run actually used (it records agent /
228
376
  // profile / model / effort), or the current defaults on Home. Codex shows mode
229
377
  // + reasoning effort; model is shown only when overridden. Clicking opens Settings.
230
378
  const configLine = buildConfigLine(
231
- activeRun ?? {
379
+ headerRun ?? {
232
380
  agent: d.selectedAgent,
233
381
  profile: d.hosted ? undefined : d.profile,
234
382
  model: d.model,
235
383
  effort: d.effort,
236
384
  },
237
385
  );
238
- const detailTitle = activeRun
239
- ? `${activeRun.kind} ${activeRun.namespace ? `${activeRun.namespace}/` : ""}${activeRun.name}`
386
+ const detailTitle = headerRun
387
+ ? `${headerRun.kind} ${headerRun.namespace ? `${headerRun.namespace}/` : ""}${headerRun.name}`
240
388
  : "AI investigations";
241
389
 
242
390
  // Absolute within the body frame: maximized fills it; docked is a right slot.
@@ -280,12 +428,13 @@ export function DiagnoseSurface({ topInset = 0 }: { topInset?: number }) {
280
428
  </div>
281
429
  ) : d.activeRunId && d.runsLoaded ? (
282
430
  // A focused id that isn't in the loaded list — a deep link (?ai-run=…) to a
283
- // cleared/evicted/unknown run. Say so; the generic "select an
431
+ // private/revoked/cleared/unknown run. The generic "select an
284
432
  // investigation" placeholder would read as a broken link.
285
433
  <div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
286
434
  <p className="text-sm text-theme-text-secondary">
287
- This investigation is no longer available history keeps the most
288
- recent investigations, and this one has been cleared.
435
+ This investigation is unavailable. It may be private, your access may
436
+ have changed, or its history may have been cleared. Check your account
437
+ and organization, or ask the creator for access.
289
438
  </p>
290
439
  <button
291
440
  onClick={d.goHome}
@@ -375,27 +524,36 @@ export function DiagnoseSurface({ topInset = 0 }: { topInset?: number }) {
375
524
  </div>
376
525
  </div>
377
526
  <div className="flex shrink-0 items-center gap-0.5">
378
- {activeRun &&
379
- canStartNewInvestigation(d.view, activeRun, d.needsConsent) && (
380
- <Tooltip content="New investigation on this resource" position="bottom">
381
- <button
382
- onClick={() =>
383
- d.openInvestigation({
384
- kind: activeRun.kind,
385
- namespace: activeRun.namespace,
386
- name: activeRun.name,
387
- issueId: activeRun.issueId,
388
- fresh: true,
389
- })
390
- }
391
- className="rounded-md p-1 text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary"
392
- aria-label="New investigation on this resource"
527
+ {headerRun &&
528
+ canStartNewInvestigation(d.view, headerRun, d.needsConsent) && (
529
+ <Tooltip
530
+ content="New investigation on this resource"
531
+ position="bottom"
393
532
  >
394
- <Plus className="h-4 w-4" />
395
- </button>
396
- </Tooltip>
533
+ <button
534
+ onClick={() =>
535
+ d.openInvestigation({
536
+ kind: headerRun.kind,
537
+ namespace: headerRun.namespace,
538
+ name: headerRun.name,
539
+ issueId: headerRun.issueId,
540
+ fresh: true,
541
+ })
542
+ }
543
+ className="rounded-md p-1 text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary"
544
+ aria-label="New investigation on this resource"
545
+ >
546
+ <Plus className="h-4 w-4" />
547
+ </button>
548
+ </Tooltip>
549
+ )}
550
+ {headerRun && (
551
+ <VisibilityControl run={headerRun} onChanged={d.updateRunSummary} />
552
+ )}
553
+ {canCopyRunLink(headerRun) && (
554
+ <CopyRunLink radarUrl={headerRun.radarUrl} visibility={headerRun.visibility} />
397
555
  )}
398
- {activeRun && <InvestigationMenu run={activeRun} />}
556
+ {headerRun && <InvestigationMenu run={headerRun} />}
399
557
  <Tooltip content={maximized ? "Restore" : "Expand"} position="bottom">
400
558
  <button
401
559
  onClick={() => setMaximized((v) => !v)}
@@ -3,6 +3,7 @@
3
3
  // docked Home view and the master pane of the maximized workspace.
4
4
  import { Loader2, Sparkles } from "lucide-react";
5
5
  import { StatusDot, type StatusTone } from "@skyhook-io/k8s-ui";
6
+ import { Badge } from "@skyhook-io/k8s-ui/components/ui/Badge";
6
7
  import { type RunSummary } from "../../api/diagnose";
7
8
 
8
9
  // Compact "3m ago" / "2h ago" / date label.
@@ -31,7 +32,7 @@ function runTone(status: RunSummary["status"]): StatusTone {
31
32
  }
32
33
 
33
34
  function statusDot(status: RunSummary["status"]) {
34
- if (status === "running")
35
+ if (status === "running" || status === "stopping")
35
36
  return <Loader2 className="h-3 w-3 shrink-0 animate-spin text-accent" />;
36
37
  return <StatusDot tone={runTone(status)} className="shrink-0" />;
37
38
  }
@@ -47,6 +48,8 @@ function statusWord(
47
48
  return { text: "Failed", cls: "text-red-400" };
48
49
  case "stopped":
49
50
  return { text: "Stopped", cls: "text-theme-text-tertiary" };
51
+ case "stopping":
52
+ return { text: "Stopping", cls: "text-theme-text-tertiary" };
50
53
  case "stale":
51
54
  // Plain words, not the internal status name: "stale" means the run was
52
55
  // about a cluster that's no longer connected.
@@ -83,80 +86,106 @@ export function RecentList({
83
86
  if (runs.length === 0) {
84
87
  return (
85
88
  <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
89
+ {degradedNote}
90
+ <div className="flex flex-col items-center px-4 py-12 text-center">
91
+ <Sparkles className="mb-3 h-7 w-7 text-accent" />
92
+ <div className="text-sm font-medium text-theme-text-primary">
93
+ No investigations yet
94
+ </div>
95
+ <p className="mt-1 max-w-xs text-sm text-theme-text-tertiary">
96
+ Open a resource and use its{" "}
97
+ <Sparkles className="inline h-3.5 w-3.5 align-text-bottom text-accent" />{" "}
98
+ action to investigate it with {agentLabel} —{" "}
99
+ <span className="font-medium text-theme-text-secondary">
100
+ Diagnose
101
+ </span>{" "}
102
+ a problem, or just ask about it. Investigations run in the
103
+ background and are kept in your history here.
104
+ </p>
91
105
  </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
106
  </div>
102
107
  );
103
108
  }
104
109
 
110
+ const organizationRuns = runs.filter(
111
+ (r) => r.trigger === "background" || r.ownedByMe === false,
112
+ );
113
+ const yourRuns = runs.filter((r) => !organizationRuns.includes(r));
114
+ const groups = organizationRuns.length
115
+ ? [
116
+ { label: "Your investigations", runs: yourRuns },
117
+ { label: "Organization", runs: organizationRuns },
118
+ ].filter((group) => group.runs.length > 0)
119
+ : [{ label: "Investigations", runs }];
120
+
105
121
  return (
106
122
  <div className="space-y-2">
107
123
  {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>
124
+ {groups.map((group) => (
125
+ <div key={group.label} className="space-y-2">
126
+ <div className="pt-1 text-[11px] font-medium uppercase tracking-wide text-theme-text-tertiary">
127
+ {group.label}
144
128
  </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}
129
+ {group.runs.map((r) => (
130
+ <button
131
+ key={r.id}
132
+ onClick={() => onSelect(r.id)}
133
+ className={`flex w-full flex-col gap-0.5 rounded-md border px-2.5 py-2 text-left ${
134
+ r.id === selectedId
135
+ ? "border-accent/50 bg-accent/10"
136
+ : "border-theme-border/60 bg-theme-base/40 hover:bg-theme-hover"
137
+ }`}
138
+ >
139
+ <div className="flex items-center gap-2">
140
+ {statusDot(r.status)}
141
+ <span className="min-w-0 flex-1 truncate text-sm text-theme-text-primary">
142
+ {r.kind} {r.namespace ? `${r.namespace}/` : ""}
143
+ {r.name}
153
144
  </span>
145
+ {(r.trigger || r.visibility) && (
146
+ <Badge severity="neutral" size="sm" className="shrink-0">
147
+ {r.trigger === "background"
148
+ ? "Automatic"
149
+ : r.visibility === "organization"
150
+ ? "Shared"
151
+ : "Private"}
152
+ </Badge>
153
+ )}
154
+ <span className="shrink-0 text-[11px] text-theme-text-tertiary">
155
+ {r.status === "running" || r.status === "stopping" ? (
156
+ r.status === "stopping" ? "stopping…" : "running…"
157
+ ) : (
158
+ <>
159
+ {(() => {
160
+ const w = statusWord(r.status);
161
+ return w ? (
162
+ <span className={`font-medium ${w.cls}`}>
163
+ {w.text} ·{" "}
164
+ </span>
165
+ ) : null;
166
+ })()}
167
+ {relativeTime(new Date(r.updatedAt).getTime(), now)}
168
+ </>
169
+ )}
170
+ </span>
171
+ </div>
172
+ {(r.status === "stale" && r.context) || r.preview ? (
173
+ <div className="truncate pl-3.5 text-xs text-theme-text-tertiary">
174
+ {/* A foreign-cluster run names its cluster — in mixed multi-
175
+ context history, identical-looking rows otherwise give no way
176
+ to tell WHICH cluster an investigation was about. */}
177
+ {r.status === "stale" && r.context ? (
178
+ <span className="text-amber-600/80 dark:text-amber-500/80">
179
+ {r.context}
180
+ </span>
181
+ ) : null}
182
+ {r.status === "stale" && r.context && r.preview ? " · " : ""}
183
+ {r.preview}
184
+ </div>
154
185
  ) : null}
155
- {r.status === "stale" && r.context && r.preview ? " · " : ""}
156
- {r.preview}
157
- </div>
158
- ) : null}
159
- </button>
186
+ </button>
187
+ ))}
188
+ </div>
160
189
  ))}
161
190
  </div>
162
191
  );