@skyhook-io/radar-app 1.13.0 → 1.13.2

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 (54) hide show
  1. package/package.json +2 -2
  2. package/src/App.tsx +55 -9
  3. package/src/RadarApp.tsx +3 -3
  4. package/src/api/diagnose.test.ts +268 -0
  5. package/src/api/diagnose.ts +117 -14
  6. package/src/components/diagnose/AISettings.tsx +10 -5
  7. package/src/components/diagnose/AgentSetupNotice.tsx +5 -5
  8. package/src/components/diagnose/ApplyDialog.test.tsx +72 -0
  9. package/src/components/diagnose/DiagnoseContext.tsx +228 -65
  10. package/src/components/diagnose/DiagnoseSurface.test.tsx +317 -9
  11. package/src/components/diagnose/DiagnoseSurface.tsx +621 -132
  12. package/src/components/diagnose/Home.test.tsx +293 -0
  13. package/src/components/diagnose/Home.tsx +307 -108
  14. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +2170 -0
  15. package/src/components/diagnose/InvestigationEvidencePane.tsx +2253 -0
  16. package/src/components/diagnose/InvestigationResourceEvidence.test.tsx +257 -0
  17. package/src/components/diagnose/InvestigationResourceEvidence.tsx +214 -0
  18. package/src/components/diagnose/InvestigationView.test.ts +17 -0
  19. package/src/components/diagnose/InvestigationView.tsx +1911 -338
  20. package/src/components/diagnose/LocalDiagnoseAction.tsx +42 -25
  21. package/src/components/diagnose/agentCatalog.ts +1 -1
  22. package/src/components/diagnose/diagnoseEvidenceTypes.ts +151 -0
  23. package/src/components/diagnose/investigationEvidence.test.ts +3109 -0
  24. package/src/components/diagnose/investigationEvidence.ts +3492 -0
  25. package/src/components/diagnose/investigationEvidencePresentation.test.ts +447 -0
  26. package/src/components/diagnose/investigationEvidencePresentation.ts +167 -0
  27. package/src/components/diagnose/investigationExplanation.test.ts +63 -0
  28. package/src/components/diagnose/investigationExplanation.ts +22 -0
  29. package/src/components/diagnose/investigationResourceEvidenceModel.ts +322 -0
  30. package/src/components/diagnose/investigationSourceFocus.test.ts +143 -0
  31. package/src/components/diagnose/investigationSourceFocus.ts +98 -0
  32. package/src/components/diagnose/investigationState.test.ts +695 -0
  33. package/src/components/diagnose/investigationState.ts +451 -0
  34. package/src/components/diagnose/parts.test.tsx +864 -3
  35. package/src/components/diagnose/parts.tsx +1340 -536
  36. package/src/components/diagnose/target.test.ts +39 -0
  37. package/src/components/diagnose/target.ts +36 -0
  38. package/src/components/diagnose/useDisclosureReveal.ts +117 -0
  39. package/src/components/execution/BatchExecutionView.render.test.tsx +35 -0
  40. package/src/components/execution/BatchExecutionView.tsx +2 -3
  41. package/src/components/execution/execution-definition.test.ts +19 -0
  42. package/src/components/execution/execution-definition.ts +2 -0
  43. package/src/components/gitops/GitOpsView.tsx +25 -3
  44. package/src/components/home/MCPSetupDialog.tsx +2 -2
  45. package/src/components/home/mcpToolCatalog.test.ts +22 -0
  46. package/src/components/home/mcpToolCatalog.ts +3 -2
  47. package/src/components/issues/IssuesPane.tsx +5 -1
  48. package/src/components/nav/navigation.test.ts +26 -0
  49. package/src/components/nav/navigation.ts +10 -0
  50. package/src/components/settings/SettingsDialog.tsx +11 -13
  51. package/src/components/workload/WorkloadView.tsx +1 -1
  52. package/src/context/DiagnoseCustomization.tsx +11 -8
  53. package/src/index.css +63 -79
  54. package/src/index.ts +1 -1
@@ -3,21 +3,34 @@
3
3
  // - expanded: a master-detail workspace that fills ONLY the content area (does
4
4
  // not cover the left nav rail or top bar) — recent list on the left, the
5
5
  // selected investigation/report on the right.
6
- import { useState } from "react";
6
+ import {
7
+ useCallback,
8
+ useEffect,
9
+ useLayoutEffect,
10
+ useRef,
11
+ useState,
12
+ } from "react";
7
13
  import {
8
14
  Sparkles,
9
15
  X,
10
16
  Maximize2,
11
17
  Minimize2,
12
- ChevronLeft,
13
18
  Settings2,
14
19
  MoreVertical,
15
20
  TerminalSquare,
16
21
  Copy,
17
22
  Check,
18
23
  Plus,
24
+ PanelLeftOpen,
25
+ Link,
26
+ Lock,
27
+ Users,
19
28
  } from "lucide-react";
20
29
  import { Tooltip } from "../ui/Tooltip";
