@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
@@ -1,16 +1,64 @@
1
+ import type { ReactNode } from "react";
1
2
  // A view over one durable, server-side investigation run. It SUBSCRIBES to the
2
3
  // run's event stream (replay + live) and reconstructs the transcript; it does not
3
4
  // own the run's lifetime — the server does. So closing the panel or navigating
4
5
  // away just unsubscribes; the run keeps going and re-subscribing replays it.
5
6
  import {
7
+ investigationDisclosureSettleDelay,
8
+ prefersReducedMotion,
9
+ useDisclosureReveal,
10
+ } from "./useDisclosureReveal";
11
+ import { investigationExplanation } from "./investigationExplanation";
12
+ import {
13
+ initialInvestigationPane,
14
+ investigationEvidenceShouldMarkUnread,
15
+ investigationEvidenceAnnouncement,
16
+ investigationIsReadOnly,
17
+ investigationInteractionsBlocked,
18
+ canOfferInvestigationApply,
19
+ investigationApplyAttemptVerified,
20
+ investigationAssessmentNeedsCurrentStateVerification,
21
+ investigationApplyRejectionIsDefinitive,
22
+ investigationApplyCompletionEffects,
23
+ investigationTurnWithTerminalEvent,
24
+ investigationApplyTerminalNeedsClusterRefresh,
25
+ investigationClosedEventIsLive,
26
+ investigationClosedRunIsUnavailable,
27
+ investigationEvidenceInputsEqual,
28
+ investigationEvidenceCoverageLimited,
29
+ investigationEvidenceConflictsWithHealthy,
30
+ investigationEndedBeforeConclusion,
31
+ type InvestigationHistoryUnavailableState,
32
+ investigationHistoryUnavailablePresentation,
33
+ investigationPaneCenteredScrollTop,
34
+ canStopInvestigation,
35
+ canContinueInvestigation,
36
+ canInvestigateFurther,
37
+ } from "./investigationState";
38
+ import type { AssessmentExplanation } from "./parts";
39
+ import {
40
+ Fragment,
6
41
  useCallback,
7
42
  useEffect,
43
+ useId,
8
44
  useLayoutEffect,
45
+ useMemo,
9
46
  useRef,
10
47
  useState,
48
+ type KeyboardEvent,
11
49
  } from "react";
12
50
  import { useQueryClient } from "@tanstack/react-query";
