@skyhook-io/radar-app 1.13.1 → 1.13.3

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