@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,604 @@
1
+ // A view over one durable, server-side investigation run. It SUBSCRIBES to the
2
+ // run's event stream (replay + live) and reconstructs the transcript; it does not
3
+ // own the run's lifetime — the server does. So closing the panel or navigating
4
+ // away just unsubscribes; the run keeps going and re-subscribing replays it.
5
+ import {
6
+ useCallback,
7
+ useEffect,
8
+ useLayoutEffect,
9
+ useRef,
10
+ useState,
11
+ } from "react";
12
+ import { useQueryClient } from "@tanstack/react-query";
13
+ import { Send, AlertTriangle, ArrowDown } from "lucide-react";
14
+ import {
15
+ subscribeRun,
16
+ addTurn,
17
+ stopRun,
18
+ DiagnoseError,
19
+ type Diagnosis,
20
+ type DiagnoseStreamEvent,
21
+ type RunSummary,
22
+ } from "../../api/diagnose";
23
+ import { useDiagnose } from "./DiagnoseContext";
24
+ import {
25
+ TurnView,
26
+ ResultCard,
27
+ ApplyDialog,
28
+ RunContextCard,
29
+ appendThinking,
30
+ upsertTool,
31
+ type Turn,
32
+ } from "./parts";
33
+
34
+ const RECHECK_QUESTION =
35
+ "Did the fix resolve the issue? Re-check the resource's current status and health now, and say whether it's healthy.";
36
+
37
+ export function InvestigationView({
38
+ run,
39
+ agentLabel,
40
+ maximized,
41
+ }: {
42
+ run: RunSummary;
43
+ agentLabel: string;
44
+ maximized: boolean;
45
+ }) {
46
+ const { kind, namespace, name } = run;
47
+ const { refreshRuns, openInvestigation } = useDiagnose();
48
+ const queryClient = useQueryClient();
49
+ const [turns, setTurns] = useState<Turn[]>([]);
50
+ // The run is gone server-side (evicted past the retention cap, or lost on a
51
+ // restart) — the stream 404s / closes with nothing to replay. Without this we'd
52
+ // show a silent blank panel; instead we render a "no longer available" state.
53
+ const [gone, setGone] = useState(false);
54
+ const [busy, setBusy] = useState(false);
55
+ const [input, setInput] = useState("");
56
+ const [actionError, setActionError] = useState<string | null>(null);
57
+ const scrollRef = useRef<HTMLDivElement>(null);
58
+ const pendingApplyRef = useRef(false);
59
+ // Set when THIS view initiates an apply, consumed once on that apply's done event
60
+ // to auto-run the health re-check (the verification). Ref, not derived from the
61
+ // stream, so replaying a past apply on reopen never re-fires the re-check.
62
+ const autoRecheckRef = useRef(false);
63
+ // Stick-to-bottom: follow streaming output while the user is at/near the bottom,
64
+ // detach the moment they scroll up to read history, re-attach when they return.
65
+ // Tracked from scroll events (the user's intent) — NOT post-render geometry, which
66
+ // mis-detaches whenever a streamed chunk is taller than the threshold.
67
+ const pinnedRef = useRef(true);
68
+ const [showJump, setShowJump] = useState(false);
69
+ const STICK_THRESHOLD = 64; // px from bottom counted as "at the bottom"
70
+
71
+ // Staged synthesis beats: when a real diagnosis is ready, hold it for a beat and
72
+ // narrate the closing reasoning ("Formulating the root cause…" → "Weighing
73
+ // remediation options…") before revealing the verdict. These ARE the phases the
74
+ // model just ran; pacing their presentation makes the payoff feel earned instead
75
+ // of dumped. Apply outcomes and plain follow-ups skip it (no root cause to build).
76
+ const [synth, setSynth] = useState<string | null>(null);
77
+ // Controls how much of a freshly-revealed diagnosis the card shows, so the verdict
78
+ // unfolds in beats: "rca" = root cause only (+ a "weighing remediation" beat),
79
+ // "full" = everything. null/"full" for replayed turns (no choreography on rebuild).
80
+ const [reveal, setReveal] = useState<"rca" | "full" | null>(null);
81
+ const synthTimers = useRef<ReturnType<typeof setTimeout>[]>([]);
82
+ const clearSynth = () => {
83
+ synthTimers.current.forEach(clearTimeout);
84
+ synthTimers.current = [];
85
+ setSynth(null);
86
+ };
87
+
88
+ // After a successful apply, refresh the cluster-state views so the fix shows in
89
+ // the surrounding UI (Issues, the resource, topology, …), not just the transcript.
90
+ const refreshClusterState = useCallback(() => {
91
+ for (const key of [
92
+ ["issues"],
93
+ ["dashboard"],
94
+ ["topology"],
95
+ ["applications"],
96
+ ["audit"],
97
+ ["gitops-insights"],
98
+ ["gitops-tree"],
99
+ ["resource", kind, namespace, name],
100
+ ]) {
101
+ queryClient.invalidateQueries({ queryKey: key });
102
+ }
103
+ }, [queryClient, kind, namespace, name]);
104
+
105
+ const updateLast = (fn: (t: Turn) => Turn) =>
106
+ setTurns((prev) => prev.map((t, i) => (i === prev.length - 1 ? fn(t) : t)));
107
+
108
+ // Progressive reasoning reveal: the agent hands us each thinking block whole, but
109
+ // dumping a paragraph at once reads as a jarring pop. Instead we buffer it and
110
+ // drip it into the transcript line-by-line so it streams the way Claude Code /
111
+ // Codex feel live. A tool call, the final report, or an error flushes the buffer
112
+ // instantly (reasoning must fully precede its own tool, and the result can't wait
113
+ // on an animation) — which also makes tab-reopen replay fast-forward for free,
114
+ // since every turn ends in one of those events.
115
+ const revealBufRef = useRef("");
116
+ const revealTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
117
+ const stopReveal = () => {
118
+ if (revealTimerRef.current) {
119
+ clearInterval(revealTimerRef.current);
120
+ revealTimerRef.current = null;
121
+ }
122
+ };
123
+ const flushReveal = () => {
124
+ stopReveal();
125
+ const rest = revealBufRef.current;
126
+ revealBufRef.current = "";
127
+ if (rest)
128
+ updateLast((t) => ({ ...t, timeline: appendThinking(t.timeline, rest) }));
129
+ };
130
+ // Next reveal unit: a whole line, but cap a long unwrapped line at a sentence
131
+ // boundary so prose paragraphs (no hard breaks) still reveal in pieces.
132
+ const nextRevealUnit = (buf: string): [string, string] => {
133
+ const nl = buf.indexOf("\n");
134
+ let cut = nl === -1 ? buf.length : nl + 1;
135
+ if (cut > 160) {
136
+ const seg = buf.slice(0, 160);
137
+ const s = Math.max(
138
+ seg.lastIndexOf(". "),
139
+ seg.lastIndexOf("? "),
140
+ seg.lastIndexOf("! "),
141
+ );
142
+ cut = s > 40 ? s + 2 : 160;
143
+ }
144
+ return [buf.slice(0, cut), buf.slice(cut)];
145
+ };
146
+ const pumpReveal = () => {
147
+ if (revealTimerRef.current) return;
148
+ revealTimerRef.current = setInterval(() => {
149
+ if (!revealBufRef.current) {
150
+ stopReveal();
151
+ return;
152
+ }
153
+ // Drain faster when a backlog builds so the reveal can't fall behind a fast
154
+ // model — pace is cosmetic, never a bottleneck on the actual investigation.
155
+ const units = revealBufRef.current.length > 900 ? 3 : 1;
156
+ let take = "";
157
+ for (let k = 0; k < units && revealBufRef.current; k++) {
158
+ const [u, rest] = nextRevealUnit(revealBufRef.current);
159
+ take += u;
160
+ revealBufRef.current = rest;
161
+ }
162
+ if (take)
163
+ updateLast((t) => ({
164
+ ...t,
165
+ timeline: appendThinking(t.timeline, take),
166
+ }));
167
+ }, 150);
168
+ };
169
+
170
+ // Subscribe to the run's event stream; rebuild the transcript from scratch on
171
+ // (re)subscribe — the server replays everything, so a fresh tab reconstructs the
172
+ // whole conversation.
173
+ useEffect(() => {
174
+ setTurns([]);
175
+ setGone(false);
176
+ setBusy(false);
177
+ setActionError(null);
178
+ pendingApplyRef.current = false;
179
+ revealBufRef.current = "";
180
+ stopReveal();
181
+ clearSynth();
182
+ setReveal(null);
183
+ // Was the run ALREADY finished when we opened it? Then this subscribe is a
184
+ // replay of history — show every verdict immediately, no staged-reveal beats.
185
+ // The choreography is only for a verdict we watch land live (status running at
186
+ // open). Captured here, not read live, so a follow-up later doesn't re-trigger
187
+ // it for the replayed turns.
188
+ const replaying = run.status !== "running";
189
+ const cancel = subscribeRun(run.id, {
190
+ onEvent: (ev: DiagnoseStreamEvent) => {
191
+ switch (ev.type) {
192
+ case "turn":
193
+ flushReveal(); // close out the prior turn's reasoning before the new one
194
+ clearSynth();
195
+ setReveal(null);
196
+ if (ev.apply) pendingApplyRef.current = true;
197
+ setBusy(true);
198
+ setTurns((prev) => [
199
+ ...prev,
200
+ {
201
+ question: ev.question,
202
+ timeline: [],
203
+ diagnosis: null,
204
+ error: null,
205
+ status: "running",
206
+ apply: ev.apply,
207
+ },
208
+ ]);
209
+ break;
210
+ case "thinking":
211
+ if (ev.token) {
212
+ revealBufRef.current += ev.token;
213
+ pumpReveal();
214
+ }
215
+ break;
216
+ case "step":
217
+ flushReveal(); // reasoning fully precedes the tool it led to
218
+ if (ev.step)
219
+ updateLast((t) => ({
220
+ ...t,
221
+ timeline: upsertTool(t.timeline, ev.step!),
222
+ }));
223
+ break;
224
+ case "done": {
225
+ flushReveal(); // the result can't wait on a reveal animation
226
+ const dx = (ev.diagnosis ?? null) as Diagnosis | null;
227
+ const isApply = pendingApplyRef.current;
228
+ const finalize = () => {
229
+ setBusy(false);
230
+ if (isApply) {
231
+ pendingApplyRef.current = false;
232
+ refreshClusterState();
233
+ // Verify the write automatically: re-check health as a follow-up
234
+ // turn. Guarded by autoRecheckRef so replaying a past apply on
235
+ // reopen never re-fires it.
236
+ if (autoRecheckRef.current && run.status !== "stale") {
237
+ autoRecheckRef.current = false;
238
+ setTimeout(() => {
239
+ addTurn(run.id, { question: RECHECK_QUESTION }).catch(
240
+ () => {},
241
+ );
242
+ }, 900);
243
+ }
244
+ }
245
+ refreshRuns();
246
+ };
247
+ const showCard = (stage: "rca" | "full") => {
248
+ setReveal(stage);
249
+ updateLast((t) => ({ ...t, diagnosis: dx, status: "done" }));
250
+ };
251
+ // Only a real, structured diagnosis earns the staged reveal — and only
252
+ // when watched live. On replay (the run was already finished when we
253
+ // opened it) the beats would just stall showing a verdict that's
254
+ // already known, so we skip straight to the full card.
255
+ const hasRC = !!dx?.rootCause;
256
+ const hasRem = (dx?.remediation?.length ?? 0) > 0;
257
+ const allClear = !!dx?.healthy && !hasRC;
258
+ const inconclusive = !!dx?.inconclusive && !hasRC;
259
+ const structured =
260
+ !!dx && (allClear || inconclusive || hasRC || hasRem);
261
+ if (!isApply && structured && !replaying) {
262
+ const STEP = 2000;
263
+ // Beat 1 (in the timeline): formulating, before any card is shown.
264
+ setReveal(null);
265
+ setSynth(
266
+ allClear
267
+ ? "Confirming health"
268
+ : inconclusive
269
+ ? "Weighing the evidence"
270
+ : hasRC
271
+ ? "Formulating the root cause"
272
+ : "Analyzing the findings",
273
+ );
274
+ synthTimers.current.push(
275
+ setTimeout(() => {
276
+ setSynth(null);
277
+ // Reveal the root cause. If remediation follows, the card shows a
278
+ // "weighing remediation options" beat where the steps will land.
279
+ showCard(hasRC && hasRem ? "rca" : "full");
280
+ if (hasRC && hasRem) {
281
+ synthTimers.current.push(
282
+ setTimeout(() => {
283
+ setReveal("full");
284
+ finalize();
285
+ }, STEP),
286
+ );
287
+ } else {
288
+ finalize();
289
+ }
290
+ }, STEP),
291
+ );
292
+ } else {
293
+ setReveal("full");
294
+ updateLast((t) => ({ ...t, diagnosis: dx, status: "done" }));
295
+ finalize();
296
+ }
297
+ break;
298
+ }
299
+ case "error":
300
+ flushReveal();
301
+ updateLast((t) => ({
302
+ ...t,
303
+ error: ev.error || "The investigation failed.",
304
+ status: "error",
305
+ }));
306
+ setBusy(false);
307
+ pendingApplyRef.current = false;
308
+ refreshRuns();
309
+ break;
310
+ }
311
+ },
312
+ // The run can no longer produce events (evicted / gone). Stale runs emit their
313
+ // own error event + banner before closing, so this only bites the case where a
314
+ // run vanishes while we still think it's running — clear the spinner and mark
315
+ // the open turn terminal so it can't shimmer forever.
316
+ onClosed: () => {
317
+ stopReveal();
318
+ clearSynth();
319
+ setBusy(false);
320
+ setGone(true); // render decides: empty → gone state; mid-run → terminal error
321
+ updateLast((t) =>
322
+ t.status === "running"
323
+ ? {
324
+ ...t,
325
+ status: "error",
326
+ error:
327
+ "This investigation is no longer available. Re-run Diagnose to analyze the current cluster.",
328
+ }
329
+ : t,
330
+ );
331
+ },
332
+ });
333
+ return () => {
334
+ stopReveal();
335
+ clearSynth();
336
+ cancel();
337
+ };
338
+ // eslint-disable-next-line react-hooks/exhaustive-deps
339
+ }, [run.id]);
340
+
341
+ // Follow the bottom IFF still pinned, on anything that changes rendered height:
342
+ // new transcript content (turns), the staged verdict reveal (reveal: rca→full
343
+ // adds the remediation card), and synthesis beats (synth). useLayoutEffect runs
344
+ // before paint, so the jump is invisible and it overrides browser scroll-anchoring
345
+ // (which would otherwise nudge us off the bottom when the remediation card lands).
346
+ useLayoutEffect(() => {
347
+ const el = scrollRef.current;
348
+ if (el && pinnedRef.current) el.scrollTop = el.scrollHeight;
349
+ }, [turns, reveal, synth]);
350
+
351
+ // User scroll updates the pin state: scrolling up past the threshold detaches;
352
+ // scrolling back within it re-attaches. Programmatic scroll-to-bottom lands at
353
+ // distance≈0, so it keeps us pinned — no fight with the auto-follow.
354
+ const onScroll = () => {
355
+ const el = scrollRef.current;
356
+ if (!el) return;
357
+ const atBottom =
358
+ el.scrollHeight - el.scrollTop - el.clientHeight < STICK_THRESHOLD;
359
+ pinnedRef.current = atBottom;
360
+ setShowJump(!atBottom);
361
+ };
362
+ const jumpToBottom = () => {
363
+ const el = scrollRef.current;
364
+ if (!el) return;
365
+ pinnedRef.current = true;
366
+ setShowJump(false);
367
+ el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
368
+ };
369
+
370
+ const stale = run.status === "stale";
371
+
372
+ const submitFollowup = () => {
373
+ const q = input.trim();
374
+ if (!q || busy || stale) return;
375
+ setInput("");
376
+ setActionError(null);
377
+ pinnedRef.current = true; // a user-initiated turn always follows to the bottom
378
+ addTurn(run.id, { question: q }).catch((e) =>
379
+ setActionError(e instanceof DiagnoseError ? e.message : "Couldn't send."),
380
+ );
381
+ };
382
+ const stop = () => stopRun(run.id);
383
+
384
+ // Ask a canned follow-up (e.g. "explain simply") — a one-tap path that turns the
385
+ // prompt's plain-language instruction into something the user controls.
386
+ const askFollowup = (q: string) => {
387
+ if (busy || stale) return;
388
+ setActionError(null);
389
+ pinnedRef.current = true;
390
+ addTurn(run.id, { question: q }).catch((e) =>
391
+ setActionError(e instanceof DiagnoseError ? e.message : "Couldn't send."),
392
+ );
393
+ };
394
+
395
+ // Apply: a user-confirmed remediation turn. Any step is applyable; the chosen
396
+ // step's text is sent so the server binds the apply to it.
397
+ const [confirmApply, setConfirmApply] = useState(false);
398
+ const [pendingFix, setPendingFix] = useState("");
399
+ const requestApply = (fix: string) => {
400
+ setPendingFix(fix);
401
+ setConfirmApply(true);
402
+ };
403
+ const runApply = () => {
404
+ setConfirmApply(false);
405
+ setActionError(null);
406
+ autoRecheckRef.current = true; // verify the write automatically once it lands
407
+ addTurn(run.id, { apply: true, fix: pendingFix }).catch((e) => {
408
+ autoRecheckRef.current = false; // the apply never started — don't auto-recheck
409
+ setActionError(e instanceof DiagnoseError ? e.message : "Couldn't apply.");
410
+ });
411
+ };
412
+ const checkStatus = () => addTurn(run.id, { question: RECHECK_QUESTION }).catch(() => {});
413
+
414
+ // Apply tracks the latest turn that produced remediation (so follow-ups don't
415
+ // strip it) and is blocked on a stale (context-switched) run.
416
+ let lastRemediationIdx = -1;
417
+ turns.forEach((t, i) => {
418
+ if (
419
+ t.status === "done" &&
420
+ !t.apply &&
421
+ (t.diagnosis?.remediation?.length ?? 0) > 0
422
+ )
423
+ lastRemediationIdx = i;
424
+ });
425
+
426
+ // The "primary verdict" — the latest initial-style structured diagnosis (root
427
+ // cause / remediation / healthy / inconclusive), excluding apply outcomes and
428
+ // conversational follow-ups. In the maximized workspace this pins to a side rail
429
+ // so it (and Apply) stay in view while the transcript scrolls as evidence.
430
+ let pinnedIdx = -1;
431
+ turns.forEach((t, i) => {
432
+ const dx = t.diagnosis;
433
+ const structured =
434
+ !!dx &&
435
+ (!!dx.rootCause ||
436
+ (dx.remediation?.length ?? 0) > 0 ||
437
+ dx.healthy ||
438
+ dx.inconclusive);
439
+ if (t.status === "done" && !t.apply && !t.question && structured)
440
+ pinnedIdx = i;
441
+ });
442
+ const pinned = maximized && pinnedIdx >= 0;
443
+
444
+ return (
445
+ <div className="relative flex min-h-0 flex-1 flex-col">
446
+ <div className="flex min-h-0 flex-1">
447
+ <div
448
+ ref={scrollRef}
449
+ onScroll={onScroll}
450
+ className="flex-1 overflow-y-auto overflow-x-hidden px-4 py-3 [scrollbar-gutter:stable]"
451
+ >
452
+ <div className={maximized ? "mx-auto max-w-3xl" : ""}>
453
+ <div className="space-y-4">
454
+ {stale && (
455
+ <div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-xs text-theme-text-secondary">
456
+ <div className="flex items-start gap-2">
457
+ <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
458
+ <span>
459
+ This investigation ran against{" "}
460
+ <span className="font-medium text-theme-text-primary">
461
+ {run.context || "a different cluster"}
462
+ </span>
463
+ . The cluster context has changed — it's read-only now.
464
+ </span>
465
+ </div>
466
+ <button
467
+ onClick={() => openInvestigation({ kind, namespace, name })}
468
+ className="mt-2 inline-flex items-center gap-1.5 rounded-md border border-amber-500/50 px-2.5 py-1 font-medium text-amber-600 hover:bg-amber-500/10 dark:text-amber-400"
469
+ >
470
+ <Send className="h-3 w-3" />
471
+ Re-run on current cluster
472
+ </button>
473
+ </div>
474
+ )}
475
+ {gone && turns.length === 0 && (
476
+ <div className="rounded-lg border border-theme-border bg-theme-elevated p-3 text-sm text-theme-text-secondary">
477
+ <div className="flex items-start gap-2">
478
+ <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
479
+ <span>
480
+ This investigation is no longer available — history keeps
481
+ the most recent investigations, and this one has been
482
+ cleared. Re-run Diagnose to analyze the current cluster.
483
+ </span>
484
+ </div>
485
+ <button
486
+ onClick={() => openInvestigation({ kind, namespace, name })}
487
+ className="mt-2 inline-flex items-center gap-1.5 rounded-md border border-theme-border px-2.5 py-1 font-medium text-theme-text-primary hover:bg-theme-hover"
488
+ >
489
+ <Send className="h-3 w-3" />
490
+ Re-run Diagnose
491
+ </button>
492
+ </div>
493
+ )}
494
+ <RunContextCard run={run} />
495
+ {turns.map((t, i) => {
496
+ const isLast = i === turns.length - 1;
497
+ const canApply = i === lastRemediationIdx && !stale;
498
+ const canCheck = isLast && t.status === "done" && !!t.apply;
499
+ return (
500
+ <TurnView
501
+ key={i}
502
+ turn={t}
503
+ synthLabel={isLast ? synth : null}
504
+ reveal={isLast ? (reveal ?? "full") : "full"}
505
+ onApply={canApply ? requestApply : undefined}
506
+ onAsk={isLast && !busy && !stale ? askFollowup : undefined}
507
+ onCheckStatus={canCheck ? checkStatus : undefined}
508
+ hideVerdict={pinned && i === pinnedIdx}
509
+ />
510
+ );
511
+ })}
512
+ {actionError && (
513
+ <div className="flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-sm text-theme-text-primary">
514
+ <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-400" />
515
+ <span>{actionError}</span>
516
+ </div>
517
+ )}
518
+ </div>
519
+ </div>
520
+ </div>
521
+ {pinned && (
522
+ <aside
523
+ className={`w-[400px] shrink-0 overflow-y-auto border-l border-theme-border px-4 py-3 ${busy ? "opacity-70" : ""}`}
524
+ >
525
+ <div className="mb-1 text-[11px] font-medium uppercase tracking-wide text-theme-text-tertiary">
526
+ {busy ? "Verdict · revising…" : "Verdict"}
527
+ </div>
528
+ <ResultCard
529
+ diagnosis={turns[pinnedIdx].diagnosis!}
530
+ onApply={
531
+ pinnedIdx === lastRemediationIdx && !stale ? requestApply : undefined
532
+ }
533
+ onAsk={!busy && !stale ? askFollowup : undefined}
534
+ reveal="full"
535
+ />
536
+ </aside>
537
+ )}
538
+ </div>
539
+
540
+ {showJump && (
541
+ <button
542
+ onClick={jumpToBottom}
543
+ className="absolute bottom-20 left-1/2 z-10 flex -translate-x-1/2 items-center gap-1.5 rounded-full border border-theme-border bg-theme-elevated px-3 py-1.5 text-xs font-medium text-theme-text-secondary shadow-theme-md transition hover:bg-theme-hover hover:text-theme-text-primary"
544
+ >
545
+ <ArrowDown className="h-3.5 w-3.5" />
546
+ {busy ? "Jump to latest" : "Scroll to bottom"}
547
+ </button>
548
+ )}
549
+
550
+ <ApplyDialog
551
+ open={confirmApply}
552
+ onClose={() => setConfirmApply(false)}
553
+ onConfirm={runApply}
554
+ agentLabel={agentLabel}
555
+ resourceLabel={`${kind} ${namespace ? `${namespace}/` : ""}${name}`}
556
+ fix={pendingFix}
557
+ managedBy={run.managedBy}
558
+ confidence={turns[lastRemediationIdx]?.diagnosis?.confidence}
559
+ />
560
+
561
+ <div
562
+ className={`border-t border-theme-border px-3 py-2.5 ${maximized ? "[&>*]:mx-auto [&>*]:max-w-3xl" : ""}`}
563
+ >
564
+ {busy ? (
565
+ <button
566
+ onClick={stop}
567
+ className="w-full rounded-lg border border-theme-border py-1.5 text-sm text-theme-text-secondary hover:bg-theme-hover"
568
+ >
569
+ Stop
570
+ </button>
571
+ ) : (
572
+ <div className="flex items-end gap-2">
573
+ <textarea
574
+ value={input}
575
+ onChange={(e) => setInput(e.target.value)}
576
+ onKeyDown={(e) => {
577
+ if (e.key === "Enter" && !e.shiftKey) {
578
+ e.preventDefault();
579
+ submitFollowup();
580
+ }
581
+ }}
582
+ rows={1}
583
+ disabled={stale}
584
+ placeholder={
585
+ stale
586
+ ? "Cluster changed — re-run Diagnose"
587
+ : "Ask a follow-up or refine…"
588
+ }
589
+ className="max-h-32 min-h-[38px] flex-1 resize-none rounded-lg border border-theme-border bg-theme-base px-3 py-2 text-sm text-theme-text-primary placeholder:text-theme-text-tertiary focus:border-accent focus:outline-none disabled:opacity-50"
590
+ />
591
+ <button
592
+ onClick={submitFollowup}
593
+ disabled={!input.trim() || stale}
594
+ className="shrink-0 rounded-lg btn-brand p-2 disabled:opacity-40"
595
+ aria-label="Send follow-up"
596
+ >
597
+ <Send className="h-4 w-4" />
598
+ </button>
599
+ </div>
600
+ )}
601
+ </div>
602
+ </div>
603
+ );
604
+ }