@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.
- package/package.json +2 -2
- package/src/App.tsx +19 -1
- package/src/RadarApp.tsx +2 -2
- package/src/api/diagnose.test.ts +268 -0
- package/src/api/diagnose.ts +74 -9
- package/src/components/diagnose/AISettings.tsx +1 -1
- package/src/components/diagnose/AgentSetupNotice.tsx +5 -5
- package/src/components/diagnose/ApplyDialog.test.tsx +72 -0
- package/src/components/diagnose/DiagnoseContext.tsx +12 -17
- package/src/components/diagnose/DiagnoseSurface.test.tsx +211 -16
- package/src/components/diagnose/DiagnoseSurface.tsx +464 -133
- package/src/components/diagnose/Home.test.tsx +293 -0
- package/src/components/diagnose/Home.tsx +289 -119
- package/src/components/diagnose/InvestigationEvidencePane.test.tsx +2170 -0
- package/src/components/diagnose/InvestigationEvidencePane.tsx +2253 -0
- package/src/components/diagnose/InvestigationResourceEvidence.test.tsx +257 -0
- package/src/components/diagnose/InvestigationResourceEvidence.tsx +214 -0
- package/src/components/diagnose/InvestigationView.test.ts +17 -0
- package/src/components/diagnose/InvestigationView.tsx +1913 -395
- package/src/components/diagnose/LocalDiagnoseAction.tsx +42 -25
- package/src/components/diagnose/agentCatalog.ts +1 -1
- package/src/components/diagnose/diagnoseEvidenceTypes.ts +151 -0
- package/src/components/diagnose/investigationEvidence.test.ts +3109 -0
- package/src/components/diagnose/investigationEvidence.ts +3492 -0
- package/src/components/diagnose/investigationEvidencePresentation.test.ts +447 -0
- package/src/components/diagnose/investigationEvidencePresentation.ts +167 -0
- package/src/components/diagnose/investigationExplanation.test.ts +92 -0
- package/src/components/diagnose/investigationExplanation.ts +29 -0
- package/src/components/diagnose/investigationResourceEvidenceModel.ts +322 -0
- package/src/components/diagnose/investigationSourceFocus.test.ts +143 -0
- package/src/components/diagnose/investigationSourceFocus.ts +98 -0
- package/src/components/diagnose/investigationState.test.ts +695 -0
- package/src/components/diagnose/investigationState.ts +451 -0
- package/src/components/diagnose/parts.test.tsx +864 -3
- package/src/components/diagnose/parts.tsx +1337 -541
- package/src/components/diagnose/target.test.ts +39 -0
- package/src/components/diagnose/target.ts +36 -0
- package/src/components/diagnose/useDisclosureReveal.ts +117 -0
- package/src/components/home/MCPSetupDialog.tsx +2 -2
- package/src/components/home/mcpToolCatalog.test.ts +22 -0
- package/src/components/home/mcpToolCatalog.ts +3 -2
- package/src/components/issues/IssuesPane.tsx +5 -1
- package/src/components/settings/SettingsDialog.tsx +11 -13
- package/src/components/workload/WorkloadView.tsx +1 -1
- package/src/context/DiagnoseCustomization.tsx +11 -8
- package/src/index.css +63 -79
- 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 {
|
|
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
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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'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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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 {
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
|
|
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
|
-
|
|
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) => ({
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
if (ev.apply)
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
278
|
-
|
|
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
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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 "
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
373
|
-
|
|
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
|
-
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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),
|
|
408
|
-
//
|
|
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,
|
|
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({
|
|
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 ||
|
|
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
|
-
|
|
444
|
-
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
const askFollowup = (q: string) => {
|
|
452
|
-
if (busy || !canContinue) return;
|
|
735
|
+
const askExplanation = (sequence: number) => {
|
|
736
|
+
if (interactionsBlocked) return;
|
|
453
737
|
setActionError(null);
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
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
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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)
|
|
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
|
-
//
|
|
495
|
-
//
|
|
496
|
-
|
|
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 (
|
|
508
|
-
|
|
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
|
|
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
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
-
|
|
556
|
-
|
|
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
|
-
|
|
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
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
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
|
-
|
|
616
|
-
|
|
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
|
-
|
|
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
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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="
|
|
635
|
-
|
|
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
|
-
<
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
:
|
|
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
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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={
|
|
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
|
-
|
|
673
|
-
|
|
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
|
-
{
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
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
|
);
|