30
+ import { ConfirmDialog } from "../ui/ConfirmDialog";
31
+ import { Badge } from "@skyhook-io/k8s-ui/components/ui/Badge";
32
+ import { useAnimatedUnmount } from "../../hooks/useAnimatedUnmount";
33
+ import { TRANSITION_BACKDROP, TRANSITION_DRAWER } from "../../utils/animation";
21
34
  import {
22
35
  useDiagnose,
23
36
  useDiagnoseLayout,
@@ -27,13 +40,20 @@ import {
27
40
  } from "./DiagnoseContext";
28
41
  import { useDiagnoseCustomization } from "../../context/DiagnoseCustomization";
29
42
  import { InvestigationView } from "./InvestigationView";
30
- import { RecentList } from "./Home";
43
+ import { RecentList, absoluteTime, statusWord } from "./Home";
31
44
  import { AgentSetupNotice } from "./AgentSetupNotice";
32
45
  import { ConsentCard } from "./parts";
33
46
  import { buildLaunchCommand, launchAgentLabel, openInTerminal } from "./launch";
34
- import { type RunSummary, type ExecutionProfile } from "../../api/diagnose";
47
+ import {
48
+ updateRunVisibility,
49
+ type RunSummary,
50
+ type ExecutionProfile,
51
+ } from "../../api/diagnose";
35
52
  import { routePath } from "../../api/config";
36
53
  import { useCapabilitiesContext } from "../../contexts/CapabilitiesContext";
54
+ import { useContexts } from "../../api/client";
55
+ import { formatInvestigationTarget } from "./target";
56
+ import type { DiagnosisResourceRef } from "./diagnoseEvidenceTypes";
37
57
 
38
58
  function capWord(s: string): string {
39
59
  return s ? s[0].toUpperCase() + s.slice(1) : s;
@@ -69,12 +89,35 @@ function buildConfigLine(cfg: {
69
89
  function InvestigationMenu({ run }: { run: RunSummary }) {
70
90
  const [open, setOpen] = useState(false);
71
91
  const [copied, setCopied] = useState(false);
92
+ const menuRef = useRef<HTMLDivElement>(null);
72
93
  const { localTerminal } = useCapabilitiesContext();
73
94
  const label = launchAgentLabel(run);
74
- const command = buildLaunchCommand(run, `${window.location.origin}${routePath('/mcp')}`);
95
+ const command = buildLaunchCommand(
96
+ run,
97
+ `${window.location.origin}${routePath("/mcp")}`,
98
+ );
99
+
100
+ // The diagnose root is a CSS container, so a position:fixed click-away layer
101
+ // inside it is panel-bound rather than viewport-bound. Dismiss from the
102
+ // document instead; the anchored menu itself can stay next to its trigger.
103
+ useEffect(() => {
104
+ if (!open) return;
105
+ const onPointerDown = (event: PointerEvent) => {
106
+ if (!menuRef.current?.contains(event.target as Node)) setOpen(false);
107
+ };
108
+ const onKeyDown = (event: KeyboardEvent) => {
109
+ if (event.key === "Escape") setOpen(false);
110
+ };
111
+ document.addEventListener("pointerdown", onPointerDown);
112
+ document.addEventListener("keydown", onKeyDown);
113
+ return () => {
114
+ document.removeEventListener("pointerdown", onPointerDown);
115
+ document.removeEventListener("keydown", onKeyDown);
116
+ };
117
+ }, [open]);
118
+
75
119
  // No resumable session yet (or stale run) → nothing to hand off.
76
120
  if (!command) return null;
77
-
78
121
  const toggle = () => {
79
122
  setCopied(false);
80
123
  setOpen((v) => !v);
@@ -89,7 +132,7 @@ function InvestigationMenu({ run }: { run: RunSummary }) {
89
132
  };
90
133
 
91
134
  return (
92
- <div className="relative flex items-center">
135
+ <div ref={menuRef} className="relative flex items-center">
93
136
  <Tooltip content="More" position="bottom">
94
137
  <button
95
138
  onClick={toggle}
@@ -102,47 +145,191 @@ function InvestigationMenu({ run }: { run: RunSummary }) {
102
145
  </button>
103
146
  </Tooltip>
104
147
  {open && (
105
- <>
106
- <div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
107
- <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">
148
+ <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">
149
+ <button
150
+ onClick={copy}
151
+ 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"
152
+ >
153
+ {copied ? (
154
+ <Check className="mt-0.5 h-4 w-4 shrink-0 text-emerald-500" />
155
+ ) : (
156
+ <Copy className="mt-0.5 h-4 w-4 shrink-0 text-theme-text-tertiary" />
157
+ )}
158
+ <span>
159
+ {copied ? "Copied ✓" : `Copy command to continue in ${label}`}
160
+ {!copied && (
161
+ <span className="block text-[11px] text-theme-text-tertiary">
162
+ Paste it wherever you run {label} — resumes this exact
163
+ session.
164
+ </span>
165
+ )}
166
+ </span>
167
+ </button>
168
+ {localTerminal && (
108
169
  <button
109
- onClick={copy}
110
- 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"
170
+ onClick={() => {
171
+ openInTerminal(command, "Radar Investigation");
172
+ setOpen(false);
173
+ }}
174
+ 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"
111
175
  >
112
- {copied ? (
113
- <Check className="mt-0.5 h-4 w-4 shrink-0 text-emerald-500" />
114
- ) : (
115
- <Copy className="mt-0.5 h-4 w-4 shrink-0 text-theme-text-tertiary" />
116
- )}
117
- <span>
118
- {copied ? "Copied ✓" : `Copy command to continue in ${label}`}
119
- {!copied && (
120
- <span className="block text-[11px] text-theme-text-tertiary">
121
- Paste it wherever you run {label} — resumes this exact
122
- session.
123
- </span>
124
- )}
125
- </span>
176
+ <TerminalSquare className="h-4 w-4 shrink-0 text-theme-text-tertiary" />
177
+ Run in a Radar terminal
126
178
  </button>
127
- {localTerminal && (
128
- <button
129
- onClick={() => {
130
- openInTerminal(command, "Diagnose");
131
- setOpen(false);
132
- }}
133
- 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"
134
- >
135
- <TerminalSquare className="h-4 w-4 shrink-0 text-theme-text-tertiary" />
136
- Run in a Radar terminal
137
- </button>
138
- )}
139
- </div>
140
- </>
179
+ )}
180
+ </div>
141
181
  )}
142
182
  </div>
143
183
  );
144
184
  }
145
185
 
186
+ export function canCopyRunLink(
187
+ run: RunSummary | null | undefined,
188
+ ): run is RunSummary & { radarUrl: string } {
189
+ return typeof run?.radarUrl === "string" && run.radarUrl.length > 0;
190
+ }
191
+
192
+ function CopyRunLink({
193
+ radarUrl,
194
+ visibility,
195
+ }: {
196
+ radarUrl: string;
197
+ visibility: RunSummary["visibility"];
198
+ }) {
199
+ const label =
200
+ visibility === "private"
201
+ ? "Copy private link (only you can open it)"
202
+ : "Copy investigation link";
203
+ const [copyState, setCopyState] = useState<"idle" | "copied" | "error">(
204
+ "idle",
205
+ );
206
+ const copy = async () => {
207
+ try {
208
+ if (!navigator.clipboard) throw new Error("clipboard unavailable");
209
+ await navigator.clipboard.writeText(
210
+ new URL(radarUrl, window.location.origin).href,
211
+ );
212
+ setCopyState("copied");
213
+ } catch {
214
+ setCopyState("error");
215
+ }
216
+ setTimeout(() => setCopyState("idle"), 1500);
217
+ };
218
+ return (
219
+ <Tooltip
220
+ content={
221
+ copyState === "copied"
222
+ ? "Link copied"
223
+ : copyState === "error"
224
+ ? "Couldn’t copy link"
225
+ : label
226
+ }
227
+ position="bottom"
228
+ >
229
+ <button
230
+ onClick={copy}
231
+ className="rounded-md p-1 text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary"
232
+ aria-label={label}
233
+ >
234
+ {copyState === "copied" ? (
235
+ <Check className="h-4 w-4 text-emerald-500" />
236
+ ) : (
237
+ <Link className="h-4 w-4" />
238
+ )}
239
+ </button>
240
+ </Tooltip>
241
+ );
242
+ }
243
+
244
+ function VisibilityControl({
245
+ run,
246
+ onChanged,
247
+ }: {
248
+ run: RunSummary;
249
+ onChanged: (run: RunSummary) => void;
250
+ }) {
251
+ const [busy, setBusy] = useState(false);
252
+ const [error, setError] = useState(false);
253
+ const [confirmShare, setConfirmShare] = useState(false);
254
+ if (!run.canManageVisibility) {
255
+ return run.visibility === "organization" ? (
256
+ <Tooltip content="Shared with your organization" position="bottom">
257
+ <Badge severity="neutral" size="sm" className="shrink-0">
258
+ <Users className="h-3 w-3" />
259
+ Organization
260
+ </Badge>
261
+ </Tooltip>
262
+ ) : run.visibility === "private" ? (
263
+ <Tooltip
264
+ content="Only you can view this investigation. Other organization members don’t have access."
265
+ position="bottom"
266
+ >
267
+ <Badge severity="neutral" size="sm" className="shrink-0">
268
+ <Lock className="h-3 w-3" />
269
+ Private
270
+ </Badge>
271
+ </Tooltip>
272
+ ) : null;
273
+ }
274
+ const shared = run.visibility === "organization";
275
+ const update = () => {
276
+ if (busy) return;
277
+ setBusy(true);
278
+ setError(false);
279
+ updateRunVisibility(run.id, shared ? "private" : "organization")
280
+ .then((updated) => {
281
+ onChanged(updated);
282
+ setConfirmShare(false);
283
+ })
284
+ .catch(() => setError(true))
285
+ .finally(() => setBusy(false));
286
+ };
287
+ const label = shared ? "Organization" : "Private";
288
+ return (
289
+ <>
290
+ <Tooltip
291
+ content={
292
+ error
293
+ ? "Couldn't change sharing"
294
+ : shared
295
+ ? "Shared with your organization — make private"
296
+ : "Private — click to let organization members access this investigation"
297
+ }
298
+ position="bottom"
299
+ >
300
+ <button
301
+ onClick={() => (shared ? update() : setConfirmShare(true))}
302
+ disabled={busy}
303
+ 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"
304
+ aria-label={
305
+ shared
306
+ ? "Shared with your organization — make private"
307
+ : "Private — click to let organization members access this investigation"
308
+ }
309
+ >
310
+ {shared ? (
311
+ <Users className="h-3.5 w-3.5" />
312
+ ) : (
313
+ <Lock className="h-3.5 w-3.5" />
314
+ )}
315
+ {label}
316
+ </button>
317
+ </Tooltip>
318
+ <ConfirmDialog
319
+ open={confirmShare}
320
+ onClose={() => !busy && setConfirmShare(false)}
321
+ onConfirm={update}
322
+ title="Share this investigation?"
323
+ 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."
324
+ confirmLabel="Share with organization"
325
+ showWarning={false}
326
+ variant="warning"
327
+ isLoading={busy}
328
+ />
329
+ </>
330
+ );
331
+ }
332
+
146
333
  // The panel is an ABSOLUTE slot inside the app's body frame (the column under the
147
334
  // header, right of the nav rail) — App renders it there and passes topInset (the
148
335
  // header height; 0 in chromeless embeds). It shares that frame with the resource/
@@ -154,11 +341,11 @@ function InvestigationMenu({ run }: { run: RunSummary }) {
154
341
  // the last focused run. Without this the button dispatches an
155
342
  // agent — real tokens — from a screen showing an unrelated list.
156
343
  // 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.
159
- // stale the body already offers "Re-run on current cluster" WITH the
160
- // warning that the context changed; a bare + carries none of it,
161
- // and the resource may not exist in the context it'd run against.
344
+ // running/stopping human starts reuse the live run. Automatic investigations
345
+ // are immutable, so they retain a separate fresh human start.
346
+ // stale the original session is closed after a cluster switch;
347
+ // the resource may not exist in the active context. Do not
348
+ // silently start a different-cluster investigation from here.
162
349
  // needsConsent the consent card owns the surface until it's answered.
163
350
  export function canStartNewInvestigation(
164
351
  view: DiagnoseView,
@@ -168,14 +355,165 @@ export function canStartNewInvestigation(
168
355
  return (
169
356
  view === "investigation" &&
170
357
  !!run &&
171
- run.status !== "running" &&
358
+ ((run.status !== "running" && run.status !== "stopping") ||
359
+ run.trigger === "background") &&
172
360
  run.status !== "stale" &&
173
361
  !needsConsent
174
362
  );
175
363
  }
176
364
 
177
- export function DiagnoseSurface({ topInset = 0 }: { topInset?: number }) {
365
+ // Home shows a list plus retained detail only when the surface has room.
366
+ // Keep these Tailwind literals aligned with the measured rail threshold.
367
+ // Detail uses one stable history toggle; below this width it opens an overlay.
368
+ export const INVESTIGATION_HISTORY_MIN_WIDTH = 1750;
369
+ export const MAXIMIZED_COMPACT_HISTORY_VISIBILITY_CLASS =
370
+ "@min-[1750px]/diagnose-surface:hidden";
371
+ export const MAXIMIZED_HOME_DETAIL_VISIBILITY_CLASS =
372
+ "hidden @min-[1750px]/diagnose-surface:flex";
373
+ export const MAXIMIZED_HOME_RUN_HEADER_VISIBILITY_CLASS =
374
+ "hidden @min-[1750px]/diagnose-surface:block";
375
+ export const MAXIMIZED_RUN_META_VISIBILITY_CLASS =
376
+ "hidden @min-[1750px]/diagnose-surface:flex";
377
+ // The panel is a bounded absolute frame whose descendants own scrolling. If
378
+ // overflow remains visible here, a tall Activity/Findings tree contributes its
379
+ // full scroll height to the document even though the frame itself is viewport-
380
+ // sized, producing a large blank page tail below Radar. This applies equally to
381
+ // docked and maximized modes; their intended scroll roots are inside the frame.
382
+ export const DIAGNOSE_SURFACE_FRAME_CLASS =
383
+ "@container/diagnose-surface absolute z-40 flex min-h-0 flex-col overflow-hidden border-l border-theme-border bg-theme-surface shadow-drawer";
384
+
385
+ export function investigationHeaderPresentation(input: {
386
+ view: DiagnoseView;
387
+ maximized: boolean;
388
+ hasVisibleRunDetail: boolean;
389
+ }): {
390
+ genericIdentityClass: string | null;
391
+ detailIdentityClass: string | null;
392
+ runActionsClass: string | null;
393
+ } {
394
+ if (input.view !== "home") {
395
+ return {
396
+ genericIdentityClass: null,
397
+ detailIdentityClass: "",
398
+ runActionsClass: input.hasVisibleRunDetail ? "" : null,
399
+ };
400
+ }
401
+ const hasWideRetainedDetail = input.maximized && input.hasVisibleRunDetail;
402
+ return {
403
+ genericIdentityClass: hasWideRetainedDetail
404
+ ? MAXIMIZED_COMPACT_HISTORY_VISIBILITY_CLASS
405
+ : "",
406
+ detailIdentityClass: hasWideRetainedDetail
407
+ ? MAXIMIZED_HOME_RUN_HEADER_VISIBILITY_CLASS
408
+ : null,
409
+ runActionsClass: hasWideRetainedDetail
410
+ ? MAXIMIZED_HOME_DETAIL_VISIBILITY_CLASS
411
+ : null,
412
+ };
413
+ }
414
+
415
+ function DiagnoseHeaderIdentity({
416
+ className,
417
+ title,
418
+ configLine,
419
+ runMeta,
420
+ onOpenSettings,
421
+ }: {
422
+ className: string;
423
+ title: string;
424
+ configLine: string;
425
+ runMeta?: {
426
+ label: string;
427
+ labelClass: string;
428
+ dateTime: string;
429
+ time: string;
430
+ };
431
+ onOpenSettings: (() => void) | null;
432
+ }) {
433
+ return (
434
+ <div className={`min-w-0 ${className}`}>
435
+ <div className="flex min-w-0 items-center gap-2">
436
+ <div className="min-w-0 flex-1 truncate text-sm font-medium text-theme-text-primary">
437
+ {title}
438
+ </div>
439
+ {runMeta ? (
440
+ <div
441
+ className={`${MAXIMIZED_RUN_META_VISIBILITY_CLASS} shrink-0 items-center gap-1 text-[11px] tabular-nums text-theme-text-tertiary`}
442
+ >
443
+ <span className={`font-medium ${runMeta.labelClass}`}>
444
+ {runMeta.label}
445
+ </span>
446
+ <span aria-hidden>·</span>
447
+ <time dateTime={runMeta.dateTime}>{runMeta.time}</time>
448
+ </div>
449
+ ) : null}
450
+ </div>
451
+ <div className="flex items-center gap-1 text-xs text-theme-text-tertiary">
452
+ <span className="truncate">{configLine}</span>
453
+ {onOpenSettings && (
454
+ <Tooltip content="AI settings" position="bottom">
455
+ <button
456
+ onClick={onOpenSettings}
457
+ className="shrink-0 rounded p-0.5 text-theme-text-tertiary hover:text-theme-text-primary"
458
+ aria-label="AI settings"
459
+ >
460
+ <Settings2 className="h-3 w-3" />
461
+ </button>
462
+ </Tooltip>
463
+ )}
464
+ </div>
465
+ </div>
466
+ );
467
+ }
468
+
469
+ export function openInvestigationEvidenceResource(
470
+ ref: DiagnosisResourceRef,
471
+ onOpenResource: (ref: DiagnosisResourceRef) => void,
472
+ setMaximized: (maximized: boolean) => void,
473
+ closeDiagnose: () => void,
474
+ dockedPanelWouldOverlay: boolean,
475
+ ) {
476
+ // A resource destination must be visible after the handoff. On a wide canvas,
477
+ // restoring the docked panel leaves Radar and the investigation side by side.
478
+ // At tighter widths that same panel overlays the host content, so close it
479
+ // before navigating instead of making the click appear to do nothing.
480
+ if (dockedPanelWouldOverlay) {
481
+ // Closing already exposes the destination; retain the user's maximized
482
+ // preference for the next time they open investigations.
483
+ closeDiagnose();
484
+ } else {
485
+ setMaximized(false);
486
+ }
487
+ onOpenResource(ref);
488
+ }
489
+
490
+ export function DiagnoseSurface({
491
+ topInset = 0,
492
+ onOpenResource,
493
+ }: {
494
+ topInset?: number;
495
+ /** Resolves an evidence subject into the embedding Radar surface. */
496
+ onOpenResource?: (ref: DiagnosisResourceRef) => void;
497
+ }) {
178
498
  const d = useDiagnose();
499
+ const { data: contexts } = useContexts();
500
+ const currentContext = contexts?.find((context) => context.isCurrent)?.name;
501
+ const [historyCollapsed, setHistoryCollapsed] = useState(false);
502
+ const [historyOverlayOpen, setHistoryOverlayOpen] = useState(false);
503
+ const [surfaceWidth, setSurfaceWidth] = useState(0);
504
+ const surfaceRef = useRef<HTMLDivElement>(null);
505
+ const historyRef = useRef<HTMLElement>(null);
506
+ const historyButtonRef = useRef<HTMLButtonElement>(null);
507
+ useLayoutEffect(() => {
508
+ const element = surfaceRef.current;
509
+ if (!element) return;
510
+ const observer = new ResizeObserver(() =>
511
+ setSurfaceWidth(element.clientWidth),
512
+ );
513
+ setSurfaceWidth(element.clientWidth);
514
+ observer.observe(element);
515
+ return () => observer.disconnect();
516
+ }, []);
179
517
  // Injected settings action: undefined = Radar's own Settings dialog;
180
518
  // null = hide the gear + links.
181
519
  const { consentCopy, onOpenSettings: hostOpenSettings } =
@@ -191,6 +529,41 @@ export function DiagnoseSurface({ topInset = 0 }: { topInset?: number }) {
191
529
  panelBounds: { min: minW, max: maxW },
192
530
  panelWidthKey: widthKey,
193
531
  } = useDiagnoseLayout();
532
+ const wideHistory =
533
+ maximized && surfaceWidth >= INVESTIGATION_HISTORY_MIN_WIDTH;
534
+ const historyOverlay =
535
+ !wideHistory && historyOverlayOpen && d.view !== "home";
536
+ const { shouldRender: historyOverlayPresent, isOpen: historySlideOpen } =
537
+ useAnimatedUnmount(historyOverlay);
538
+ const dismissHistory = useCallback(() => {
539
+ setHistoryOverlayOpen(false);
540
+ historyButtonRef.current?.focus({ preventScroll: true });
541
+ }, []);
542
+ useEffect(() => {
543
+ setHistoryOverlayOpen(false);
544
+ }, [wideHistory, maximized, d.view]);
545
+ useEffect(() => {
546
+ if (historyOverlay) {
547
+ const target =
548
+ historyRef.current?.querySelector<HTMLElement>(
549
+ '[aria-current="true"]',
550
+ ) ?? historyRef.current?.querySelector<HTMLElement>("button");
551
+ (target ?? historyRef.current)?.focus({ preventScroll: true });
552
+ }
553
+ }, [historyOverlay]);
554
+ const openEvidenceResource = useCallback(
555
+ (ref: DiagnosisResourceRef) => {
556
+ if (!onOpenResource) return;
557
+ openInvestigationEvidenceResource(
558
+ ref,
559
+ onOpenResource,
560
+ setMaximized,
561
+ d.close,
562
+ narrow,
563
+ );
564
+ },
565
+ [d.close, narrow, onOpenResource, setMaximized],
566
+ );
194
567
 
195
568
  const startResize = (e: React.MouseEvent) => {
196
569
  e.preventDefault();
@@ -220,24 +593,42 @@ export function DiagnoseSurface({ topInset = 0 }: { topInset?: number }) {
220
593
  d.setupState === "needs-install" || d.setupState === "needs-restart";
221
594
 
222
595
  const activeRun = d.runs.find((r) => r.id === d.activeRunId) ?? null;
596
+ // Consent replaces the detail pane even if goHome retained an older run id.
597
+ // Header identity and actions must describe what is actually visible.
598
+ const visibleRunDetail = d.needsConsent ? null : activeRun;
223
599
  // A focused run shows the agent it actually ran with; Home reflects the current pick.
224
- const activeAgentLabel = activeRun?.agent
225
- ? agentLabelFor(activeRun.agent)
600
+ const activeAgentLabel = visibleRunDetail?.agent
601
+ ? agentLabelFor(visibleRunDetail.agent)
226
602
  : d.agentLabel;
227
- // Header subtitle: the config a focused run actually used (it records agent /
228
- // profile / model / effort), or the current defaults on Home. Codex shows mode
229
- // + reasoning effort; model is shown only when overridden. Clicking opens Settings.
230
- const configLine = buildConfigLine(
231
- activeRun ?? {
232
- agent: d.selectedAgent,
233
- profile: d.hosted ? undefined : d.profile,
234
- model: d.model,
235
- effort: d.effort,
236
- },
603
+ const defaultHeaderConfig = {
604
+ agent: d.selectedAgent,
605
+ profile: d.hosted ? undefined : d.profile,
606
+ model: d.model,
607
+ effort: d.effort,
608
+ };
609
+ const genericConfigLine = buildConfigLine(defaultHeaderConfig);
610
+ const focusedConfigLine = buildConfigLine(
611
+ visibleRunDetail ?? defaultHeaderConfig,
237
612
  );
238
- const detailTitle = activeRun
239
- ? `${activeRun.kind} ${activeRun.namespace ? `${activeRun.namespace}/` : ""}${activeRun.name}`
613
+ const focusedTitle = visibleRunDetail
614
+ ? formatInvestigationTarget(visibleRunDetail)
240
615
  : "AI investigations";
616
+ const headerPresentation = investigationHeaderPresentation({
617
+ view: d.view,
618
+ maximized,
619
+ hasVisibleRunDetail: !!visibleRunDetail,
620
+ });
621
+ const focusedRunMeta = visibleRunDetail
622
+ ? {
623
+ label: statusWord(visibleRunDetail.status).text,
624
+ labelClass: statusWord(visibleRunDetail.status).cls,
625
+ dateTime: visibleRunDetail.updatedAt,
626
+ time: absoluteTime(
627
+ new Date(visibleRunDetail.updatedAt).getTime(),
628
+ Date.now(),
629
+ ),
630
+ }
631
+ : undefined;
241
632
 
242
633
  // Absolute within the body frame: maximized fills it; docked is a right slot.
243
634
  // topInset clears the header (the frame spans the full column incl. the header).
@@ -268,6 +659,7 @@ export function DiagnoseSurface({ topInset = 0 }: { topInset?: number }) {
268
659
  run={activeRun}
269
660
  agentLabel={activeAgentLabel}
270
661
  maximized={maximized}
662
+ onOpenResource={onOpenResource ? openEvidenceResource : undefined}
271
663
  />
272
664
  ) : d.activeRunId && !d.runsLoaded ? (
273
665
  // Deep-linked to a run before the list has ever loaded: show the load
@@ -280,12 +672,13 @@ export function DiagnoseSurface({ topInset = 0 }: { topInset?: number }) {
280
672
  </div>
281
673
  ) : d.activeRunId && d.runsLoaded ? (
282
674
  // 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
675
+ // private/revoked/cleared/unknown run. The generic "select an
284
676
  // investigation" placeholder would read as a broken link.
285
677
  <div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
286
678
  <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.
679
+ This investigation is unavailable. It may be private, your access may
680
+ have changed, or its history may have been cleared. Check your account
681
+ and organization, or ask the creator for access.
289
682
  </p>
290
683
  <button
291
684
  onClick={d.goHome}
@@ -310,20 +703,51 @@ export function DiagnoseSurface({ topInset = 0 }: { topInset?: number }) {
310
703
  </div>
311
704
  ) : (
312
705
  <div className="flex flex-1 items-center justify-center px-6 text-center text-sm text-theme-text-tertiary">
313
- Select an investigation, or open a resource and click Diagnose.
706
+ Select an investigation, or open a resource and click Investigate.
314
707
  </div>
315
708
  );
709
+ const compactHistory = (
710
+ <>
711
+ {setupPending && <AgentSetupNotice setupState={d.setupState} />}
712
+ {(!setupPending || d.runs.length > 0) && (
713
+ <RecentList
714
+ currentContext={currentContext}
715
+ agentLabel={d.agentLabel}
716
+ runs={d.runs}
717
+ onSelect={d.openRun}
718
+ historyDegraded={d.historyDegraded}
719
+ />
720
+ )}
721
+ </>
722
+ );
316
723
 
317
- const showBreadcrumb = !maximized && d.view !== "home";
318
- // The maximized workspace always shows the recent-investigations list (there's
319
- // room for it in full-wide) — it's the master pane of the master-detail layout.
320
- const showHistory = maximized;
724
+ const showHistory = !setupPending || d.runs.length > 0;
725
+ const historyVisible =
726
+ showHistory &&
727
+ (wideHistory ? !historyCollapsed || d.view === "home" : historyOverlay);
728
+
729
+ useLayoutEffect(() => {
730
+ if (
731
+ !historyVisible &&
732
+ historyRef.current?.contains(document.activeElement)
733
+ ) {
734
+ historyButtonRef.current?.focus({ preventScroll: true });
735
+ }
736
+ }, [historyVisible]);
321
737
 
322
738
  return (
323
739
  <div
740
+ ref={surfaceRef}
324
741
  role="dialog"
325
742
  aria-label="AI investigations"
326
- className="absolute z-40 flex flex-col border-l border-theme-border bg-theme-surface shadow-drawer"
743
+ onKeyDownCapture={(event) => {
744
+ if (historyOverlay && event.key === "Escape") {
745
+ event.preventDefault();
746
+ event.stopPropagation();
747
+ dismissHistory();
748
+ }
749
+ }}
750
+ className={DIAGNOSE_SURFACE_FRAME_CLASS}
327
751
  style={{
328
752
  ...positionStyle,
329
753
  animation: "slide-in-from-right 0.22s cubic-bezier(0.32,0.72,0,1)",
@@ -341,61 +765,103 @@ export function DiagnoseSurface({ topInset = 0 }: { topInset?: number }) {
341
765
 
342
766
  {/* Header */}
343
767
  <div className="flex items-center justify-between border-b border-theme-border px-4 py-2.5">
344
- <div className="flex min-w-0 items-center gap-2">
345
- {!showBreadcrumb && (
346
- <Sparkles className="h-4 w-4 shrink-0 text-accent" />
347
- )}
348
- <div className="min-w-0">
349
- {showBreadcrumb && (
768
+ <div className="flex min-w-0 flex-1 items-center gap-2">
769
+ {d.view !== "home" && showHistory ? (
770
+ <Tooltip
771
+ content={
772
+ historyVisible
773
+ ? "Hide investigation history"
774
+ : "Show investigation history"
775
+ }
776
+ position="bottom"
777
+ className="pointer-events-none"
778
+ >
350
779
  <button
351
- onClick={d.goHome}
352
- 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"
780
+ ref={historyButtonRef}
781
+ type="button"
782
+ aria-label="Investigations"
783
+ aria-expanded={historyVisible}
784
+ aria-controls="investigation-history"
785
+ onClick={() =>
786
+ wideHistory
787
+ ? setHistoryCollapsed((value) => !value)
788
+ : historyOverlay
789
+ ? dismissHistory()
790
+ : setHistoryOverlayOpen(true)
791
+ }
792
+ className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-theme-text-secondary hover:bg-theme-hover focus-visible:ring-2 focus-visible:ring-accent"
353
793
  >
354
- <ChevronLeft className="h-3 w-3" />
355
- Investigations
794
+ <PanelLeftOpen className="h-4 w-4" />
356
795
  </button>
796
+ </Tooltip>
797
+ ) : (
798
+ <span className="flex h-8 w-8 shrink-0 items-center justify-center">
799
+ <Sparkles className="h-4 w-4 text-accent" />
800
+ </span>
801
+ )}
802
+ <div className="min-w-0 flex-1">
803
+ {headerPresentation.genericIdentityClass !== null && (
804
+ <DiagnoseHeaderIdentity
805
+ className={headerPresentation.genericIdentityClass}
806
+ title="AI investigations"
807
+ configLine={genericConfigLine}
808
+ onOpenSettings={openSettings}
809
+ />
810
+ )}
811
+ {headerPresentation.detailIdentityClass !== null && (
812
+ <DiagnoseHeaderIdentity
813
+ className={headerPresentation.detailIdentityClass}
814
+ title={focusedTitle}
815
+ configLine={focusedConfigLine}
816
+ runMeta={focusedRunMeta}
817
+ onOpenSettings={openSettings}
818
+ />
357
819
  )}
358
- <div className="truncate text-sm font-medium text-theme-text-primary">
359
- {detailTitle}
360
- </div>
361
- <div className="flex items-center gap-1 text-xs text-theme-text-tertiary">
362
- <span className="truncate">{configLine}</span>
363
- {openSettings && (
364
- <Tooltip content="AI settings" position="bottom">
365
- <button
366
- onClick={openSettings}
367
- className="shrink-0 rounded p-0.5 text-theme-text-tertiary hover:text-theme-text-primary"
368
- aria-label="AI settings"
369
- >
370
- <Settings2 className="h-3 w-3" />
371
- </button>
372
- </Tooltip>
373
- )}
374
- </div>
375
820
  </div>
376
821
  </div>
377
822
  <div className="flex shrink-0 items-center gap-0.5">
378
823
  {activeRun &&
379
824
  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"
825
+ <Tooltip
826
+ content="Start fresh — ignore earlier findings"
827
+ position="bottom"
393
828
  >
394
- <Plus className="h-4 w-4" />
395
- </button>
396
- </Tooltip>
829
+ <button
830
+ onClick={() =>
831
+ d.openInvestigation({
832
+ kind: activeRun.kind,
833
+ group: activeRun.group,
834
+ namespace: activeRun.namespace,
835
+ name: activeRun.name,
836
+ issueId: activeRun.issueId,
837
+ fresh: true,
838
+ })
839
+ }
840
+ className="rounded-md p-1 text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary"
841
+ aria-label="Start fresh — ignore earlier findings"
842
+ >
843
+ <Plus className="h-4 w-4" />
844
+ </button>
845
+ </Tooltip>
846
+ )}
847
+ {visibleRunDetail && headerPresentation.runActionsClass !== null && (
848
+ <div
849
+ className={`items-center gap-1 ${headerPresentation.runActionsClass || "flex"}`}
850
+ >
851
+ <VisibilityControl
852
+ key={visibleRunDetail.id}
853
+ run={visibleRunDetail}
854
+ onChanged={d.updateRunSummary}
855
+ />
856
+ {canCopyRunLink(visibleRunDetail) && (
857
+ <CopyRunLink
858
+ radarUrl={visibleRunDetail.radarUrl}
859
+ visibility={visibleRunDetail.visibility}
860
+ />
861
+ )}
862
+ <InvestigationMenu run={visibleRunDetail} />
863
+ </div>
397
864
  )}
398
- {activeRun && <InvestigationMenu run={activeRun} />}
399
865
  <Tooltip content={maximized ? "Restore" : "Expand"} position="bottom">
400
866
  <button
401
867
  onClick={() => setMaximized((v) => !v)}
@@ -426,38 +892,61 @@ export function DiagnoseSurface({ topInset = 0 }: { topInset?: number }) {
426
892
  (which would discard its transcript and re-run the agent). The aside
427
893
  only appears when expanded; keys keep the detail node identity-stable
428
894
  as it comes and goes. */}
429
- <div className="flex min-h-0 flex-1">
430
- {showHistory && (!setupPending || d.runs.length > 0) && (
895
+ <div className="relative flex min-h-0 flex-1">
896
+ {!wideHistory && historyOverlayPresent && (
897
+ <div
898
+ aria-hidden="true"
899
+ onClick={dismissHistory}
900
+ className={`absolute inset-0 z-10 bg-black/20 ${TRANSITION_BACKDROP} motion-reduce:transition-none ${historySlideOpen ? "opacity-100" : "opacity-0"} ${historyOverlay ? "" : "pointer-events-none"}`}
901
+ />
902
+ )}
903
+ {showHistory && (
431
904
  <aside
432
905
  key="recent"
433
- className="w-72 shrink-0 overflow-y-auto border-r border-theme-border px-3 py-3"
906
+ ref={historyRef}
907
+ tabIndex={-1}
908
+ aria-label="Investigation history"
909
+ aria-hidden={!historyVisible}
910
+ inert={!historyVisible}
911
+ id="investigation-history"
912
+ className={`${historyVisible || (!wideHistory && historyOverlayPresent) ? "block" : "hidden"} ${wideHistory ? "" : `absolute inset-y-0 left-0 z-20 max-w-[calc(100%-2rem)] shadow-drawer ${TRANSITION_DRAWER} motion-reduce:transition-none ${historySlideOpen ? "translate-x-0 opacity-100" : "-translate-x-full opacity-0"}`} w-72 shrink-0 overflow-y-auto border-r border-theme-border bg-theme-surface px-3 py-3 outline-none`}
434
913
  >
435
914
  <RecentList
915
+ currentContext={currentContext}
436
916
  agentLabel={d.agentLabel}
437
917
  runs={d.runs}
438
918
  selectedId={d.activeRunId}
439
- onSelect={d.openRun}
919
+ onSelect={(id) => {
920
+ d.openRun(id);
921
+ if (historyOverlay) dismissHistory();
922
+ }}
440
923
  historyDegraded={d.historyDegraded}
441
924
  />
442
925
  </aside>
443
926
  )}
444
- {!maximized && d.view === "home" ? (
927
+ {d.view === "home" ? (
928
+ <>
929
+ <div
930
+ key="history"
931
+ className={`flex-1 overflow-y-auto overflow-x-hidden px-4 py-3 ${maximized ? MAXIMIZED_COMPACT_HISTORY_VISIBILITY_CLASS : ""}`}
932
+ >
933
+ {compactHistory}
934
+ </div>
935
+ {maximized && (
936
+ <div
937
+ key="main"
938
+ className={`${MAXIMIZED_HOME_DETAIL_VISIBILITY_CLASS} min-h-0 min-w-0 flex-1 flex-col`}
939
+ >
940
+ {detail}
941
+ </div>
942
+ )}
943
+ </>
944
+ ) : (
445
945
  <div
446
946
  key="main"
447
- className="flex-1 overflow-y-auto overflow-x-hidden px-4 py-3"
947
+ inert={historyOverlay}
948
+ className="flex min-h-0 min-w-0 flex-1 flex-col"
448
949
  >
449
- {setupPending && <AgentSetupNotice setupState={d.setupState} />}
450
- {(!setupPending || d.runs.length > 0) && (
451
- <RecentList
452
- agentLabel={d.agentLabel}
453
- runs={d.runs}
454
- onSelect={d.openRun}
455
- historyDegraded={d.historyDegraded}
456
- />
457
- )}
458
- </div>
459
- ) : (
460
- <div key="main" className="flex min-h-0 min-w-0 flex-1 flex-col">
461
950
  {detail}
462
951
  </div>
463
952
  )}