13
- import { Send, AlertTriangle, ArrowDown } from "lucide-react";
51
+ import { Badge, Collapse, CollapseChevron } from "@skyhook-io/k8s-ui";
52
+ import {
53
+ Send,
54
+ AlertTriangle,
55
+ ArrowDown,
56
+ ArrowRight,
57
+ Activity,
58
+ CheckCircle2,
59
+ Files,
60
+ Loader2,
61
+ } from "lucide-react";
14
62
  import {
15
63
  subscribeRun,
16
64
  addTurn,
@@ -24,43 +72,110 @@ import { useDiagnose } from "./DiagnoseContext";
24
72
  import {
25
73
  TurnView,
26
74
  ResultCard,
75
+ AssessmentSources,
27
76
  ApplyDialog,
28
- RunContextCard,
29
77
  appendThinking,
30
78
  upsertTool,
31
79
  type Turn,
32
80
  } from "./parts";
81
+ import {
82
+ investigationActivitySourceDomId,
83
+ investigationEvidenceStepIdsByTurn,
84
+ investigationEvidenceSourceDomId,
85
+ projectInvestigationEvidence,
86
+ resolveInvestigationRootCauseEvidence,
87
+ } from "./investigationEvidence";
88
+ import {
89
+ InvestigationEvidencePane,
90
+ partitionInvestigationEvidence,
91
+ } from "./InvestigationEvidencePane";
92
+ import type { DiagnosisResourceRef } from "./diagnoseEvidenceTypes";
93
+ import { formatInvestigationTarget } from "./target";
94
+ import { parseContextName } from "../../utils/context-name";
95
+ import type { InvestigationSourceExcerpt } from "./investigationSourceFocus";
33
96
 
34
97
  const RECHECK_QUESTION =
35
98
  "Did the fix resolve the issue? Re-check the resource's current status and health now, and say whether it's healthy.";
36
99
 
100
+ export function InvestigationStartErrorAlert({
101
+ error,
102
+ onDismiss,
103
+ }: {
104
+ error: string;
105
+ onDismiss: () => void;
106
+ }) {
107
+ return (
108
+ <div
109
+ role="alert"
110
+ className="flex items-start gap-2 border-b border-red-500/30 bg-red-500/10 px-3 py-2.5 text-sm text-theme-text-primary"
111
+ >
112
+ <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-400" />
113
+ <div className="min-w-0 flex-1">
114
+ <div className="font-medium">
115
+ Couldn&apos;t start a new investigation
116
+ </div>
117
+ <div className="text-theme-text-secondary">{error}</div>
118
+ </div>
119
+ <button
120
+ type="button"
121
+ onClick={onDismiss}
122
+ className="shrink-0 rounded px-1.5 py-0.5 text-xs text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary"
123
+ >
124
+ Dismiss
125
+ </button>
126
+ </div>
127
+ );
128
+ }
129
+
130
+ function captureEvidenceCardLayout(container: HTMLElement) {
131
+ const containerTop = container.getBoundingClientRect().top;
132
+ return new Map(
133
+ Array.from(container.querySelectorAll<HTMLElement>("[data-evidence-card]"))
134
+ .filter((card) => !!card.id)
135
+ .map((card) => {
136
+ const rect = card.getBoundingClientRect();
137
+ return [
138
+ card.id,
139
+ {
140
+ top: rect.top - containerTop + container.scrollTop,
141
+ height: rect.height,
142
+ },
143
+ ] as const;
144
+ }),
145
+ );
146
+ }
147
+
37
148
  export function InvestigationView({
38
149
  run,
39
150
  agentLabel,
40
151
  maximized,
152
+ onOpenResource,
41
153
  }: {
42
154
  run: RunSummary;
43
155
  agentLabel: string;
44
156
  maximized: boolean;
157
+ /** Opens an unambiguous evidence subject in Radar's native resource views. */
158
+ onOpenResource?: (ref: DiagnosisResourceRef) => void;
45
159
  }) {
46
160
  const { kind, namespace, name } = run;
47
161
  // Apply is off for hosted agents (read-only server-side). Keyed on the selected
48
162
  // agent, which matches run.agent unless a deployment mixes hosted + local agents.
49
163
  const { refreshRuns, openInvestigation, startError, dismissError, hosted } =
50
164
  useDiagnose();
51
- // Re-run means look again, so it asks for a new session explicitly and only
165
+ // Investigate again means look again, so it asks for a new session explicitly and only
52
166
  // carries the issue forward — being handed the previous answer is the one
53
167
  // thing someone clicking this doesn't want.
54
168
  const retryDiagnosis = useCallback(
55
169
  () =>
56
170
  openInvestigation({
57
171
  kind,
172
+ group: run.group,
58
173
  namespace,
59
174
  name,
60
175
  issueId: run.issueId,
61
176
  fresh: true,
62
177
  }),
63
- [openInvestigation, kind, namespace, name, run.issueId],
178
+ [openInvestigation, kind, namespace, name, run.group, run.issueId],
64
179
  );
65
180
  const queryClient = useQueryClient();
66
181
  const [turns, setTurns] = useState<Turn[]>([]);
@@ -69,14 +184,96 @@ export function InvestigationView({
69
184
  // show a silent blank panel; instead we render a "no longer available" state.
70
185
  const [gone, setGone] = useState(false);
71
186
  const [busy, setBusy] = useState(false);
187
+ const [requestPending, setRequestPending] = useState(false);
188
+ const explanationRequestSerial = useRef(0);
189
+ const [explanationRequest, setExplanationRequest] = useState<{
190
+ sequence: number;
191
+ status: "running" | "error";
192
+ error?: string;
193
+ } | null>(null);
194
+ const [explanationReveal, setExplanationReveal] = useState<{
195
+ sequence: number;
196
+ request: number;
197
+ currentAssessmentIndex: number;
198
+ } | null>(null);
199
+ const [streamReady, setStreamReady] = useState(false);
200
+ const [historyUnavailable, setHistoryUnavailable] =
201
+ useState<InvestigationHistoryUnavailableState | null>(null);
72
202
  const [input, setInput] = useState("");
73
203
  const [actionError, setActionError] = useState<string | null>(null);
204
+ const [verificationError, setVerificationError] = useState<string | null>(
205
+ null,
206
+ );
207
+ // Retire the assessment as soon as the operator confirms Apply, before the
208
+ // HTTP request crosses the network. A lost response is ambiguous: the server
209
+ // may have accepted and completed the write even though fetch rejected. Only
210
+ // a later structured verification assessment makes that action eligible again.
211
+ const [localApplyAttemptAssessmentIdx, setLocalApplyAttemptAssessmentIdx] =
212
+ useState(-1);
213
+ const [applyOutcomeUncertain, setApplyOutcomeUncertain] = useState<
214
+ string | null
215
+ >(null);
216
+ // Covers the short event-stream hand-off after an apply succeeds and before
217
+ // the server-owned verification turn arrives. This is presentation state only:
218
+ // the durable server job, never the browser, schedules verification.
219
+ const [verificationPending, setVerificationPending] = useState(false);
220
+ // The panes are simultaneous above the workspace breakpoint and tabs below it.
221
+ // Successful/stale history opens on its outcome; running and ended-early runs
222
+ // open on Activity, where the user can immediately see what happened.
223
+ const [narrowPane, setNarrowPane] = useState<"activity" | "evidence">(() =>
224
+ initialInvestigationPane(run.status),
225
+ );
226
+ const [unreadEvidence, setUnreadEvidence] = useState(false);
227
+ const [evidenceUpdateAvailable, setEvidenceUpdateAvailable] = useState(false);
228
+ const [evidenceRevealRequest, setEvidenceRevealRequest] = useState<{
229
+ sourceId: string;
230
+ requestId: number;
231
+ }>();
232
+ const [activityRevealRequest, setActivityRevealRequest] = useState<{
233
+ sourceId: string;
234
+ requestId: number;
235
+ excerpt?: InvestigationSourceExcerpt;
236
+ }>();
74
237
  const scrollRef = useRef<HTMLDivElement>(null);
238
+ const evidenceScrollRef = useRef<HTMLDivElement>(null);
239
+ // display:none reports scrollTop=0 even though the browser retains the pane's
240
+ // position. Keep the visible position for evidence arriving behind Activity.
241
+ const evidenceScrollTopRef = useRef(0);
242
+ const evidenceContentRef = useRef<HTMLDivElement>(null);
243
+ const nextStepsRef = useRef<HTMLElement>(null);
244
+ const evidenceCardLayoutRef = useRef(
245
+ new Map<string, { top: number; height: number }>(),
246
+ );
247
+ const latestEvidenceUpdateSourceIdRef = useRef<string | undefined>(undefined);
248
+ const evidenceProjectionTurnsRef = useRef<readonly Turn[]>([]);
249
+ // Replay is accumulated off-screen and committed once at its boundary. This
250
+ // avoids painting a saved transcript turn-by-turn on initial load or reconnect.
251
+ const turnsRef = useRef<Turn[]>([]);
252
+ const replayTurnsRef = useRef<Turn[]>([]);
253
+ // Every subscription starts with replay, including reconnects to a running run.
254
+ // Motion only resumes after the server's explicit replay_complete boundary.
255
+ const suppressEvidenceMotionRef = useRef(true);
256
+ const seenEvidenceGroupRevisionsRef = useRef<Map<string, number>>(new Map());
257
+ const seenEvidenceSourceIdsRef = useRef<Set<string>>(new Set());
258
+ const replayCompleteRef = useRef(false);
259
+ const streamInFlightRef = useRef(false);
75
260
  const pendingApplyRef = useRef(false);
76
- // Set when THIS view initiates an apply, consumed once on that apply's done event
77
- // to auto-run the health re-check (the verification). Ref, not derived from the
78
- // stream, so replaying a past apply on reopen never re-fires the re-check.
79
- const autoRecheckRef = useRef(false);
261
+ // A replayed historical apply marker must reconstruct the transcript without
262
+ // invalidating queries for the cluster connected today. Remember whether the
263
+ // pending marker belongs to activity this view actually watched (including a
264
+ // locally accepted request whose marker arrived during reconnect replay).
265
+ const pendingApplyStartedLiveRef = useRef(false);
266
+ // Covers the interval after the local Apply confirmation but before its SSE
267
+ // turn marker arrives. pendingApplyRef takes over once the stream confirms it.
268
+ const localApplyRequestRef = useRef(false);
269
+ const paneSelectionTouchedRef = useRef(false);
270
+ const evidenceRevealRequestIdRef = useRef(0);
271
+ const activityRevealRequestIdRef = useRef(0);
272
+ const workspaceId = useId();
273
+ const activityTabId = `${workspaceId}-activity-tab`;
274
+ const activityPaneId = `${workspaceId}-activity-pane`;
275
+ const findingsTabId = `${workspaceId}-findings-tab`;
276
+ const findingsPaneId = `${workspaceId}-findings-pane`;
80
277
  // Stick-to-bottom: follow streaming output while the user is at/near the bottom,
81
278
  // detach the moment they scroll up to read history, re-attach when they return.
82
279
  // Tracked from scroll events (the user's intent) — NOT post-render geometry, which
@@ -85,23 +282,6 @@ export function InvestigationView({
85
282
  const [showJump, setShowJump] = useState(false);
86
283
  const STICK_THRESHOLD = 64; // px from bottom counted as "at the bottom"
87
284
 
88
- // Staged synthesis beats: when a real diagnosis is ready, hold it for a beat and
89
- // narrate the closing reasoning ("Formulating the root cause…" → "Weighing
90
- // remediation options…") before revealing the verdict. These ARE the phases the
91
- // model just ran; pacing their presentation makes the payoff feel earned instead
92
- // of dumped. Apply outcomes and plain follow-ups skip it (no root cause to build).
93
- const [synth, setSynth] = useState<string | null>(null);
94
- // Controls how much of a freshly-revealed diagnosis the card shows, so the verdict
95
- // unfolds in beats: "rca" = root cause only (+ a "weighing remediation" beat),
96
- // "full" = everything. null/"full" for replayed turns (no choreography on rebuild).
97
- const [reveal, setReveal] = useState<"rca" | "full" | null>(null);
98
- const synthTimers = useRef<ReturnType<typeof setTimeout>[]>([]);
99
- const clearSynth = () => {
100
- synthTimers.current.forEach(clearTimeout);
101
- synthTimers.current = [];
102
- setSynth(null);
103
- };
104
-
105
285
  // After a successful apply, refresh the cluster-state views so the fix shows in
106
286
  // the surrounding UI (Issues, the resource, topology, …), not just the transcript.
107
287
  const refreshClusterState = useCallback(() => {
@@ -119,8 +299,19 @@ export function InvestigationView({
119
299
  }
120
300
  }, [queryClient, kind, namespace, name]);
121
301
 
302
+ const updateTurns = (fn: (prev: Turn[]) => Turn[]) => {
303
+ if (!replayCompleteRef.current) {
304
+ replayTurnsRef.current = fn(replayTurnsRef.current);
305
+ return;
306
+ }
307
+ const next = fn(turnsRef.current);
308
+ turnsRef.current = next;
309
+ setTurns(next);
310
+ };
122
311
  const updateLast = (fn: (t: Turn) => Turn) =>
123
- setTurns((prev) => prev.map((t, i) => (i === prev.length - 1 ? fn(t) : t)));
312
+ updateTurns((prev) =>
313
+ prev.map((t, i) => (i === prev.length - 1 ? fn(t) : t)),
314
+ );
124
315
 
125
316
  // Progressive reasoning reveal: the agent hands us each thinking block whole, but
126
317
  // dumping a paragraph at once reads as a jarring pop. Instead we buffer it and
@@ -137,12 +328,15 @@ export function InvestigationView({
137
328
  revealTimerRef.current = null;
138
329
  }
139
330
  };
140
- const flushReveal = () => {
331
+ const flushReveal = (animate = replayCompleteRef.current) => {
141
332
  stopReveal();
142
333
  const rest = revealBufRef.current;
143
334
  revealBufRef.current = "";
144
335
  if (rest)
145
- updateLast((t) => ({ ...t, timeline: appendThinking(t.timeline, rest) }));
336
+ updateLast((t) => ({
337
+ ...t,
338
+ timeline: appendThinking(t.timeline, rest, animate),
339
+ }));
146
340
  };
147
341
  // Next reveal unit: a whole line, but cap a long unwrapped line at a sentence
148
342
  // boundary so prose paragraphs (no hard breaks) still reveal in pieces.
@@ -179,7 +373,7 @@ export function InvestigationView({
179
373
  if (take)
180
374
  updateLast((t) => ({
181
375
  ...t,
182
- timeline: appendThinking(t.timeline, take),
376
+ timeline: appendThinking(t.timeline, take, true),
183
377
  }));
184
378
  }, 150);
185
379
  };
@@ -188,182 +382,286 @@ export function InvestigationView({
188
382
  // (re)subscribe — the server replays everything, so a fresh tab reconstructs the
189
383
  // whole conversation.
190
384
  useEffect(() => {
385
+ turnsRef.current = [];
386
+ replayTurnsRef.current = [];
191
387
  setTurns([]);
192
388
  setGone(false);
193
389
  setBusy(false);
390
+ setRequestPending(false);
391
+ setStreamReady(false);
392
+ setExplanationRequest(null);
393
+ explanationRequestSerial.current++;
394
+ setExplanationReveal(null);
395
+ setHistoryUnavailable(null);
194
396
  setActionError(null);
397
+ setVerificationError(null);
398
+ setVerificationPending(false);
399
+ setLocalApplyAttemptAssessmentIdx(-1);
400
+ setApplyOutcomeUncertain(null);
195
401
  pendingApplyRef.current = false;
402
+ pendingApplyStartedLiveRef.current = false;
403
+ localApplyRequestRef.current = false;
404
+ replayCompleteRef.current = false;
405
+ streamInFlightRef.current = false;
406
+ suppressEvidenceMotionRef.current = true;
407
+ seenEvidenceGroupRevisionsRef.current.clear();
408
+ seenEvidenceSourceIdsRef.current.clear();
196
409
  revealBufRef.current = "";
197
410
  stopReveal();
198
- clearSynth();
199
- setReveal(null);
200
- // Was the run ALREADY finished when we opened it? Then this subscribe is a
201
- // replay of history — show every verdict immediately, no staged-reveal beats.
202
- // The choreography is only for a verdict we watch land live (status running at
203
- // open). Captured here, not read live, so a follow-up later doesn't re-trigger
204
- // it for the replayed turns.
205
- const replaying = run.status !== "running";
411
+ paneSelectionTouchedRef.current = false;
412
+ setNarrowPane(initialInvestigationPane(run.status));
413
+ setUnreadEvidence(false);
414
+ setEvidenceUpdateAvailable(false);
415
+ evidenceScrollTopRef.current = 0;
416
+ latestEvidenceUpdateSourceIdRef.current = undefined;
417
+ setEvidenceRevealRequest(undefined);
418
+ evidenceRevealRequestIdRef.current = 0;
419
+ setActivityRevealRequest(undefined);
420
+ activityRevealRequestIdRef.current = 0;
421
+ evidenceCardLayoutRef.current.clear();
422
+ evidenceProjectionTurnsRef.current = [];
206
423
  const cancel = subscribeRun(run.id, {
207
- onEvent: (ev: DiagnoseStreamEvent) => {
424
+ onEvent: (ev: DiagnoseStreamEvent, sequence?: number) => {
425
+ const live = replayCompleteRef.current;
208
426
  switch (ev.type) {
209
427
  case "turn":
210
- flushReveal(); // close out the prior turn's reasoning before the new one
211
- clearSynth();
212
- setReveal(null);
213
- if (ev.apply) pendingApplyRef.current = true;
214
- setBusy(true);
215
- setTurns((prev) => [
428
+ flushReveal(live); // close out the prior turn's reasoning before the new one
429
+ setRequestPending(false);
430
+ if (ev.explainAssessment) setExplanationRequest(null);
431
+ if (ev.apply) {
432
+ pendingApplyStartedLiveRef.current =
433
+ pendingApplyStartedLiveRef.current ||
434
+ live ||
435
+ localApplyRequestRef.current;
436
+ pendingApplyRef.current = true;
437
+ localApplyRequestRef.current = false;
438
+ // The stream now owns the exact outcome. Keep the assessment
439
+ // retired, but replace the transport-uncertainty banner with the
440
+ // streamed apply result / error when it arrives.
441
+ setApplyOutcomeUncertain(null);
442
+ }
443
+ if (ev.verify) {
444
+ setVerificationPending(false);
445
+ setVerificationError(null);
446
+ }
447
+ streamInFlightRef.current = true;
448
+ if (live) setBusy(true);
449
+ updateTurns((prev) => [
216
450
  ...prev,
217
451
  {
218
452
  question: ev.question,
453
+ actor: ev.actor,
454
+ explainAssessment: ev.explainAssessment,
219
455
  timeline: [],
220
456
  diagnosis: null,
221
457
  error: null,
222
458
  status: "running",
223
459
  apply: ev.apply,
460
+ verify: ev.verify,
224
461
  },
225
462
  ]);
226
463
  break;
227
464
  case "thinking":
228
465
  if (ev.token) {
229
- revealBufRef.current += ev.token;
230
- pumpReveal();
466
+ if (live) {
467
+ revealBufRef.current += ev.token;
468
+ pumpReveal();
469
+ } else {
470
+ updateLast((t) => ({
471
+ ...t,
472
+ timeline: appendThinking(t.timeline, ev.token!, false),
473
+ }));
474
+ }
231
475
  }
232
476
  break;
233
477
  case "step":
234
- flushReveal(); // reasoning fully precedes the tool it led to
478
+ flushReveal(live); // reasoning fully precedes the tool it led to
235
479
  if (ev.step)
236
480
  updateLast((t) => ({
237
481
  ...t,
238
- timeline: upsertTool(t.timeline, ev.step!),
482
+ timeline: upsertTool(t.timeline, ev.step!, live),
239
483
  }));
240
484
  break;
241
485
  case "done": {
242
- flushReveal(); // the result can't wait on a reveal animation
243
- const dx = (ev.diagnosis ?? null) as Diagnosis | null;
486
+ flushReveal(live); // the result can't wait on a reveal animation
244
487
  const isApply = pendingApplyRef.current;
245
- const finalize = () => {
246
- setBusy(false);
247
- if (isApply) {
248
- pendingApplyRef.current = false;
249
- refreshClusterState();
250
- // Verify the write automatically: re-check health as a follow-up
251
- // turn. Guarded by autoRecheckRef so replaying a past apply on
252
- // reopen never re-fires it.
253
- if (autoRecheckRef.current && run.status !== "stale") {
254
- autoRecheckRef.current = false;
255
- setTimeout(() => {
256
- addTurn(run.id, { question: RECHECK_QUESTION }).catch(
257
- () => {},
258
- );
259
- }, 900);
260
- }
488
+ const applyStartedLive = pendingApplyStartedLiveRef.current;
489
+ streamInFlightRef.current = false;
490
+ updateLast((t) => ({
491
+ ...investigationTurnWithTerminalEvent(t, ev, live),
492
+ resultSequence: sequence,
493
+ }));
494
+ if (live) setBusy(false);
495
+ if (isApply) {
496
+ pendingApplyRef.current = false;
497
+ const effects = investigationApplyCompletionEffects({
498
+ live,
499
+ applyStartedLive,
500
+ stale: run.status === "stale",
501
+ });
502
+ pendingApplyStartedLiveRef.current = false;
503
+ if (effects.refreshClusterState) refreshClusterState();
504
+ // A successful apply is one compound server-owned job. Its next
505
+ // durable event is the automatic read-only verification turn; hold
506
+ // the controls through that adjacent event so there is no idle flash.
507
+ if (effects.verificationPending) setVerificationPending(true);
508
+ }
509
+ if (live || (isApply && applyStartedLive)) refreshRuns();
510
+ break;
511
+ }
512
+ case "error": {
513
+ flushReveal(live);
514
+ streamInFlightRef.current = false;
515
+ const verificationScheduled =
516
+ live && ev.verificationScheduled === true;
517
+ const applyMayHaveMutated =
518
+ investigationApplyTerminalNeedsClusterRefresh({
519
+ localApplyRequestPending: localApplyRequestRef.current,
520
+ streamedApplyPending: pendingApplyRef.current,
521
+ streamedApplyStartedLive: pendingApplyStartedLiveRef.current,
522
+ terminalEventIsLive: live,
523
+ });
524
+ {
525
+ const activeTurns = replayCompleteRef.current
526
+ ? turnsRef.current
527
+ : replayTurnsRef.current;
528
+ if (live && activeTurns.at(-1)?.verify) {
529
+ setVerificationError(
530
+ ev.error || "The verification could not be completed.",
531
+ );
261
532
  }
262
- refreshRuns();
263
- };
264
- const showCard = (stage: "rca" | "full") => {
265
- setReveal(stage);
266
- updateLast((t) => ({ ...t, diagnosis: dx, status: "done" }));
267
- };
268
- // Only a real, structured diagnosis earns the staged reveal — and only
269
- // when watched live. On replay (the run was already finished when we
270
- // opened it) the beats would just stall showing a verdict that's
271
- // already known, so we skip straight to the full card.
272
- const hasRC = !!dx?.rootCause;
273
- const hasRem = (dx?.remediation?.length ?? 0) > 0;
274
- const allClear = !!dx?.healthy && !hasRC;
275
- const inconclusive = !!dx?.inconclusive && !hasRC;
276
- const structured =
277
- !!dx && (allClear || inconclusive || hasRC || hasRem);
278
- if (!isApply && structured && !replaying) {
279
- const STEP = 2000;
280
- // Beat 1 (in the timeline): formulating, before any card is shown.
281
- setReveal(null);
282
- setSynth(
283
- allClear
284
- ? "Confirming health"
285
- : inconclusive
286
- ? "Weighing the evidence"
287
- : hasRC
288
- ? "Formulating the root cause"
289
- : "Analyzing the findings",
290
- );
291
- synthTimers.current.push(
292
- setTimeout(() => {
293
- setSynth(null);
294
- // Reveal the root cause. If remediation follows, the card shows a
295
- // "weighing remediation options" beat where the steps will land.
296
- showCard(hasRC && hasRem ? "rca" : "full");
297
- if (hasRC && hasRem) {
298
- synthTimers.current.push(
299
- setTimeout(() => {
300
- setReveal("full");
301
- finalize();
302
- }, STEP),
303
- );
304
- } else {
305
- finalize();
306
- }
307
- }, STEP),
308
- );
309
- } else {
310
- setReveal("full");
311
- updateLast((t) => ({ ...t, diagnosis: dx, status: "done" }));
312
- finalize();
313
533
  }
534
+ updateLast((t) => investigationTurnWithTerminalEvent(t, ev, live));
535
+ if (live && !verificationScheduled) setBusy(false);
536
+ setRequestPending(false);
537
+ pendingApplyRef.current = false;
538
+ pendingApplyStartedLiveRef.current = false;
539
+ localApplyRequestRef.current = false;
540
+ setVerificationPending(verificationScheduled);
541
+ if (applyMayHaveMutated) refreshClusterState();
542
+ if (live) refreshRuns();
314
543
  break;
315
544
  }
316
- case "error":
317
- flushReveal();
318
- updateLast((t) => ({
319
- ...t,
320
- error: ev.error || "The investigation failed.",
321
- status: "error",
322
- }));
545
+ case "history_unavailable":
546
+ setHistoryUnavailable({
547
+ error: ev.error || "Radar could not read the saved history.",
548
+ retryable: ev.retryable === true,
549
+ });
550
+ setStreamReady(false);
323
551
  setBusy(false);
324
- pendingApplyRef.current = false;
325
- refreshRuns();
552
+ setRequestPending(false);
326
553
  break;
554
+ case "replay_complete":
555
+ turnsRef.current = replayTurnsRef.current;
556
+ setTurns(replayTurnsRef.current);
557
+ if (!paneSelectionTouchedRef.current) {
558
+ const latest = replayTurnsRef.current.at(-1);
559
+ if (latest?.explainAssessment) {
560
+ setNarrowPane("evidence");
561
+ } else if (
562
+ latest?.status === "done" &&
563
+ latest.question &&
564
+ !latest.verify &&
565
+ !latest.apply
566
+ ) {
567
+ setNarrowPane("activity");
568
+ }
569
+ }
570
+ replayCompleteRef.current = true;
571
+ suppressEvidenceMotionRef.current = false;
572
+ setHistoryUnavailable(null);
573
+ setStreamReady(true);
574
+ setBusy(streamInFlightRef.current);
575
+ break;
576
+ }
577
+ },
578
+ onReplayStart: () => {
579
+ // `open` fires for reconnects too. Transitioning from live seeds the replay
580
+ // staging buffer from the committed transcript. If an initial replay itself
581
+ // reconnects, preserve its uncommitted prefix: Last-Event-ID only sends the
582
+ // suffix, so resetting here would silently drop already-received history.
583
+ const wasLive = replayCompleteRef.current;
584
+ if (wasLive) {
585
+ flushReveal(true);
586
+ replayTurnsRef.current = turnsRef.current;
327
587
  }
588
+ replayCompleteRef.current = false;
589
+ suppressEvidenceMotionRef.current = true;
590
+ setStreamReady(false);
328
591
  },
329
592
  // The run can no longer produce events (evicted / gone). Stale runs emit their
330
593
  // own error event + banner before closing, so this only bites the case where a
331
594
  // run vanishes while we still think it's running — clear the spinner and mark
332
595
  // the open turn terminal so it can't shimmer forever.
333
- onClosed: () => {
596
+ onClosed: (reason) => {
334
597
  stopReveal();
335
- clearSynth();
598
+ const applyMayHaveMutated =
599
+ investigationApplyTerminalNeedsClusterRefresh({
600
+ localApplyRequestPending: localApplyRequestRef.current,
601
+ streamedApplyPending: pendingApplyRef.current,
602
+ streamedApplyStartedLive: pendingApplyStartedLiveRef.current,
603
+ terminalEventIsLive: investigationClosedEventIsLive({
604
+ reason,
605
+ subscribedRunStatus: run.status,
606
+ replayComplete: replayCompleteRef.current,
607
+ }),
608
+ });
609
+ pendingApplyRef.current = false;
610
+ pendingApplyStartedLiveRef.current = false;
611
+ localApplyRequestRef.current = false;
612
+ if (applyMayHaveMutated) refreshClusterState();
613
+ if (!replayCompleteRef.current) {
614
+ turnsRef.current = replayTurnsRef.current;
615
+ setTurns(replayTurnsRef.current);
616
+ }
617
+ replayCompleteRef.current = false;
618
+ streamInFlightRef.current = false;
336
619
  setBusy(false);
337
- setGone(true); // render decides: empty → gone state; mid-run → terminal error
338
- updateLast((t) =>
339
- t.status === "running"
340
- ? {
341
- ...t,
342
- status: "error",
343
- error:
344
- "This investigation is no longer available. Re-run Diagnose to analyze the current cluster.",
345
- }
346
- : t,
347
- );
620
+ setRequestPending(false);
621
+ setVerificationPending(false);
622
+ setHistoryUnavailable(null);
623
+ setStreamReady(true);
624
+ // A durable close is expected for stale history. A 404/eviction is gone;
625
+ // don't relabel a successfully reconstructed stale transcript as missing.
626
+ const unavailable = investigationClosedRunIsUnavailable({
627
+ reason,
628
+ subscribedRunStatus: run.status,
629
+ });
630
+ setGone(unavailable);
631
+ if (unavailable) {
632
+ const current = turnsRef.current;
633
+ const next = current.map((t, i) =>
634
+ i === current.length - 1 && t.status === "running"
635
+ ? {
636
+ ...t,
637
+ status: "error" as const,
638
+ error: applyMayHaveMutated
639
+ ? "This investigation is no longer available. The requested change may have completed; Radar refreshed cluster state, but you should start a new investigation to verify it before applying anything again."
640
+ : "This investigation is no longer available. Start a new investigation to analyze the current cluster.",
641
+ }
642
+ : t,
643
+ );
644
+ turnsRef.current = next;
645
+ setTurns(next);
646
+ }
348
647
  },
349
648
  });
350
649
  return () => {
351
650
  stopReveal();
352
- clearSynth();
353
651
  cancel();
354
652
  };
355
653
  // eslint-disable-next-line react-hooks/exhaustive-deps
356
654
  }, [run.id]);
357
655
 
358
656
  // Follow the bottom IFF still pinned, on anything that changes rendered height:
359
- // new transcript content (turns), the staged verdict reveal (reveal: rca→full
360
- // adds the remediation card), and synthesis beats (synth). useLayoutEffect runs
657
+ // new transcript content (turns), or Activity becoming visible after live work
658
+ // arrived behind the Findings tab. useLayoutEffect runs
361
659
  // before paint, so the jump is invisible and it overrides browser scroll-anchoring
362
660
  // (which would otherwise nudge us off the bottom when the remediation card lands).
363
661
  useLayoutEffect(() => {
364
662
  const el = scrollRef.current;
365
663
  if (el && pinnedRef.current) el.scrollTop = el.scrollHeight;
366
- }, [turns, reveal, synth]);
664
+ }, [turns, narrowPane]);
367
665
 
368
666
  // User scroll updates the pin state: scrolling up past the threshold detaches;
369
667
  // scrolling back within it re-attaches. Programmatic scroll-to-bottom lands at
@@ -381,32 +679,108 @@ export function InvestigationView({
381
679
  if (!el) return;
382
680
  pinnedRef.current = true;
383
681
  setShowJump(false);
384
- el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
682
+ el.scrollTo({
683
+ top: el.scrollHeight,
684
+ behavior: prefersReducedMotion() ? "auto" : "smooth",
685
+ });
385
686
  };
386
687
 
387
688
  const stale = run.status === "stale";
689
+ const lastTurn = turns.at(-1);
690
+ const unavailable = investigationIsReadOnly(run.status, gone);
691
+ const canContinue = canContinueInvestigation(run, lastTurn?.status, gone);
692
+ const canStop = canStopInvestigation(run, busy, gone, lastTurn?.status);
693
+ const readOnly = unavailable || !canContinue;
694
+ const endedEarly = investigationEndedBeforeConclusion(run.status, lastTurn);
695
+ const rebuildingReplay = !streamReady && turns.length === 0;
696
+ const historyUnavailablePresentation = historyUnavailable
697
+ ? investigationHistoryUnavailablePresentation(historyUnavailable)
698
+ : null;
699
+ const interactionsBlocked = investigationInteractionsBlocked({
700
+ streamReady,
701
+ busy,
702
+ requestPending,
703
+ readOnly,
704
+ verificationPending,
705
+ });
388
706
 
389
707
  const submitFollowup = () => {
390
708
  const q = input.trim();
391
- if (!q || busy || stale) return;
709
+ if (!q || interactionsBlocked) return;
392
710
  setInput("");
393
711
  setActionError(null);
712
+ setNarrowPane("activity");
713
+ suppressEvidenceMotionRef.current = false;
394
714
  pinnedRef.current = true; // a user-initiated turn always follows to the bottom
395
- addTurn(run.id, { question: q }).catch((e) =>
396
- setActionError(e instanceof DiagnoseError ? e.message : "Couldn't send."),
397
- );
715
+ setRequestPending(true);
716
+ addTurn(run.id, { question: q }).catch((e) => {
717
+ setRequestPending(false);
718
+ setActionError(e instanceof DiagnoseError ? e.message : "Couldn't send.");
719
+ });
398
720
  };
399
721
  const stop = () => stopRun(run.id);
400
722
 
401
- // Ask a canned follow-up (e.g. "explain simply") — a one-tap path that turns the
402
- // prompt's plain-language instruction into something the user controls.
403
- const askFollowup = (q: string) => {
404
- if (busy || stale) return;
723
+ const askExplanation = (sequence: number) => {
724
+ if (interactionsBlocked) return;
405
725
  setActionError(null);
406
- pinnedRef.current = true;
407
- addTurn(run.id, { question: q }).catch((e) =>
408
- setActionError(e instanceof DiagnoseError ? e.message : "Couldn't send."),
409
- );
726
+ const serial = ++explanationRequestSerial.current;
727
+ const previousTurns = turnsRef.current.length;
728
+ setExplanationRequest({ sequence, status: "running" });
729
+ setRequestPending(true);
730
+ addTurn(run.id, { explainAssessment: sequence }).catch((e) => {
731
+ if (serial !== explanationRequestSerial.current) return;
732
+ // If the stream already accepted the turn, it owns progress and failure.
733
+ if (
734
+ turnsRef.current
735
+ .slice(previousTurns)
736
+ .some((turn) => turn.explainAssessment === sequence)
737
+ )
738
+ return;
739
+ setRequestPending(false);
740
+ setExplanationRequest({
741
+ sequence,
742
+ status: "error",
743
+ error:
744
+ e instanceof DiagnoseError
745
+ ? e.message
746
+ : "Couldn't request an explanation.",
747
+ });
748
+ });
749
+ };
750
+
751
+ const explanationFor = (
752
+ assessment: Turn,
753
+ ): AssessmentExplanation | undefined => {
754
+ const sequence = assessment.resultSequence;
755
+ if (!sequence || !assessment.diagnosis?.rootCause) return undefined;
756
+ const saved = investigationExplanation(turns, sequence);
757
+ // This intent is implemented by the local run manager; hosted continuation
758
+ // alone does not imply support for assessment-bound explanation turns.
759
+ if ((readOnly || hosted) && saved.status === "idle") return undefined;
760
+ const state =
761
+ explanationRequest?.sequence === sequence ? explanationRequest : saved;
762
+ return {
763
+ ...state,
764
+ onGenerate:
765
+ !hosted && !interactionsBlocked
766
+ ? () => askExplanation(sequence)
767
+ : undefined,
768
+ openRequest:
769
+ explanationReveal?.sequence === sequence &&
770
+ explanationReveal.currentAssessmentIndex === currentAssessmentIdx
771
+ ? explanationReveal.request
772
+ : undefined,
773
+ };
774
+ };
775
+
776
+ const viewExplanation = (sequence: number) => {
777
+ paneSelectionTouchedRef.current = true;
778
+ setNarrowPane("evidence");
779
+ setExplanationReveal((previous) => ({
780
+ sequence,
781
+ request: (previous?.request ?? 0) + 1,
782
+ currentAssessmentIndex: currentAssessmentIdx,
783
+ }));
410
784
  };
411
785
 
412
786
  // Apply: a user-confirmed remediation turn. Any step is applyable; the chosen
@@ -414,37 +788,90 @@ export function InvestigationView({
414
788
  const [confirmApply, setConfirmApply] = useState(false);
415
789
  const [pendingFix, setPendingFix] = useState("");
416
790
  const requestApply = (fix: string) => {
791
+ if (interactionsBlocked) return;
417
792
  setPendingFix(fix);
418
793
  setConfirmApply(true);
419
794
  };
420
795
  const runApply = () => {
421
796
  setConfirmApply(false);
797
+ if (interactionsBlocked) return;
422
798
  setActionError(null);
423
- autoRecheckRef.current = true; // verify the write automatically once it lands
799
+ setVerificationError(null);
800
+ setApplyOutcomeUncertain(null);
801
+ setNarrowPane("activity");
802
+ suppressEvidenceMotionRef.current = false;
803
+ pinnedRef.current = true;
804
+ // Pessimistic by design: once the operator confirms a write, a transport
805
+ // failure cannot prove that it did not run. Retire this assessment before
806
+ // fetch and require a later verification before Apply can return.
807
+ setLocalApplyAttemptAssessmentIdx((previous) =>
808
+ Math.max(previous, currentAssessmentIdx),
809
+ );
810
+ localApplyRequestRef.current = true;
811
+ setRequestPending(true);
424
812
  addTurn(run.id, { apply: true, fix: pendingFix }).catch((e) => {
425
- autoRecheckRef.current = false; // the apply never started — don't auto-recheck
426
- setActionError(e instanceof DiagnoseError ? e.message : "Couldn't apply.");
813
+ setRequestPending(false);
814
+ if (investigationApplyRejectionIsDefinitive(e)) {
815
+ localApplyRequestRef.current = false;
816
+ setLocalApplyAttemptAssessmentIdx(-1);
817
+ setApplyOutcomeUncertain(null);
818
+ setActionError(e.message.trim() || "Couldn't apply.");
819
+ return;
820
+ }
821
+ refreshClusterState();
822
+ const detail = e instanceof DiagnoseError ? e.message.trim() : "";
823
+ setApplyOutcomeUncertain(
824
+ detail
825
+ ? `${detail} Radar has not verified the current state; check it before applying again.`
826
+ : "Radar couldn't confirm whether the apply request completed. Cluster state was refreshed; check current status before applying again.",
827
+ );
828
+ });
829
+ };
830
+ const checkStatus = () => {
831
+ if (interactionsBlocked) return Promise.resolve();
832
+ setActionError(null);
833
+ setVerificationError(null);
834
+ setNarrowPane("activity");
835
+ suppressEvidenceMotionRef.current = false;
836
+ pinnedRef.current = true;
837
+ setRequestPending(true);
838
+ return addTurn(run.id, {
839
+ question: RECHECK_QUESTION,
840
+ verify: true,
841
+ }).catch((error) => {
842
+ setRequestPending(false);
843
+ setVerificationError(
844
+ error instanceof DiagnoseError
845
+ ? error.message
846
+ : "Couldn't check status.",
847
+ );
427
848
  });
428
849
  };
429
- const checkStatus = () => addTurn(run.id, { question: RECHECK_QUESTION }).catch(() => {});
430
850
 
431
851
  // Apply tracks the latest turn that produced remediation (so follow-ups don't
432
- // strip it) and is blocked on a stale (context-switched) run.
852
+ // strip it). Any accepted apply attempt, including a stopped or failed one,
853
+ // retires that assessment until a later verification produces a new one.
433
854
  let lastRemediationIdx = -1;
855
+ let lastApplyAttemptIdx = -1;
856
+ let lastApplyOutcome: Turn["applyOutcome"];
434
857
  turns.forEach((t, i) => {
435
858
  if (
436
859
  t.status === "done" &&
437
860
  !t.apply &&
861
+ !t.explainAssessment &&
862
+ (!t.question || t.verify) &&
438
863
  (t.diagnosis?.remediation?.length ?? 0) > 0
439
864
  )
440
865
  lastRemediationIdx = i;
866
+ if (t.apply) {
867
+ lastApplyAttemptIdx = i;
868
+ lastApplyOutcome = t.applyOutcome;
869
+ }
441
870
  });
442
871
 
443
- // The "primary verdict" — the latest initial-style structured diagnosis (root
444
- // cause / remediation / healthy / inconclusive), excluding apply outcomes and
445
- // conversational follow-ups. In the maximized workspace this pins to a side rail
446
- // so it (and Apply) stay in view while the transcript scrolls as evidence.
447
- let pinnedIdx = -1;
872
+ // Initial and explicit verification turns update the Evidence-pane assessment.
873
+ // Ordinary questions remain conversational answers in Activity.
874
+ const assessmentIndexes: number[] = [];
448
875
  turns.forEach((t, i) => {
449
876
  const dx = t.diagnosis;
450
877
  const structured =
@@ -453,206 +880,1352 @@ export function InvestigationView({
453
880
  (dx.remediation?.length ?? 0) > 0 ||
454
881
  dx.healthy ||
455
882
  dx.inconclusive);
456
- if (t.status === "done" && !t.apply && !t.question && structured)
457
- pinnedIdx = i;
883
+ if (
884
+ t.status === "done" &&
885
+ !t.apply &&
886
+ !t.explainAssessment &&
887
+ (!t.question || t.verify) &&
888
+ structured
889
+ )
890
+ assessmentIndexes.push(i);
458
891
  });
459
- const pinned = maximized && pinnedIdx >= 0;
892
+ const currentAssessmentIdx = assessmentIndexes.at(-1) ?? -1;
893
+ const initialAssessmentIdx = assessmentIndexes[0] ?? -1;
894
+ const hasMultipleAssessments = assessmentIndexes.length > 1;
895
+ const currentAssessment =
896
+ currentAssessmentIdx >= 0 ? turns[currentAssessmentIdx] : undefined;
460
897
 
461
- return (
462
- <div className="relative flex min-h-0 flex-1 flex-col">
463
- <div className="flex min-h-0 flex-1">
464
- <div
465
- ref={scrollRef}
466
- onScroll={onScroll}
467
- className="flex-1 overflow-y-auto overflow-x-hidden px-4 py-3 [scrollbar-gutter:stable]"
468
- >
469
- <div className={maximized ? "mx-auto max-w-3xl" : ""}>
470
- <div className="space-y-4">
471
- {stale && (
472
- <div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-xs text-theme-text-secondary">
473
- <div className="flex items-start gap-2">
474
- <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
898
+ const laterVerificationRecorded = investigationApplyAttemptVerified({
899
+ localApplyAttemptAssessmentIdx,
900
+ currentAssessmentIdx,
901
+ currentAssessmentIsVerification:
902
+ turns[currentAssessmentIdx]?.verify === true,
903
+ });
904
+ useEffect(() => {
905
+ if (!laterVerificationRecorded) return;
906
+ setLocalApplyAttemptAssessmentIdx(-1);
907
+ setApplyOutcomeUncertain(null);
908
+ localApplyRequestRef.current = false;
909
+ }, [laterVerificationRecorded]);
910
+
911
+ if (
912
+ !investigationEvidenceInputsEqual(evidenceProjectionTurnsRef.current, turns)
913
+ ) {
914
+ evidenceProjectionTurnsRef.current = turns;
915
+ }
916
+ const evidenceProjectionTurns = evidenceProjectionTurnsRef.current;
917
+ const projection = useMemo(
918
+ () =>
919
+ projectInvestigationEvidence(evidenceProjectionTurns, {
920
+ kind,
921
+ group: run.group,
922
+ namespace,
923
+ name,
924
+ }),
925
+ [evidenceProjectionTurns, kind, namespace, name, run.group],
926
+ );
927
+ const currentAssessmentProjection = useMemo(
928
+ () =>
929
+ projectInvestigationEvidence(
930
+ currentAssessmentIdx >= 0
931
+ ? [evidenceProjectionTurns[currentAssessmentIdx]]
932
+ : [],
933
+ { kind, group: run.group, namespace, name },
934
+ ),
935
+ [
936
+ evidenceProjectionTurns,
937
+ currentAssessmentIdx,
938
+ kind,
939
+ namespace,
940
+ name,
941
+ run.group,
942
+ ],
943
+ );
944
+ const rootCauseEvidenceResolution = useMemo(
945
+ () =>
946
+ currentAssessment?.diagnosis?.rootCause
947
+ ? resolveInvestigationRootCauseEvidence(
948
+ projection,
949
+ currentAssessment.diagnosis.rootCauseEvidence,
950
+ currentAssessmentIdx,
951
+ )
952
+ : undefined,
953
+ [currentAssessment, currentAssessmentIdx, projection],
954
+ );
955
+ const visibleEvidenceGroupIds = useMemo(
956
+ () =>
957
+ new Set(
958
+ partitionInvestigationEvidence(
959
+ projection.groups,
960
+ rootCauseEvidenceResolution,
961
+ ).collectionByGroup.keys(),
962
+ ),
963
+ [projection.groups, rootCauseEvidenceResolution],
964
+ );
965
+ const evidenceStepIdsByTurn = useMemo(
966
+ () =>
967
+ investigationEvidenceStepIdsByTurn(projection, visibleEvidenceGroupIds),
968
+ [projection, visibleEvidenceGroupIds],
969
+ );
970
+
971
+ const animateEvidenceGroupIds = useMemo(() => {
972
+ if (suppressEvidenceMotionRef.current) return new Set<string>();
973
+ return new Set(
974
+ projection.groups
975
+ .filter((group) => {
976
+ if (!visibleEvidenceGroupIds.has(group.id)) return false;
977
+ const seen = seenEvidenceGroupRevisionsRef.current.get(group.id) ?? 0;
978
+ return (
979
+ group.observations.length > seen &&
980
+ group.observations
981
+ .slice(seen)
982
+ .some(
983
+ (observation) =>
984
+ turns[observation.source.turnIndex]?.timeline[
985
+ observation.source.timelineIndex
986
+ ]?.animate === true,
987
+ )
988
+ );
989
+ })
990
+ .map((group) => group.id),
991
+ );
992
+ }, [projection.groups, turns, visibleEvidenceGroupIds]);
993
+ useEffect(() => {
994
+ for (const group of projection.groups) {
995
+ seenEvidenceGroupRevisionsRef.current.set(
996
+ group.id,
997
+ group.observations.length,
998
+ );
999
+ }
1000
+ }, [projection.groups]);
1001
+
1002
+ // A repeated check can revise an existing card without changing the group
1003
+ // count, so new live sources—not card count—drive the inactive-tab pulse.
1004
+ // Replayed sources are marked seen without pulsing the tab.
1005
+ useEffect(() => {
1006
+ const newLiveSources = projection.sources.filter((source) => {
1007
+ if (!evidenceStepIdsByTurn.get(source.turnIndex)?.has(source.stepId))
1008
+ return false;
1009
+ if (seenEvidenceSourceIdsRef.current.has(source.id)) return false;
1010
+ return (
1011
+ turns[source.turnIndex]?.timeline[source.timelineIndex]?.animate ===
1012
+ true
1013
+ );
1014
+ });
1015
+ const hasNewLiveSource = newLiveSources.length > 0;
1016
+ if (
1017
+ investigationEvidenceShouldMarkUnread({
1018
+ hasNewLiveSource,
1019
+ selectedPane: narrowPane,
1020
+ evidencePaneVisible:
1021
+ evidenceScrollRef.current !== null &&
1022
+ evidenceScrollRef.current.offsetParent !== null,
1023
+ })
1024
+ ) {
1025
+ setUnreadEvidence(true);
1026
+ }
1027
+ const scrollTop = evidenceScrollRef.current?.offsetParent
1028
+ ? evidenceScrollRef.current.scrollTop
1029
+ : evidenceScrollTopRef.current;
1030
+ if (hasNewLiveSource && scrollTop > 80) {
1031
+ latestEvidenceUpdateSourceIdRef.current = newLiveSources.at(-1)?.id;
1032
+ setEvidenceUpdateAvailable(true);
1033
+ }
1034
+ for (const source of projection.sources) {
1035
+ seenEvidenceSourceIdsRef.current.add(source.id);
1036
+ }
1037
+ }, [projection.sources, turns, narrowPane, evidenceStepIdsByTurn]);
1038
+
1039
+ // The projection can fold a fresh observation into an existing evidence
1040
+ // source. Keep the scrolled-away cue reliable for that in-place revision too.
1041
+ useEffect(() => {
1042
+ const scrollTop = evidenceScrollRef.current?.offsetParent
1043
+ ? evidenceScrollRef.current.scrollTop
1044
+ : evidenceScrollTopRef.current;
1045
+ if (animateEvidenceGroupIds.size > 0 && scrollTop > 80) {
1046
+ const latestChangedSource = projection.groups
1047
+ .filter((group) => animateEvidenceGroupIds.has(group.id))
1048
+ .map((group) => group.chronologicalLatest.source)
1049
+ .filter((source) =>
1050
+ evidenceStepIdsByTurn.get(source.turnIndex)?.has(source.stepId),
1051
+ )
1052
+ .sort((left, right) => left.order - right.order)
1053
+ .at(-1);
1054
+ if (latestChangedSource) {
1055
+ latestEvidenceUpdateSourceIdRef.current = latestChangedSource.id;
1056
+ setEvidenceUpdateAvailable(true);
1057
+ }
1058
+ }
1059
+ }, [animateEvidenceGroupIds, projection.groups, evidenceStepIdsByTurn]);
1060
+
1061
+ // Evidence is inserted into semantic tiers rather than blindly appended. Keep
1062
+ // the first card a user is reading fixed in place when a live result lands above
1063
+ // it. Native scroll anchoring varies across nested grids, so this pane owns the
1064
+ // policy explicitly (and leaves the top of the story free to update when the
1065
+ // reader has not scrolled away from it).
1066
+ const evidenceLayoutRevision = `${projection.groups
1067
+ .filter((group) => visibleEvidenceGroupIds.has(group.id))
1068
+ .map(
1069
+ (group) =>
1070
+ `${group.id}:${group.observations.length}:${group.latest.tier}:${group.historical ? 1 : 0}`,
1071
+ )
1072
+ .join("|")}|limitations:${projection.limitations
1073
+ .map(
1074
+ (limitation) =>
1075
+ `${limitation.kind}:${limitation.source}:${limitation.sources.length}:${limitation.message}`,
1076
+ )
1077
+ .join(",")}`;
1078
+ useLayoutEffect(() => {
1079
+ const container = evidenceScrollRef.current;
1080
+ if (!container || container.offsetParent === null) return;
1081
+ const cards = Array.from(
1082
+ container.querySelectorAll<HTMLElement>("[data-evidence-card]"),
1083
+ );
1084
+ const current = captureEvidenceCardLayout(container);
1085
+
1086
+ const previous = evidenceCardLayoutRef.current;
1087
+ if (
1088
+ !suppressEvidenceMotionRef.current &&
1089
+ previous.size > 0 &&
1090
+ container.scrollTop > 8
1091
+ ) {
1092
+ const anchor = cards
1093
+ .map((card) => ({ card, layout: previous.get(card.id) }))
1094
+ .filter(
1095
+ (
1096
+ entry,
1097
+ ): entry is {
1098
+ card: HTMLElement;
1099
+ layout: { top: number; height: number };
1100
+ } => !!entry.layout,
1101
+ )
1102
+ .sort((a, b) => a.layout.top - b.layout.top)
1103
+ .find(
1104
+ ({ layout }) => layout.top + layout.height >= container.scrollTop - 1,
1105
+ );
1106
+ if (anchor) {
1107
+ const nextTop = current.get(anchor.card.id)?.top;
1108
+ if (nextTop != null) {
1109
+ const delta = nextTop - anchor.layout.top;
1110
+ if (Math.abs(delta) > 1) container.scrollTop += delta;
1111
+ }
1112
+ }
1113
+ }
1114
+
1115
+ // Record positions after any scroll correction so the next insertion compares
1116
+ // against what the user actually saw.
1117
+ evidenceCardLayoutRef.current = captureEvidenceCardLayout(container);
1118
+ }, [evidenceLayoutRevision, currentAssessmentIdx, narrowPane]);
1119
+
1120
+ // Disclosure animations and responsive reflow can move cards without changing
1121
+ // the evidence projection. Continuously refresh the baseline after those layout
1122
+ // changes; the layout effect above remains the only place that adjusts scroll.
1123
+ useEffect(() => {
1124
+ const container = evidenceScrollRef.current;
1125
+ const content = evidenceContentRef.current;
1126
+ if (!container || !content || typeof ResizeObserver === "undefined") {
1127
+ return;
1128
+ }
1129
+ let frame: number | undefined;
1130
+ const refreshLayoutBaseline = () => {
1131
+ if (frame !== undefined) cancelAnimationFrame(frame);
1132
+ frame = requestAnimationFrame(() => {
1133
+ frame = undefined;
1134
+ if (container.offsetParent !== null) {
1135
+ // A responsive transition can reveal Findings without changing the
1136
+ // selected narrow-pane tab (for example, maximizing into split view).
1137
+ // Once the evidence is onscreen it is no longer unread.
1138
+ setUnreadEvidence(false);
1139
+ evidenceCardLayoutRef.current = captureEvidenceCardLayout(container);
1140
+ }
1141
+ });
1142
+ };
1143
+ const observer = new ResizeObserver(refreshLayoutBaseline);
1144
+ // Observe the pane itself so crossing the responsive visibility boundary is
1145
+ // detected even when the projected evidence content has not changed size.
1146
+ observer.observe(container);
1147
+ observer.observe(content);
1148
+ for (const card of container.querySelectorAll<HTMLElement>(
1149
+ "[data-evidence-card]",
1150
+ )) {
1151
+ observer.observe(card);
1152
+ }
1153
+ refreshLayoutBaseline();
1154
+ return () => {
1155
+ observer.disconnect();
1156
+ if (frame !== undefined) cancelAnimationFrame(frame);
1157
+ };
1158
+ }, [evidenceLayoutRevision, narrowPane, maximized]);
1159
+
1160
+ const focusAfterPaneSwitch = useCallback(
1161
+ (domId: string, evidence: boolean) => {
1162
+ requestAnimationFrame(() => {
1163
+ requestAnimationFrame(() => {
1164
+ const marker = document.getElementById(domId);
1165
+ const target = evidence
1166
+ ? ((marker?.closest(
1167
+ "[data-evidence-card], [data-evidence-source-container]",
1168
+ ) as HTMLElement | null) ?? marker)
1169
+ : marker;
1170
+ const container = evidence
1171
+ ? evidenceScrollRef.current
1172
+ : scrollRef.current;
1173
+ if (target && container) {
1174
+ const containerRect = container.getBoundingClientRect();
1175
+ const targetRect = target.getBoundingClientRect();
1176
+ container.scrollTo({
1177
+ top: investigationPaneCenteredScrollTop({
1178
+ scrollTop: container.scrollTop,
1179
+ viewportHeight: container.clientHeight,
1180
+ contentHeight: container.scrollHeight,
1181
+ targetTop: targetRect.top - containerRect.top,
1182
+ targetHeight: targetRect.height,
1183
+ }),
1184
+ behavior: prefersReducedMotion() ? "auto" : "smooth",
1185
+ });
1186
+ }
1187
+ target?.focus({ preventScroll: true });
1188
+ });
1189
+ });
1190
+ },
1191
+ [],
1192
+ );
1193
+ const viewEvidenceSource = useCallback((sourceId: string) => {
1194
+ paneSelectionTouchedRef.current = true;
1195
+ setNarrowPane("evidence");
1196
+ setUnreadEvidence(false);
1197
+ // This path reveals the exact changed source, so the broader scrolled-away
1198
+ // cue has served its purpose even when the centered card remains below 80px.
1199
+ setEvidenceUpdateAvailable(false);
1200
+ latestEvidenceUpdateSourceIdRef.current = undefined;
1201
+ evidenceRevealRequestIdRef.current += 1;
1202
+ setEvidenceRevealRequest({
1203
+ sourceId,
1204
+ requestId: evidenceRevealRequestIdRef.current,
1205
+ });
1206
+ }, []);
1207
+ const revealEvidenceSource = useCallback(
1208
+ (sourceId: string) => {
1209
+ focusAfterPaneSwitch(investigationEvidenceSourceDomId(sourceId), true);
1210
+ },
1211
+ [focusAfterPaneSwitch],
1212
+ );
1213
+ const viewActivitySource = useCallback(
1214
+ (sourceId: string, excerpt?: InvestigationSourceExcerpt) => {
1215
+ paneSelectionTouchedRef.current = true;
1216
+ activityRevealRequestIdRef.current += 1;
1217
+ setActivityRevealRequest({
1218
+ sourceId,
1219
+ excerpt,
1220
+ requestId: activityRevealRequestIdRef.current,
1221
+ });
1222
+ setNarrowPane("activity");
1223
+ window.setTimeout(
1224
+ () =>
1225
+ focusAfterPaneSwitch(
1226
+ investigationActivitySourceDomId(sourceId),
1227
+ false,
1228
+ ),
1229
+ investigationDisclosureSettleDelay(prefersReducedMotion()),
1230
+ );
1231
+ },
1232
+ [focusAfterPaneSwitch],
1233
+ );
1234
+
1235
+ const verificationRunning = turns.some(
1236
+ (turn) => turn.verify && turn.status === "running",
1237
+ );
1238
+ const toolCallCount = turns.reduce(
1239
+ (count, turn) =>
1240
+ count + turn.timeline.filter((item) => item.kind === "tool").length,
1241
+ 0,
1242
+ );
1243
+ const latestVerification = [...turns].reverse().find((turn) => turn.verify);
1244
+ const displayedVerificationError =
1245
+ latestVerification?.status === "error"
1246
+ ? latestVerification.error || "The verification could not be completed."
1247
+ : verificationError;
1248
+ const displayedStatusCheckError =
1249
+ displayedVerificationError || applyOutcomeUncertain;
1250
+ const currentKeyFindingCount = projection.groups.filter(
1251
+ (group) => !group.historical && group.latest.tier === "key",
1252
+ ).length;
1253
+ const findingsTabAccessibleLabel =
1254
+ "Findings: current assessment, Radar evidence, and next steps";
1255
+ const currentAssessmentCoverageLimited = investigationEvidenceCoverageLimited(
1256
+ currentAssessmentProjection,
1257
+ );
1258
+ const currentAssessmentEvidenceConflict =
1259
+ currentAssessment?.diagnosis?.healthy === true &&
1260
+ investigationEvidenceConflictsWithHealthy(projection);
1261
+ const hasEvidenceCollectedAfterAssessment =
1262
+ currentAssessmentIdx >= 0 &&
1263
+ projection.sources.some(
1264
+ (source) => source.turnIndex > currentAssessmentIdx,
1265
+ );
1266
+ const assessmentNeedsCurrentStateVerification =
1267
+ investigationAssessmentNeedsCurrentStateVerification({
1268
+ currentAssessmentIdx,
1269
+ lastApplyAttemptIdx,
1270
+ lastApplyOutcome,
1271
+ localApplyAttemptAssessmentIdx,
1272
+ });
1273
+ const hasNextSteps = Boolean(
1274
+ currentAssessment?.diagnosis &&
1275
+ (currentAssessment.diagnosis.remediation?.length ?? 0) > 0,
1276
+ );
1277
+ const earlierPlan =
1278
+ assessmentNeedsCurrentStateVerification ||
1279
+ hasEvidenceCollectedAfterAssessment;
1280
+ const showSplitWorkspace = maximized;
1281
+ const splitGridClass = showSplitWorkspace
1282
+ ? "@min-[1000px]/investigation:grid-cols-[minmax(360px,520px)_minmax(0,1fr)]"
1283
+ : "";
1284
+ const splitTabClass = showSplitWorkspace
1285
+ ? "@min-[1000px]/investigation:hidden"
1286
+ : "";
1287
+ const splitPaneClass = showSplitWorkspace
1288
+ ? "@min-[1000px]/investigation:flex"
1289
+ : "";
1290
+ const splitActivityBorderClass = showSplitWorkspace
1291
+ ? "@min-[1000px]/investigation:border-r @min-[1000px]/investigation:border-theme-border"
1292
+ : "";
1293
+
1294
+ const selectPane = (pane: "activity" | "evidence") => {
1295
+ paneSelectionTouchedRef.current = true;
1296
+ setNarrowPane(pane);
1297
+ if (pane === "evidence") {
1298
+ setUnreadEvidence(false);
1299
+ }
1300
+ };
1301
+ const viewActivity = () => {
1302
+ paneSelectionTouchedRef.current = true;
1303
+ setNarrowPane("activity");
1304
+ requestAnimationFrame(() => {
1305
+ scrollRef.current?.scrollTo({ top: 0 });
1306
+ document.getElementById(activityPaneId)?.focus({ preventScroll: true });
1307
+ });
1308
+ };
1309
+ const onTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
1310
+ let pane: "activity" | "evidence" | undefined;
1311
+ if (event.key === "ArrowLeft" || event.key === "Home") pane = "activity";
1312
+ if (event.key === "ArrowRight" || event.key === "End") pane = "evidence";
1313
+ if (!pane) return;
1314
+ event.preventDefault();
1315
+ selectPane(pane);
1316
+ document
1317
+ .getElementById(pane === "activity" ? activityTabId : findingsTabId)
1318
+ ?.focus();
1319
+ };
1320
+ const revealLatestEvidenceUpdate = () => {
1321
+ const sourceId = latestEvidenceUpdateSourceIdRef.current;
1322
+ const source = projection.sources.find((item) => item.id === sourceId);
1323
+ if (
1324
+ source &&
1325
+ evidenceStepIdsByTurn.get(source.turnIndex)?.has(source.stepId)
1326
+ ) {
1327
+ viewEvidenceSource(source.id);
1328
+ return;
1329
+ }
1330
+ evidenceScrollRef.current?.scrollTo({
1331
+ top: 0,
1332
+ behavior: prefersReducedMotion() ? "auto" : "smooth",
1333
+ });
1334
+ setEvidenceUpdateAvailable(false);
1335
+ };
1336
+
1337
+ const composer = !unavailable ? (
1338
+ <div className="shrink-0 border-t border-theme-border px-3 py-2.5">
1339
+ {canStop ? (
1340
+ <button
1341
+ type="button"
1342
+ onClick={stop}
1343
+ className="w-full rounded-lg border border-theme-border py-1.5 text-sm text-theme-text-secondary hover:bg-theme-hover"
1344
+ >
1345
+ Stop agent
1346
+ </button>
1347
+ ) : run.status === "stopping" ? (
1348
+ <div className="px-3 py-2 text-xs text-theme-text-secondary">
1349
+ Stopping investigation…
1350
+ </div>
1351
+ ) : !canContinue ? (
1352
+ <div className="px-3 py-2 text-xs text-theme-text-secondary">
1353
+ {run.trigger === "background" ? (
1354
+ <>
1355
+ <p>Automatic investigation · read-only.</p>
1356
+ {canInvestigateFurther(run, gone) ? (
1357
+ <div className="mt-2 flex flex-wrap items-center gap-2">
1358
+ <button
1359
+ className="btn-brand px-3 py-2 text-xs"
1360
+ onClick={() =>
1361
+ openInvestigation({
1362
+ kind,
1363
+ group: run.group,
1364
+ namespace,
1365
+ name,
1366
+ issueId: run.issueId,
1367
+ })
1368
+ }
1369
+ >
1370
+ Investigate further
1371
+ </button>
475
1372
  <span>
476
- This investigation ran against{" "}
477
- <span className="font-medium text-theme-text-primary">
478
- {run.context || "a different cluster"}
479
- </span>
480
- . The cluster context has changed — it's read-only now.
1373
+ Re-checks current state, including recent automatic findings
1374
+ when available.
481
1375
  </span>
482
1376
  </div>
483
- <button
484
- onClick={retryDiagnosis}
485
- 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"
486
- >
487
- <Send className="h-3 w-3" />
488
- Re-run on current cluster
489
- </button>
1377
+ ) : (
1378
+ <p>
1379
+ Start a new investigation on this resource to continue
1380
+ digging.
1381
+ </p>
1382
+ )}
1383
+ </>
1384
+ ) : (
1385
+ "This investigation is read-only."
1386
+ )}
1387
+ </div>
1388
+ ) : (
1389
+ <div className="flex items-end gap-2">
1390
+ <textarea
1391
+ value={input}
1392
+ onChange={(event) => setInput(event.target.value)}
1393
+ onKeyDown={(event) => {
1394
+ if (event.key === "Enter" && !event.shiftKey) {
1395
+ event.preventDefault();
1396
+ submitFollowup();
1397
+ }
1398
+ }}
1399
+ rows={1}
1400
+ disabled={
1401
+ !streamReady ||
1402
+ readOnly ||
1403
+ busy ||
1404
+ requestPending ||
1405
+ verificationPending
1406
+ }
1407
+ placeholder={
1408
+ !streamReady
1409
+ ? historyUnavailablePresentation?.loading
1410
+ ? "Retrying investigation history…"
1411
+ : historyUnavailablePresentation
1412
+ ? "Investigation history unavailable"
1413
+ : "Loading investigation history…"
1414
+ : verificationPending
1415
+ ? "Waiting to verify the applied change…"
1416
+ : requestPending
1417
+ ? "Agent is working…"
1418
+ : "Ask a follow-up or refine…"
1419
+ }
1420
+ 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"
1421
+ />
1422
+ <button
1423
+ type="button"
1424
+ onClick={submitFollowup}
1425
+ disabled={!input.trim() || interactionsBlocked}
1426
+ className="shrink-0 rounded-lg btn-brand p-2 disabled:opacity-40"
1427
+ aria-label="Send follow-up"
1428
+ >
1429
+ <Send className="h-4 w-4" />
1430
+ </button>
1431
+ </div>
1432
+ )}
1433
+ </div>
1434
+ ) : null;
1435
+
1436
+ return (
1437
+ <div
1438
+ data-investigation-workspace
1439
+ className={`@container/investigation relative flex min-h-0 flex-1 flex-col bg-theme-surface ${maximized ? "investigation-split-enabled" : ""}`}
1440
+ >
1441
+ {stale ? (
1442
+ <div className="flex items-center gap-2 border-b border-amber-500/35 bg-amber-500/10 px-3 py-2 text-xs text-theme-text-secondary">
1443
+ <AlertTriangle className="h-4 w-4 shrink-0 text-amber-500" />
1444
+ <span className="min-w-0 flex-1">
1445
+ This investigation ran on{" "}
1446
+ <span className="font-medium text-theme-text-primary">
1447
+ {parseContextName(run.context).clusterName}
1448
+ </span>
1449
+ . It is read-only because its agent session was closed after a
1450
+ cluster switch.
1451
+ </span>
1452
+ </div>
1453
+ ) : null}
1454
+ {!stale && gone ? (
1455
+ <div className="flex items-center gap-2 border-b border-theme-border bg-theme-elevated px-3 py-2 text-xs text-theme-text-secondary">
1456
+ <AlertTriangle className="h-4 w-4 shrink-0 text-amber-500" />
1457
+ <span className="min-w-0 flex-1">
1458
+ This investigation is closed and read-only.{" "}
1459
+ {turns.length > 0
1460
+ ? "Evidence already loaded in this view is preserved, but Radar can no longer continue the run."
1461
+ : "It may be private, your access may have changed, or its history may have been cleared. Check your account and organization, or ask the creator for access."}
1462
+ </span>
1463
+ <button
1464
+ type="button"
1465
+ onClick={retryDiagnosis}
1466
+ className="shrink-0 rounded-md border border-theme-border px-2 py-1 font-medium text-theme-text-primary hover:bg-theme-hover"
1467
+ >
1468
+ Investigate again
1469
+ </button>
1470
+ </div>
1471
+ ) : null}
1472
+ {!stale && !gone && endedEarly ? (
1473
+ <div
1474
+ role="status"
1475
+ className="flex items-center gap-2 border-b border-theme-border bg-theme-elevated px-3 py-2 text-xs text-theme-text-secondary"
1476
+ >
1477
+ <AlertTriangle className="h-4 w-4 shrink-0 text-amber-500" />
1478
+ <span className="min-w-0 flex-1">
1479
+ {run.status === "stopped"
1480
+ ? "This investigation was stopped before it reached a conclusion."
1481
+ : "This investigation ended before it reached a complete conclusion."}
1482
+ <span className="ml-1">
1483
+ Evidence collected so far is preserved. See Activity for the final
1484
+ error or stopped state.
1485
+ </span>
1486
+ </span>
1487
+ <button
1488
+ type="button"
1489
+ onClick={retryDiagnosis}
1490
+ className="shrink-0 rounded-md border border-theme-border px-2 py-1 font-medium text-theme-text-primary hover:bg-theme-hover"
1491
+ >
1492
+ Investigate again
1493
+ </button>
1494
+ </div>
1495
+ ) : null}
1496
+
1497
+ {startError ? (
1498
+ <InvestigationStartErrorAlert
1499
+ error={startError}
1500
+ onDismiss={dismissError}
1501
+ />
1502
+ ) : null}
1503
+
1504
+ {!gone && historyUnavailablePresentation ? (
1505
+ <div
1506
+ role={historyUnavailablePresentation.loading ? "status" : "alert"}
1507
+ className={`flex items-start gap-2 border-b px-3 py-2.5 text-xs text-theme-text-secondary ${
1508
+ historyUnavailablePresentation.loading
1509
+ ? "border-amber-500/35 bg-amber-500/10"
1510
+ : "border-red-500/30 bg-red-500/10"
1511
+ }`}
1512
+ >
1513
+ {historyUnavailablePresentation.loading ? (
1514
+ <Loader2 className="mt-0.5 h-4 w-4 shrink-0 animate-spin text-amber-500" />
1515
+ ) : (
1516
+ <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-400" />
1517
+ )}
1518
+ <span className="min-w-0">
1519
+ <span className="block font-medium text-theme-text-primary">
1520
+ {historyUnavailablePresentation.title}
1521
+ </span>
1522
+ <span>{historyUnavailablePresentation.detail}</span>
1523
+ </span>
1524
+ </div>
1525
+ ) : null}
1526
+
1527
+ <div
1528
+ role="group"
1529
+ aria-label="Investigation workspace"
1530
+ className={`grid grid-cols-2 border-b border-theme-border bg-theme-base/40 p-1 ${splitTabClass}`}
1531
+ >
1532
+ <button
1533
+ type="button"
1534
+ id={activityTabId}
1535
+ aria-controls={activityPaneId}
1536
+ aria-pressed={narrowPane === "activity"}
1537
+ aria-label="Activity: agent reasoning and tool calls"
1538
+ onKeyDown={onTabKeyDown}
1539
+ onClick={() => selectPane("activity")}
1540
+ className={`flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium ${
1541
+ narrowPane === "activity"
1542
+ ? "selection-strong selection-text selection-ring"
1543
+ : "text-theme-text-secondary hover:bg-theme-hover"
1544
+ }`}
1545
+ >
1546
+ <Activity className="h-3.5 w-3.5" />
1547
+ Activity
1548
+ </button>
1549
+ <button
1550
+ type="button"
1551
+ id={findingsTabId}
1552
+ aria-controls={findingsPaneId}
1553
+ aria-pressed={narrowPane === "evidence"}
1554
+ aria-label={findingsTabAccessibleLabel}
1555
+ onKeyDown={onTabKeyDown}
1556
+ onClick={() => selectPane("evidence")}
1557
+ className={`relative flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium ${
1558
+ narrowPane === "evidence"
1559
+ ? "selection-strong selection-text selection-ring"
1560
+ : "text-theme-text-secondary hover:bg-theme-hover"
1561
+ }`}
1562
+ >
1563
+ <Files className="h-3.5 w-3.5" />
1564
+ Findings
1565
+ {unreadEvidence && narrowPane !== "evidence" ? (
1566
+ <span className="h-1.5 w-1.5 rounded-full bg-accent" aria-hidden />
1567
+ ) : null}
1568
+ </button>
1569
+ <span className="sr-only" role="status" aria-live="polite">
1570
+ {investigationEvidenceAnnouncement({
1571
+ unreadEvidence,
1572
+ evidenceUpdateAvailable,
1573
+ })}
1574
+ </span>
1575
+ </div>
1576
+
1577
+ <div className={`grid min-h-0 flex-1 ${splitGridClass}`}>
1578
+ <section
1579
+ id={activityPaneId}
1580
+ tabIndex={-1}
1581
+ aria-label="Activity: agent reasoning and tool calls"
1582
+ aria-busy={busy || requestPending || rebuildingReplay}
1583
+ className={`${
1584
+ narrowPane === "activity" ? "flex" : "hidden"
1585
+ } relative min-h-0 min-w-0 flex-col outline-none ${splitPaneClass} ${splitActivityBorderClass}`}
1586
+ >
1587
+ <div
1588
+ className={`hidden items-center justify-between border-b border-theme-border/60 px-3 py-2 ${splitPaneClass}`}
1589
+ >
1590
+ <div className="flex min-w-0 items-center gap-2">
1591
+ <Activity className="h-4 w-4 shrink-0 text-theme-text-tertiary" />
1592
+ <div className="min-w-0">
1593
+ <h2 className="truncate text-sm font-semibold text-theme-text-primary">
1594
+ Activity
1595
+ </h2>
1596
+ <p className="truncate text-[11px] text-theme-text-tertiary">
1597
+ Agent reasoning and tool calls
1598
+ </p>
490
1599
  </div>
491
- )}
492
- {gone && turns.length === 0 && (
493
- <div className="rounded-lg border border-theme-border bg-theme-elevated p-3 text-sm text-theme-text-secondary">
494
- <div className="flex items-start gap-2">
495
- <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
496
- <span>
497
- This investigation is no longer available — history keeps
498
- the most recent investigations, and this one has been
499
- cleared. Re-run Diagnose to analyze the current cluster.
500
- </span>
1600
+ </div>
1601
+ <span className="inline-flex items-center gap-1.5 text-[11px] text-theme-text-tertiary">
1602
+ {toolCallCount > 0
1603
+ ? `${toolCallCount} ${toolCallCount === 1 ? "tool call" : "tool calls"}`
1604
+ : turns.length > 0
1605
+ ? `${turns.length} ${turns.length === 1 ? "turn" : "turns"}`
1606
+ : null}
1607
+ </span>
1608
+ </div>
1609
+ <div className="relative flex min-h-0 flex-1 flex-col">
1610
+ <div
1611
+ ref={scrollRef}
1612
+ data-investigation-activity-scroll
1613
+ onScroll={onScroll}
1614
+ className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 py-3 [scrollbar-gutter:stable]"
1615
+ >
1616
+ <div>
1617
+ <div className="space-y-4">
1618
+ {turns.length === 0 && !gone ? (
1619
+ <div className="flex min-h-36 flex-col items-center justify-center rounded-lg border border-dashed border-theme-border px-4 text-center">
1620
+ {historyUnavailablePresentation &&
1621
+ !historyUnavailablePresentation.loading ? (
1622
+ <AlertTriangle className="h-5 w-5 text-red-400" />
1623
+ ) : !streamReady || busy ? (
1624
+ <Loader2 className="h-5 w-5 animate-spin text-accent" />
1625
+ ) : (
1626
+ <Activity className="h-5 w-5 text-theme-text-tertiary" />
1627
+ )}
1628
+ <p className="mt-2 text-sm font-medium text-theme-text-secondary">
1629
+ {!streamReady
1630
+ ? historyUnavailablePresentation?.loading
1631
+ ? "Retrying saved activity"
1632
+ : historyUnavailablePresentation
1633
+ ? "Saved activity unavailable"
1634
+ : "Loading saved activity"
1635
+ : busy
1636
+ ? "Starting the investigation"
1637
+ : "No activity recorded"}
1638
+ </p>
1639
+ <p className="mt-1 text-xs text-theme-text-tertiary">
1640
+ {!streamReady
1641
+ ? historyUnavailablePresentation?.loading
1642
+ ? "Radar will continue when saved history is available."
1643
+ : historyUnavailablePresentation
1644
+ ? "Radar could not restore this run from saved history."
1645
+ : "Restoring this run from saved history."
1646
+ : busy
1647
+ ? "Reasoning and tool activity will appear here."
1648
+ : "No saved activity is available for this run."}
1649
+ </p>
1650
+ </div>
1651
+ ) : null}
1652
+ {turns.map((turn, index) => {
1653
+ const isLast = index === turns.length - 1;
1654
+ const verifiedHealthy =
1655
+ turn.verify &&
1656
+ turn.diagnosis?.healthy === true &&
1657
+ !currentAssessmentCoverageLimited &&
1658
+ !currentAssessmentEvidenceConflict;
1659
+ const canCheck =
1660
+ isLast &&
1661
+ (turn.status === "done" || turn.status === "error") &&
1662
+ !!turn.apply &&
1663
+ !readOnly;
1664
+ return (
1665
+ <Fragment key={index}>
1666
+ <TurnView
1667
+ turn={turn}
1668
+ turnIndex={index}
1669
+ evidenceStepIds={evidenceStepIdsByTurn.get(index)}
1670
+ onViewEvidence={viewEvidenceSource}
1671
+ sourceRevealRequest={activityRevealRequest}
1672
+ onViewExplanation={
1673
+ turn.explainAssessment
1674
+ ? () => viewExplanation(turn.explainAssessment!)
1675
+ : undefined
1676
+ }
1677
+ onCheckStatus={
1678
+ canCheck && !interactionsBlocked
1679
+ ? checkStatus
1680
+ : undefined
1681
+ }
1682
+ onRetryDiagnosis={
1683
+ isLast &&
1684
+ turn.status === "error" &&
1685
+ !turn.question &&
1686
+ !turn.apply &&
1687
+ !stale
1688
+ ? retryDiagnosis
1689
+ : undefined
1690
+ }
1691
+ hideConclusion={assessmentIndexes.includes(index)}
1692
+ />
1693
+ {showSplitWorkspace &&
1694
+ index === currentAssessmentIdx &&
1695
+ turn.timeline.length === 0 ? (
1696
+ <p className="hidden text-sm text-theme-text-tertiary @min-[1000px]/investigation:block">
1697
+ No reasoning or tool activity was recorded for this
1698
+ assessment. See Findings.
1699
+ </p>
1700
+ ) : null}
1701
+ {index === currentAssessmentIdx ? (
1702
+ <button
1703
+ type="button"
1704
+ onClick={() => selectPane("evidence")}
1705
+ className={`group flex w-full items-center gap-2.5 rounded-lg border px-3 py-2.5 text-left transition-colors ${
1706
+ verifiedHealthy
1707
+ ? "border-emerald-500/30 bg-emerald-500/5 hover:bg-emerald-500/10"
1708
+ : "border-accent/30 bg-accent/5 hover:bg-accent/10"
1709
+ } ${splitTabClass}`}
1710
+ >
1711
+ <span
1712
+ className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-full ${
1713
+ verifiedHealthy
1714
+ ? "bg-emerald-500/15 text-emerald-500"
1715
+ : "bg-accent/10 text-accent-text"
1716
+ }`}
1717
+ >
1718
+ {verifiedHealthy ? (
1719
+ <CheckCircle2 className="h-4 w-4" />
1720
+ ) : (
1721
+ <Files className="h-4 w-4" />
1722
+ )}
1723
+ </span>
1724
+ <span className="min-w-0 flex-1">
1725
+ <span className="block text-xs font-semibold text-theme-text-primary">
1726
+ {turn.verify
1727
+ ? "Verification complete"
1728
+ : "Assessment ready"}
1729
+ </span>
1730
+ <span className="block text-[11px] text-theme-text-tertiary">
1731
+ {currentKeyFindingCount > 0
1732
+ ? `${currentKeyFindingCount} ${currentKeyFindingCount === 1 ? "key finding" : "key findings"} ready to review`
1733
+ : "Findings compiled from Radar results"}
1734
+ </span>
1735
+ </span>
1736
+ <span className="inline-flex shrink-0 items-center gap-1 text-xs font-medium text-accent-text">
1737
+ View Findings
1738
+ <ArrowRight className="h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5" />
1739
+ </span>
1740
+ </button>
1741
+ ) : null}
1742
+ </Fragment>
1743
+ );
1744
+ })}
1745
+ {(actionError || displayedStatusCheckError) && (
1746
+ <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">
1747
+ <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-400" />
1748
+ <div className="min-w-0 flex-1">
1749
+ <span>{displayedStatusCheckError || actionError}</span>
1750
+ {displayedStatusCheckError ? (
1751
+ <button
1752
+ type="button"
1753
+ onClick={checkStatus}
1754
+ disabled={interactionsBlocked}
1755
+ className="mt-2 block rounded-md border border-red-500/30 px-2 py-1 text-xs font-medium text-theme-text-primary hover:bg-red-500/10 disabled:opacity-50"
1756
+ >
1757
+ {applyOutcomeUncertain &&
1758
+ !displayedVerificationError
1759
+ ? "Check current status"
1760
+ : "Check current status again"}
1761
+ </button>
1762
+ ) : null}
1763
+ </div>
1764
+ </div>
1765
+ )}
501
1766
  </div>
502
- <button
503
- onClick={retryDiagnosis}
504
- 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"
505
- >
506
- <Send className="h-3 w-3" />
507
- Re-run Diagnose
508
- </button>
509
- </div>
510
- )}
511
- <RunContextCard run={run} />
512
- {turns.map((t, i) => {
513
- const isLast = i === turns.length - 1;
514
- // Hosted runners are read-only — the server refuses apply turns.
515
- const canApply = i === lastRemediationIdx && !stale && !hosted;
516
- const canCheck = isLast && t.status === "done" && !!t.apply;
517
- return (
518
- <TurnView
519
- key={i}
520
- turn={t}
521
- synthLabel={isLast ? synth : null}
522
- reveal={isLast ? (reveal ?? "full") : "full"}
523
- onApply={canApply ? requestApply : undefined}
524
- onAsk={isLast && !busy && !stale ? askFollowup : undefined}
525
- onCheckStatus={canCheck ? checkStatus : undefined}
526
- onRetryDiagnosis={
527
- isLast &&
528
- t.status === "error" &&
529
- !t.question &&
530
- !t.apply &&
531
- !stale
532
- ? retryDiagnosis
533
- : undefined
534
- }
535
- hideVerdict={pinned && i === pinnedIdx}
536
- />
537
- );
538
- })}
539
- {actionError && (
540
- <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">
541
- <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-400" />
542
- <span>{actionError}</span>
543
1767
  </div>
544
- )}
545
- {/* A start that failed belongs to the investigation that never began,
546
- not to the one on screen. Unlabelled at the foot of a finished
547
- transcript — verdict directly above — it reads as "this
548
- investigation failed". It also outlives the click that caused it,
549
- and this is the only place it surfaces while a run is focused, so
550
- it needs a way out. */}
551
- {startError && (
552
- <div
553
- role="alert"
554
- 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"
1768
+ </div>
1769
+ {showJump ? (
1770
+ <button
1771
+ type="button"
1772
+ onClick={jumpToBottom}
1773
+ className="absolute bottom-3 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 hover:bg-theme-hover hover:text-theme-text-primary"
555
1774
  >
556
- <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-400" />
557
- <div className="min-w-0 flex-1">
558
- <div className="font-medium">
559
- Couldn&apos;t start a new investigation
560
- </div>
561
- <div className="text-theme-text-secondary">{startError}</div>
562
- </div>
1775
+ <ArrowDown className="h-3.5 w-3.5" />
1776
+ {busy ? "Jump to latest" : "Scroll to bottom"}
1777
+ </button>
1778
+ ) : null}
1779
+ </div>
1780
+ {composer}
1781
+ </section>
1782
+ <section
1783
+ id={findingsPaneId}
1784
+ aria-label="Findings: current assessment, Radar evidence, and next steps"
1785
+ aria-busy={
1786
+ busy || requestPending || verificationPending || rebuildingReplay
1787
+ }
1788
+ className={`${
1789
+ narrowPane === "evidence" ? "flex" : "hidden"
1790
+ } relative min-h-0 min-w-0 flex-col ${splitPaneClass}`}
1791
+ >
1792
+ <div
1793
+ className={`hidden items-center justify-between gap-3 border-b border-theme-border/60 px-3 py-2 ${splitPaneClass}`}
1794
+ >
1795
+ <div className="flex min-w-0 items-center gap-2">
1796
+ <Files className="h-4 w-4 shrink-0 text-theme-text-tertiary" />
1797
+ <div className="min-w-0">
1798
+ <h2 className="truncate text-sm font-semibold text-theme-text-primary">
1799
+ Findings
1800
+ </h2>
1801
+ <p className="truncate text-[11px] text-theme-text-tertiary">
1802
+ Current assessment, evidence, and next steps
1803
+ </p>
1804
+ </div>
1805
+ </div>
1806
+ <div className="flex shrink-0 items-center gap-2">
1807
+ {evidenceUpdateAvailable ? (
563
1808
  <button
564
- onClick={dismissError}
565
- className="shrink-0 rounded px-1.5 py-0.5 text-xs text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary"
1809
+ type="button"
1810
+ onClick={revealLatestEvidenceUpdate}
1811
+ className="inline-flex items-center gap-1 rounded-full border border-accent/30 bg-accent/5 px-2 py-1 text-[11px] font-medium text-accent-text hover:bg-accent/10"
566
1812
  >
567
- Dismiss
1813
+ <Files className="h-3 w-3" />
1814
+ See new evidence
568
1815
  </button>
569
- </div>
570
- )}
1816
+ ) : null}
1817
+ </div>
571
1818
  </div>
572
- </div>
573
- </div>
574
- {pinned && (
575
- <aside
576
- className={`w-[400px] shrink-0 overflow-y-auto border-l border-theme-border px-4 py-3 ${busy ? "opacity-70" : ""}`}
577
- >
578
- <div className="mb-1 text-[11px] font-medium uppercase tracking-wide text-theme-text-tertiary">
579
- {busy ? "Verdict · revising…" : "Verdict"}
1819
+ {evidenceUpdateAvailable ? (
1820
+ <div className={`absolute right-4 top-2 z-20 ${splitTabClass}`}>
1821
+ <button
1822
+ type="button"
1823
+ onClick={revealLatestEvidenceUpdate}
1824
+ className="inline-flex items-center gap-1 rounded-full border border-accent/30 bg-theme-elevated px-2 py-1 text-[11px] font-medium text-accent-text shadow-theme-sm hover:bg-theme-hover"
1825
+ >
1826
+ <Files className="h-3 w-3" />
1827
+ See new evidence
1828
+ </button>
1829
+ </div>
1830
+ ) : null}
1831
+ <div
1832
+ ref={evidenceScrollRef}
1833
+ data-investigation-findings-scroll
1834
+ onScroll={(event) => {
1835
+ if (event.currentTarget.offsetParent === null) return;
1836
+ evidenceScrollTopRef.current = event.currentTarget.scrollTop;
1837
+ if (event.currentTarget.scrollTop <= 40) {
1838
+ setEvidenceUpdateAvailable(false);
1839
+ latestEvidenceUpdateSourceIdRef.current = undefined;
1840
+ }
1841
+ }}
1842
+ className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 py-3 [overflow-anchor:none] [scrollbar-gutter:stable]"
1843
+ >
1844
+ <div ref={evidenceContentRef} className="min-w-0 space-y-6">
1845
+ {rebuildingReplay ? (
1846
+ <div className="flex min-h-28 flex-col items-center justify-center rounded-lg border border-dashed border-theme-border px-4 text-center">
1847
+ {historyUnavailablePresentation &&
1848
+ !historyUnavailablePresentation.loading ? (
1849
+ <AlertTriangle className="h-5 w-5 text-red-400" />
1850
+ ) : (
1851
+ <Loader2 className="h-5 w-5 animate-spin text-accent" />
1852
+ )}
1853
+ <p className="mt-2 text-sm font-medium text-theme-text-secondary">
1854
+ {historyUnavailablePresentation?.loading
1855
+ ? "Retrying saved evidence"
1856
+ : historyUnavailablePresentation
1857
+ ? "Saved evidence unavailable"
1858
+ : "Loading saved evidence"}
1859
+ </p>
1860
+ <p className="mt-1 text-xs text-theme-text-tertiary">
1861
+ {historyUnavailablePresentation?.loading
1862
+ ? "Radar will continue when saved history is available."
1863
+ : historyUnavailablePresentation
1864
+ ? "Radar could not restore evidence from saved history."
1865
+ : "Restoring the assessment and evidence from saved history."}
1866
+ </p>
1867
+ </div>
1868
+ ) : (
1869
+ <>
1870
+ <section
1871
+ aria-labelledby={`${workspaceId}-assessment-heading`}
1872
+ className="investigation-assessment rounded-xl border p-4"
1873
+ >
1874
+ <div className="flex flex-wrap items-center gap-1.5">
1875
+ <h2
1876
+ id={`${workspaceId}-assessment-heading`}
1877
+ className="text-lg font-semibold text-theme-text-primary"
1878
+ >
1879
+ {assessmentNeedsCurrentStateVerification
1880
+ ? "Assessment before apply"
1881
+ : hasEvidenceCollectedAfterAssessment
1882
+ ? "Earlier assessment"
1883
+ : !currentAssessment
1884
+ ? "Assessment"
1885
+ : currentAssessment.verify
1886
+ ? "Verification result"
1887
+ : currentAssessmentIdx ===
1888
+ initialAssessmentIdx &&
1889
+ hasMultipleAssessments
1890
+ ? "Initial assessment"
1891
+ : "Assessment"}
1892
+ </h2>
1893
+ {assessmentNeedsCurrentStateVerification ? (
1894
+ <Badge severity="warning" size="sm">
1895
+ Current state unverified
1896
+ </Badge>
1897
+ ) : null}
1898
+ {hasEvidenceCollectedAfterAssessment &&
1899
+ !assessmentNeedsCurrentStateVerification ? (
1900
+ <Badge severity="info" size="sm">
1901
+ Newer evidence below
1902
+ </Badge>
1903
+ ) : null}
1904
+ {verificationRunning || verificationPending ? (
1905
+ <Badge severity="info" size="sm">
1906
+ Verifying…
1907
+ </Badge>
1908
+ ) : null}
1909
+ </div>
1910
+ {assessmentNeedsCurrentStateVerification ? (
1911
+ <p className="mt-0.5 text-xs text-theme-text-tertiary">
1912
+ {verificationRunning || verificationPending
1913
+ ? "This assessment predates the apply attempt. Radar is checking the current state now."
1914
+ : "This assessment predates the apply attempt; cluster state after it has not been verified."}
1915
+ </p>
1916
+ ) : hasEvidenceCollectedAfterAssessment ? (
1917
+ <p className="mt-0.5 text-xs text-theme-text-tertiary">
1918
+ Some evidence below was collected after this assessment.
1919
+ Validate the conclusion against it before acting.
1920
+ </p>
1921
+ ) : null}
1922
+ {currentAssessment?.diagnosis ? (
1923
+ <ResultCard
1924
+ key={
1925
+ currentAssessment.resultSequence ??
1926
+ currentAssessmentIdx
1927
+ }
1928
+ diagnosis={currentAssessment.diagnosis}
1929
+ assessmentSources={
1930
+ rootCauseEvidenceResolution?.links.length ? (
1931
+ <AssessmentSources
1932
+ resolution={rootCauseEvidenceResolution}
1933
+ onViewSource={viewActivitySource}
1934
+ />
1935
+ ) : undefined
1936
+ }
1937
+ assessmentAction={
1938
+ hasNextSteps ? (
1939
+ <button
1940
+ type="button"
1941
+ onClick={() => {
1942
+ const section = nextStepsRef.current;
1943
+ const scroller = evidenceScrollRef.current;
1944
+ if (!section || !scroller) return;
1945
+ section.focus({ preventScroll: true });
1946
+ scroller.scrollTo({
1947
+ top:
1948
+ scroller.scrollTop +
1949
+ section.getBoundingClientRect().top -
1950
+ scroller.getBoundingClientRect().top -
1951
+ 12,
1952
+ behavior: prefersReducedMotion()
1953
+ ? "auto"
1954
+ : "smooth",
1955
+ });
1956
+ }}
1957
+ className="ml-auto rounded-md px-2 py-1 text-xs font-medium text-accent-text hover:bg-theme-hover"
1958
+ >
1959
+ {earlierPlan
1960
+ ? "Earlier proposed steps ↓"
1961
+ : "Next steps ↓"}
1962
+ </button>
1963
+ ) : null
1964
+ }
1965
+ explanation={explanationFor(currentAssessment)}
1966
+ section="conclusion"
1967
+ animate={currentAssessment.animateResult !== false}
1968
+ showDisclaimer={false}
1969
+ coverageLimited={currentAssessmentCoverageLimited}
1970
+ evidenceConflict={currentAssessmentEvidenceConflict}
1971
+ />
1972
+ ) : (
1973
+ <div className="mt-2 flex items-center gap-2 rounded-md bg-theme-surface/60 px-2.5 py-2 text-xs text-theme-text-tertiary">
1974
+ {busy || requestPending ? (
1975
+ <span
1976
+ className="h-1.5 w-1.5 shrink-0 animate-pulse rounded-full bg-accent"
1977
+ aria-hidden
1978
+ />
1979
+ ) : (
1980
+ <Activity
1981
+ className="h-3.5 w-3.5 shrink-0"
1982
+ aria-hidden
1983
+ />
1984
+ )}
1985
+ <span>
1986
+ {busy || requestPending
1987
+ ? "Forming an assessment as evidence arrives…"
1988
+ : "The agent did not provide a final assessment."}
1989
+ </span>
1990
+ </div>
1991
+ )}
1992
+ {displayedStatusCheckError ? (
1993
+ <div className="mt-2 flex items-center gap-2 rounded-lg border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-theme-text-secondary">
1994
+ <AlertTriangle className="h-3.5 w-3.5 shrink-0 text-red-400" />
1995
+ <span className="min-w-0 flex-1">
1996
+ {applyOutcomeUncertain && !displayedVerificationError
1997
+ ? displayedStatusCheckError
1998
+ : `Verification did not complete: ${displayedStatusCheckError}`}
1999
+ </span>
2000
+ <button
2001
+ type="button"
2002
+ onClick={checkStatus}
2003
+ disabled={interactionsBlocked}
2004
+ className="shrink-0 rounded-md border border-theme-border px-2 py-1 font-medium text-theme-text-primary hover:bg-theme-hover disabled:opacity-50"
2005
+ >
2006
+ {applyOutcomeUncertain && !displayedVerificationError
2007
+ ? "Check current status"
2008
+ : "Check current status again"}
2009
+ </button>
2010
+ </div>
2011
+ ) : null}
2012
+ </section>
2013
+
2014
+ <AssessmentHistory
2015
+ assessments={assessmentIndexes
2016
+ .filter(
2017
+ (index) =>
2018
+ index !== currentAssessmentIdx &&
2019
+ (index === initialAssessmentIdx ||
2020
+ (turns[index].diagnosis?.remediation?.length ?? 0) >
2021
+ 0 ||
2022
+ turns.some(
2023
+ (turn) =>
2024
+ turn.explainAssessment ===
2025
+ turns[index].resultSequence,
2026
+ )),
2027
+ )
2028
+ .map((index) => ({
2029
+ sequence: turns[index].resultSequence ?? index,
2030
+ initial: index === initialAssessmentIdx,
2031
+ diagnosis: turns[index].diagnosis!,
2032
+ explanation: explanationFor(turns[index]),
2033
+ sources: (() => {
2034
+ const resolution =
2035
+ resolveInvestigationRootCauseEvidence(
2036
+ projection,
2037
+ turns[index].diagnosis!.rootCauseEvidence,
2038
+ index,
2039
+ );
2040
+ return resolution.links.length ? (
2041
+ <AssessmentSources
2042
+ resolution={resolution}
2043
+ onViewSource={viewActivitySource}
2044
+ />
2045
+ ) : undefined;
2046
+ })(),
2047
+ }))}
2048
+ />
2049
+
2050
+ <InvestigationEvidencePane
2051
+ projection={projection}
2052
+ rootCauseEvidence={rootCauseEvidenceResolution}
2053
+ collecting={
2054
+ explanationRequest?.status !== "running" &&
2055
+ (requestPending ||
2056
+ (busy &&
2057
+ (!lastTurn?.explainAssessment ||
2058
+ lastTurn.timeline.some(
2059
+ (item) => item.kind === "tool",
2060
+ ))))
2061
+ }
2062
+ animateGroupIds={animateEvidenceGroupIds}
2063
+ onViewSource={viewActivitySource}
2064
+ onViewActivity={viewActivity}
2065
+ onOpenResource={stale ? undefined : onOpenResource}
2066
+ revealRequest={evidenceRevealRequest}
2067
+ onRevealReady={revealEvidenceSource}
2068
+ afterEvidence={
2069
+ hasNextSteps && currentAssessment?.diagnosis ? (
2070
+ <section
2071
+ ref={nextStepsRef}
2072
+ tabIndex={-1}
2073
+ aria-labelledby={`${workspaceId}-next-steps`}
2074
+ className="investigation-next-steps rounded-xl border p-4 outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
2075
+ >
2076
+ <h2
2077
+ id={`${workspaceId}-next-steps`}
2078
+ className="text-lg font-semibold text-theme-text-primary"
2079
+ >
2080
+ {earlierPlan
2081
+ ? "Earlier proposed steps"
2082
+ : "Next steps"}
2083
+ </h2>
2084
+ <ResultCard
2085
+ diagnosis={currentAssessment.diagnosis}
2086
+ section="actions"
2087
+ compactActions
2088
+ actionNotice={
2089
+ earlierPlan
2090
+ ? assessmentNeedsCurrentStateVerification
2091
+ ? "Proposed before the apply attempt. Current state has not been verified."
2092
+ : "Proposed before the latest evidence. Reassess before applying."
2093
+ : undefined
2094
+ }
2095
+ onApply={
2096
+ canOfferInvestigationApply({
2097
+ currentAssessmentIdx,
2098
+ lastRemediationIdx,
2099
+ lastApplyAttemptIdx,
2100
+ localApplyAttemptAssessmentIdx,
2101
+ interactionsBlocked,
2102
+ hosted,
2103
+ hasNewerEvidence:
2104
+ hasEvidenceCollectedAfterAssessment,
2105
+ })
2106
+ ? requestApply
2107
+ : undefined
2108
+ }
2109
+ animate={currentAssessment.animateResult !== false}
2110
+ showDisclaimer={false}
2111
+ />
2112
+ </section>
2113
+ ) : undefined
2114
+ }
2115
+ />
2116
+ </>
2117
+ )}
2118
+ </div>
580
2119
  </div>
581
- <ResultCard
582
- diagnosis={turns[pinnedIdx].diagnosis!}
583
- onApply={
584
- pinnedIdx === lastRemediationIdx && !stale && !hosted
585
- ? requestApply
586
- : undefined
587
- }
588
- onAsk={!busy && !stale ? askFollowup : undefined}
589
- reveal="full"
590
- />
591
- </aside>
592
- )}
2120
+ </section>
593
2121
  </div>
594
2122
 
595
- {showJump && (
596
- <button
597
- onClick={jumpToBottom}
598
- 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"
599
- >
600
- <ArrowDown className="h-3.5 w-3.5" />
601
- {busy ? "Jump to latest" : "Scroll to bottom"}
602
- </button>
603
- )}
604
-
605
2123
  <ApplyDialog
606
2124
  open={confirmApply}
607
2125
  onClose={() => setConfirmApply(false)}
608
2126
  onConfirm={runApply}
609
2127
  agentLabel={agentLabel}
610
- resourceLabel={`${kind} ${namespace ? `${namespace}/` : ""}${name}`}
2128
+ resourceLabel={formatInvestigationTarget(run)}
2129
+ context={run.context}
611
2130
  fix={pendingFix}
612
2131
  managedBy={run.managedBy}
613
2132
  confidence={turns[lastRemediationIdx]?.diagnosis?.confidence}
614
2133
  />
2134
+ </div>
2135
+ );
2136
+ }
615
2137
 
616
- <div
617
- className={`border-t border-theme-border px-3 py-2.5 ${maximized ? "[&>*]:mx-auto [&>*]:max-w-3xl" : ""}`}
2138
+ function AssessmentHistory({
2139
+ assessments,
2140
+ }: {
2141
+ assessments: {
2142
+ sequence: number;
2143
+ diagnosis: Diagnosis;
2144
+ explanation?: AssessmentExplanation;
2145
+ initial: boolean;
2146
+ sources?: ReactNode;
2147
+ }[];
2148
+ }) {
2149
+ const reveal = useDisclosureReveal<HTMLDivElement>();
2150
+ const { revealAfterToggle } = reveal;
2151
+ const [open, setOpen] = useState(false);
2152
+ const regionId = useId();
2153
+ const openRequest = assessments.find((item) => item.explanation?.openRequest)
2154
+ ?.explanation?.openRequest;
2155
+ useEffect(() => {
2156
+ if (openRequest) {
2157
+ setOpen(true);
2158
+ revealAfterToggle(true);
2159
+ }
2160
+ }, [openRequest, revealAfterToggle]);
2161
+ if (assessments.length === 0) return null;
2162
+ return (
2163
+ <div data-investigation-assessment-history className="overflow-hidden">
2164
+ <button
2165
+ type="button"
2166
+ aria-expanded={open}
2167
+ aria-controls={regionId}
2168
+ onClick={() => {
2169
+ setOpen(!open);
2170
+ reveal.revealAfterToggle(!open);
2171
+ }}
2172
+ className="flex items-center gap-2 rounded-md px-2 py-1 text-left text-xs text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-secondary"
618
2173
  >
619
- {busy ? (
620
- <button
621
- onClick={stop}
622
- className="w-full rounded-lg border border-theme-border py-1.5 text-sm text-theme-text-secondary hover:bg-theme-hover"
623
- >
624
- Stop
625
- </button>
626
- ) : (
627
- <div className="flex items-end gap-2">
628
- <textarea
629
- value={input}
630
- onChange={(e) => setInput(e.target.value)}
631
- onKeyDown={(e) => {
632
- if (e.key === "Enter" && !e.shiftKey) {
633
- e.preventDefault();
634
- submitFollowup();
635
- }
636
- }}
637
- rows={1}
638
- disabled={stale}
639
- placeholder={
640
- stale
641
- ? "Cluster changed — re-run Diagnose"
642
- : "Ask a follow-up or refine…"
643
- }
644
- 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"
645
- />
646
- <button
647
- onClick={submitFollowup}
648
- disabled={!input.trim() || stale}
649
- className="shrink-0 rounded-lg btn-brand p-2 disabled:opacity-40"
650
- aria-label="Send follow-up"
651
- >
652
- <Send className="h-4 w-4" />
653
- </button>
2174
+ <CollapseChevron open={open} className="h-3.5 w-3.5" />
2175
+ <span className="font-medium">
2176
+ Previous assessments · {assessments.length}
2177
+ </span>
2178
+ </button>
2179
+ <div id={regionId} ref={reveal.elementRef}>
2180
+ <Collapse open={open}>
2181
+ <div className="mt-2 space-y-4 border-l border-theme-border pl-3 pb-2">
2182
+ {assessments.map(
2183
+ (
2184
+ { sequence, diagnosis, explanation, initial, sources },
2185
+ index,
2186
+ ) => (
2187
+ <section
2188
+ key={sequence}
2189
+ aria-label={
2190
+ initial
2191
+ ? "Initial assessment"
2192
+ : `Earlier assessment ${index + 1}`
2193
+ }
2194
+ >
2195
+ <h3 className="text-sm font-medium text-theme-text-secondary">
2196
+ {initial
2197
+ ? "Initial assessment"
2198
+ : `Earlier assessment ${index + 1}`}
2199
+ </h3>
2200
+ <ResultCard
2201
+ diagnosis={diagnosis}
2202
+ explanation={explanation}
2203
+ assessmentSources={sources}
2204
+ section="conclusion"
2205
+ showDisclaimer={false}
2206
+ />
2207
+ {(diagnosis.remediation?.length ?? 0) > 0 && (
2208
+ <section
2209
+ aria-label="Earlier proposed steps"
2210
+ className="mt-3 border-t border-theme-border/60 pt-3"
2211
+ >
2212
+ <h3 className="text-sm font-medium text-theme-text-primary">
2213
+ Earlier proposed steps
2214
+ </h3>
2215
+ <ResultCard
2216
+ diagnosis={diagnosis}
2217
+ section="actions"
2218
+ compactActions
2219
+ actionNotice="From an earlier assessment, not the current recommendation."
2220
+ showDisclaimer={false}
2221
+ />
2222
+ </section>
2223
+ )}
2224
+ </section>
2225
+ ),
2226
+ )}
654
2227
  </div>
655
- )}
2228
+ </Collapse>
656
2229
  </div>
657
2230
  </div>
658
2231
  );