@skyhook-io/radar-app 1.14.1 → 1.14.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/api/client.ts +30 -0
- package/src/components/CloudConnectFlow.tsx +21 -7
- package/src/components/CloudFunnelButton.tsx +85 -8
- package/src/components/cloudConnectHandoff.test.ts +10 -1
- package/src/components/cloudConnectHandoff.ts +15 -1
- package/src/components/diagnose/AIMarkdown.tsx +87 -0
- package/src/components/diagnose/ActivityTurn.tsx +1244 -0
- package/src/components/diagnose/AgentControls.tsx +603 -0
- package/src/components/diagnose/AnalysisStory.test.tsx +31 -25
- package/src/components/diagnose/AnalysisStory.tsx +13 -18
- package/src/components/diagnose/ApplyDialog.tsx +289 -0
- package/src/components/diagnose/AssessmentCard.tsx +1560 -0
- package/src/components/diagnose/DiagnoseSurface.test.tsx +9 -9
- package/src/components/diagnose/DiagnoseSurface.tsx +5 -1
- package/src/components/diagnose/EvidenceCard.tsx +611 -0
- package/src/components/diagnose/Home.test.tsx +1 -3
- package/src/components/diagnose/Home.tsx +2 -1
- package/src/components/diagnose/InvestigationEvidencePane.story.test.tsx +6 -8
- package/src/components/diagnose/InvestigationEvidencePane.test.tsx +5 -3
- package/src/components/diagnose/InvestigationEvidencePane.tsx +31 -3445
- package/src/components/diagnose/InvestigationEvidenceSections.tsx +523 -0
- package/src/components/diagnose/InvestigationView.tsx +11 -28
- package/src/components/diagnose/StoryExcerpt.tsx +118 -0
- package/src/components/diagnose/assessmentCopy.ts +169 -0
- package/src/components/diagnose/investigationCase.test.tsx +631 -2
- package/src/components/diagnose/investigationCase.ts +132 -24
- package/src/components/diagnose/investigationEvidence/adapters/coverage.test.tsx +660 -0
- package/src/components/diagnose/investigationEvidence/adapters/{helm.test.ts → helm.test.tsx} +63 -0
- package/src/components/diagnose/investigationEvidence/adapters/helm.ts +70 -1
- package/src/components/diagnose/investigationEvidence/adapters/listings.ts +134 -0
- package/src/components/diagnose/investigationEvidence/adapters/{permissions.test.ts → permissions.test.tsx} +50 -0
- package/src/components/diagnose/investigationEvidence/adapters/permissions.ts +18 -0
- package/src/components/diagnose/investigationEvidence/adapters/posture.ts +272 -0
- package/src/components/diagnose/investigationEvidence/adapters/prometheus.ts +49 -0
- package/src/components/diagnose/investigationEvidence/adapters/rankings.ts +147 -0
- package/src/components/diagnose/investigationEvidence/adapters/resource.ts +20 -10
- package/src/components/diagnose/investigationEvidence/bodies/index.tsx +127 -0
- package/src/components/diagnose/investigationEvidence/bodies/inventory.tsx +158 -0
- package/src/components/diagnose/investigationEvidence/bodies/metrics.tsx +373 -0
- package/src/components/diagnose/investigationEvidence/bodies/network.tsx +274 -0
- package/src/components/diagnose/investigationEvidence/bodies/platform.tsx +475 -0
- package/src/components/diagnose/investigationEvidence/bodies/posture.tsx +60 -0
- package/src/components/diagnose/investigationEvidence/bodies/ranking.tsx +76 -0
- package/src/components/diagnose/investigationEvidence/bodies/streams.tsx +212 -0
- package/src/components/diagnose/investigationEvidence/bodies/workload.tsx +335 -0
- package/src/components/diagnose/investigationEvidence/builder.ts +15 -0
- package/src/components/diagnose/investigationEvidence/cardParts.tsx +312 -0
- package/src/components/diagnose/investigationEvidence/excerpt.test.ts +105 -0
- package/src/components/diagnose/investigationEvidence/excerpt.ts +92 -0
- package/src/components/diagnose/investigationEvidence/identity.test.ts +5 -7
- package/src/components/diagnose/investigationEvidence/index.ts +1 -1
- package/src/components/diagnose/investigationEvidence/navigation.tsx +26 -0
- package/src/components/diagnose/investigationEvidence/observations.ts +11 -0
- package/src/components/diagnose/investigationEvidence/projection.test.ts +1 -1
- package/src/components/diagnose/investigationEvidence/types.ts +97 -2
- package/src/components/diagnose/investigationEvidenceKinds.ts +22 -0
- package/src/components/diagnose/investigationEvidencePartition.ts +296 -0
- package/src/components/diagnose/parts.test.tsx +3 -1
- package/src/components/diagnose/parts.tsx +26 -3904
- package/src/components/diagnose/target.ts +9 -3
- package/src/components/diagnose/toolCallLabel.test.ts +47 -1
- package/src/components/diagnose/toolCallLabel.ts +41 -2
- package/src/components/helm/HelmReleaseDrawer.tsx +1 -1
- package/src/components/home/MCPSetupDialog.tsx +1 -1
- package/src/components/resource/PrometheusChartsGrid.tsx +1 -1
- package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
- package/src/components/resource/WorkloadMetricsSection.tsx +1 -1
- package/src/components/resources/PodFilePreview.tsx +1 -1
- package/src/components/ui/CommandPalette.tsx +12 -8
- package/src/components/ui/DiagnosticsOverlay.tsx +7 -5
- package/src/components/ui/Omnibar.tsx +33 -10
- package/src/components/ui/ShortcutHelpOverlay.tsx +8 -5
- package/src/components/ui/Disclosure.tsx +0 -47
|
@@ -0,0 +1,1244 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useEffect,
|
|
3
|
+
useId,
|
|
4
|
+
useMemo,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
} from "react";
|
|
9
|
+
import { TW_EASE_UI } from "../../utils/animation";
|
|
10
|
+
import {
|
|
11
|
+
Loader2,
|
|
12
|
+
CheckCircle2,
|
|
13
|
+
AlertTriangle,
|
|
14
|
+
ShieldCheck,
|
|
15
|
+
Sparkles,
|
|
16
|
+
RefreshCw,
|
|
17
|
+
Maximize2,
|
|
18
|
+
HelpCircle,
|
|
19
|
+
} from "lucide-react";
|
|
20
|
+
import { stringify as toYaml } from "yaml";
|
|
21
|
+
import { codeToHtml } from "shiki";
|
|
22
|
+
import { DialogPortal } from "@skyhook-io/k8s-ui/components/ui/DialogPortal";
|
|
23
|
+
import { useTheme } from "../../context/ThemeContext";
|
|
24
|
+
import type {
|
|
25
|
+
Diagnosis,
|
|
26
|
+
DiagnoseStep,
|
|
27
|
+
ApplyMutationOutcome,
|
|
28
|
+
DiagnoseStreamEvent,
|
|
29
|
+
MCPServerStatus,
|
|
30
|
+
} from "../../api/diagnose";
|
|
31
|
+
import { Collapse, CollapseChevron } from "@skyhook-io/k8s-ui";
|
|
32
|
+
import { Tooltip } from "../ui/Tooltip";
|
|
33
|
+
import type { InvestigationSourceExcerpt } from "./investigationSourceFocus";
|
|
34
|
+
import {
|
|
35
|
+
highlightRelatedEvidence,
|
|
36
|
+
locateSourceExcerpt,
|
|
37
|
+
} from "./investigationSourceFocus";
|
|
38
|
+
import {
|
|
39
|
+
investigationActivitySourceDomId,
|
|
40
|
+
investigationEvidenceSourceId,
|
|
41
|
+
} from "./investigationEvidence";
|
|
42
|
+
import { useDisclosureReveal } from "./useDisclosureReveal";
|
|
43
|
+
import { Segmented } from "./AgentControls";
|
|
44
|
+
import { ApplyOutcomeCard } from "./ApplyDialog";
|
|
45
|
+
import {
|
|
46
|
+
type AssessmentExplanation,
|
|
47
|
+
ResultCard,
|
|
48
|
+
WorkingNotes,
|
|
49
|
+
} from "./AssessmentCard";
|
|
50
|
+
import { AIMarkdown, CopyButton } from "./AIMarkdown";
|
|
51
|
+
import { prettyTool } from "./toolCallLabel";
|
|
52
|
+
|
|
53
|
+
// Turn is one round of the conversation: the initial investigation (no question)
|
|
54
|
+
// or a follow-up, each with its own transcript + result.
|
|
55
|
+
export type Turn = {
|
|
56
|
+
resultSequence?: number;
|
|
57
|
+
explainAssessment?: number;
|
|
58
|
+
question?: string;
|
|
59
|
+
actor?: string;
|
|
60
|
+
timeline: TimelineItem[];
|
|
61
|
+
diagnosis: Diagnosis | null;
|
|
62
|
+
error: string | null;
|
|
63
|
+
status: "running" | "done" | "error";
|
|
64
|
+
// apply turns execute the recommended fix (write tools) — they report an
|
|
65
|
+
// outcome, not a root cause, so the UI frames them differently.
|
|
66
|
+
apply?: boolean;
|
|
67
|
+
// Set from the apply turn's terminal stream event. Green success is reserved
|
|
68
|
+
// for an explicit producer-confirmed mutation.
|
|
69
|
+
applyOutcome?: ApplyMutationOutcome;
|
|
70
|
+
// Verification turns are structurally a fresh health assessment, even though
|
|
71
|
+
// they carry a question. Keeping the bit explicit prevents the UI from
|
|
72
|
+
// misclassifying them as ordinary conversational follow-ups on replay.
|
|
73
|
+
verify?: boolean;
|
|
74
|
+
// Set from the replay/live boundary when the terminal event arrives. Historical
|
|
75
|
+
// conclusions render immediately; conclusions observed live enter smoothly.
|
|
76
|
+
animateResult?: boolean;
|
|
77
|
+
// The latest startup phase the server reported before the agent's first
|
|
78
|
+
// message. It drives the pending status line only; it is never a transcript item.
|
|
79
|
+
startup?: StartupSignal;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export type StartupSignal = {
|
|
83
|
+
phase: "investigating" | "connected" | "ready";
|
|
84
|
+
model?: string;
|
|
85
|
+
toolCount?: number;
|
|
86
|
+
mcpServers?: MCPServerStatus[];
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const STARTUP_PHASE_RANK: Record<StartupSignal["phase"], number> = {
|
|
90
|
+
investigating: 0,
|
|
91
|
+
connected: 1,
|
|
92
|
+
ready: 2,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// Folds a phase event into the turn's startup signal. The handshake and the
|
|
96
|
+
// CLI's init line are reported by different goroutines, so a later event can
|
|
97
|
+
// name an earlier phase; the furthest phase wins and the init facts are kept.
|
|
98
|
+
export function mergeStartupSignal(
|
|
99
|
+
prev: StartupSignal | undefined,
|
|
100
|
+
event: Pick<
|
|
101
|
+
DiagnoseStreamEvent,
|
|
102
|
+
"phase" | "model" | "toolCount" | "mcpServers"
|
|
103
|
+
>,
|
|
104
|
+
): StartupSignal | undefined {
|
|
105
|
+
const phase = event.phase;
|
|
106
|
+
if (phase !== "investigating" && phase !== "connected" && phase !== "ready")
|
|
107
|
+
return prev;
|
|
108
|
+
const facts =
|
|
109
|
+
phase === "ready"
|
|
110
|
+
? {
|
|
111
|
+
model: event.model,
|
|
112
|
+
toolCount: event.toolCount,
|
|
113
|
+
mcpServers: event.mcpServers,
|
|
114
|
+
}
|
|
115
|
+
: {};
|
|
116
|
+
if (prev && STARTUP_PHASE_RANK[prev.phase] >= STARTUP_PHASE_RANK[phase]) {
|
|
117
|
+
return phase === "ready" ? { ...prev, ...facts } : prev;
|
|
118
|
+
}
|
|
119
|
+
return { ...prev, ...facts, phase };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function startupLabel(startup: StartupSignal, agentLabel: string): string {
|
|
123
|
+
switch (startup.phase) {
|
|
124
|
+
case "investigating":
|
|
125
|
+
return `${agentLabel} starting…`;
|
|
126
|
+
case "connected":
|
|
127
|
+
return "Connected to Radar's tools";
|
|
128
|
+
case "ready": {
|
|
129
|
+
const parts = [`${agentLabel} ready`];
|
|
130
|
+
if (startup.model) parts.push(startup.model);
|
|
131
|
+
if (startup.toolCount !== undefined)
|
|
132
|
+
parts.push(
|
|
133
|
+
`${startup.toolCount} Radar ${startup.toolCount === 1 ? "tool" : "tools"}`,
|
|
134
|
+
);
|
|
135
|
+
return parts.join(" · ");
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// TimelineItem is one ordered transcript entry: agent reasoning, or a tool call.
|
|
141
|
+
export type TimelineItem =
|
|
142
|
+
| { kind: "thinking"; text: string; animate?: boolean }
|
|
143
|
+
| {
|
|
144
|
+
kind: "tool";
|
|
145
|
+
id: string;
|
|
146
|
+
tool: string;
|
|
147
|
+
status: string;
|
|
148
|
+
ms?: number;
|
|
149
|
+
summary?: string;
|
|
150
|
+
result?: string;
|
|
151
|
+
evidenceRef?: string;
|
|
152
|
+
radarEvidence?: boolean;
|
|
153
|
+
truncated?: boolean;
|
|
154
|
+
// Tri-state by design: false = producer confirmed success, true = producer
|
|
155
|
+
// confirmed failure, undefined = this replay cannot establish the outcome.
|
|
156
|
+
isError?: boolean;
|
|
157
|
+
// Arrival motion is event-local so a replayed running turn can keep receiving
|
|
158
|
+
// live tool calls without reanimating the history reconstructed before it.
|
|
159
|
+
animate?: boolean;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
export function appendThinking(
|
|
163
|
+
prev: TimelineItem[],
|
|
164
|
+
text: string,
|
|
165
|
+
animate = true,
|
|
166
|
+
): TimelineItem[] {
|
|
167
|
+
const normalized = text.replace(/\*\*\r?\n(?=\*\*)/g, "**\n\n");
|
|
168
|
+
// Agent CLIs commonly emit each short bold planning update as a complete
|
|
169
|
+
// stream event. Preserve those as discrete chronological beats instead of
|
|
170
|
+
// producing invalid/run-on Markdown such as `**one****two**`. Ordinary token
|
|
171
|
+
// chunks still concatenate into their current beat exactly as emitted.
|
|
172
|
+
const blocks = normalized.split(/\n{2,}(?=\s*\*\*)/);
|
|
173
|
+
const next = [...prev];
|
|
174
|
+
for (const block of blocks) {
|
|
175
|
+
if (!block) continue;
|
|
176
|
+
const last = next[next.length - 1];
|
|
177
|
+
const beginsBeat = /^\s*\*\*/.test(block);
|
|
178
|
+
const priorBeatComplete =
|
|
179
|
+
last?.kind === "thinking" && /\*\*\s*$/.test(last.text);
|
|
180
|
+
// Some agents repeat a bold phase heading at stream boundaries. Suppress
|
|
181
|
+
// only that presentation artifact; identical ordinary lines can be real
|
|
182
|
+
// evidence/reasoning and must survive both replay and live chunking.
|
|
183
|
+
if (
|
|
184
|
+
last?.kind === "thinking" &&
|
|
185
|
+
beginsBeat &&
|
|
186
|
+
priorBeatComplete &&
|
|
187
|
+
last.text.trim() === block.trim()
|
|
188
|
+
)
|
|
189
|
+
continue;
|
|
190
|
+
if (last?.kind === "thinking" && !(beginsBeat && priorBeatComplete)) {
|
|
191
|
+
next[next.length - 1] = {
|
|
192
|
+
...last,
|
|
193
|
+
text: (last.text + block).slice(-4000),
|
|
194
|
+
animate: last.animate === true || animate,
|
|
195
|
+
};
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
next.push({
|
|
199
|
+
kind: "thinking",
|
|
200
|
+
text: block.trimStart(),
|
|
201
|
+
animate,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
return next;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function upsertTool(
|
|
208
|
+
prev: TimelineItem[],
|
|
209
|
+
step: DiagnoseStep,
|
|
210
|
+
animate = true,
|
|
211
|
+
): TimelineItem[] {
|
|
212
|
+
const i = prev.findIndex((it) => it.kind === "tool" && it.id === step.id);
|
|
213
|
+
if (i >= 0) {
|
|
214
|
+
const next = [...prev];
|
|
215
|
+
const cur = next[i] as Extract<TimelineItem, { kind: "tool" }>;
|
|
216
|
+
// The `done` event omits the tool name + input; keep them from `running`.
|
|
217
|
+
next[i] = {
|
|
218
|
+
...cur,
|
|
219
|
+
...step,
|
|
220
|
+
kind: "tool",
|
|
221
|
+
tool: step.tool || cur.tool,
|
|
222
|
+
summary: step.summary || cur.summary,
|
|
223
|
+
animate: cur.animate === true || animate,
|
|
224
|
+
};
|
|
225
|
+
return next;
|
|
226
|
+
}
|
|
227
|
+
return [...prev, { kind: "tool", ...step, animate }];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function TurnView({
|
|
231
|
+
turn,
|
|
232
|
+
agentLabel,
|
|
233
|
+
onApply,
|
|
234
|
+
onViewExplanation,
|
|
235
|
+
onCheckStatus,
|
|
236
|
+
onRetryDiagnosis,
|
|
237
|
+
hideConclusion = false,
|
|
238
|
+
assessment = false,
|
|
239
|
+
explanation,
|
|
240
|
+
turnIndex,
|
|
241
|
+
evidenceStepIds,
|
|
242
|
+
onViewEvidence,
|
|
243
|
+
sourceRevealRequest,
|
|
244
|
+
assessmentSources,
|
|
245
|
+
}: {
|
|
246
|
+
turn: Turn;
|
|
247
|
+
agentLabel?: string;
|
|
248
|
+
onApply?: (fix: string) => void;
|
|
249
|
+
onViewExplanation?: () => void;
|
|
250
|
+
onCheckStatus?: () => void;
|
|
251
|
+
onRetryDiagnosis?: () => void;
|
|
252
|
+
/** Sources and agent items an answer turn cited; answers otherwise show none. */
|
|
253
|
+
assessmentSources?: ReactNode;
|
|
254
|
+
// The current assessment is Findings; the transcript keeps a pointer to it
|
|
255
|
+
// rather than a second copy (reasoning + tool calls still show).
|
|
256
|
+
hideConclusion?: boolean;
|
|
257
|
+
/** This turn is (or was) an assessment: render its full verdict, never as a conversational answer. */
|
|
258
|
+
assessment?: boolean;
|
|
259
|
+
/** A saved plain-language explanation of an earlier assessment, shown with it here. */
|
|
260
|
+
explanation?: AssessmentExplanation;
|
|
261
|
+
turnIndex?: number;
|
|
262
|
+
evidenceStepIds?: ReadonlySet<string>;
|
|
263
|
+
onViewEvidence?: (sourceId: string) => void;
|
|
264
|
+
sourceRevealRequest?: {
|
|
265
|
+
sourceId: string;
|
|
266
|
+
requestId: number;
|
|
267
|
+
excerpt?: InvestigationSourceExcerpt;
|
|
268
|
+
};
|
|
269
|
+
}) {
|
|
270
|
+
// A follow-up (a turn the user asked a question on) is a conversational reply,
|
|
271
|
+
// not a fresh diagnosis — render it as a plain answer, never the root-cause
|
|
272
|
+
// anchor or a remediation card — unless its verdict revised the assessment.
|
|
273
|
+
const followup =
|
|
274
|
+
!!turn.question && !turn.apply && !turn.verify && !assessment;
|
|
275
|
+
// Whether the done turn has anything for ResultCard to render — mirrors its
|
|
276
|
+
// branch order exactly (apply → followup → structured/healthy), since a followup
|
|
277
|
+
// ONLY ever renders FollowupAnswer (report/rootCause), never the remediation list.
|
|
278
|
+
// When false, TurnView shows the narration or an explicit empty note, not a blank.
|
|
279
|
+
const dx = turn.diagnosis;
|
|
280
|
+
const hasResult = dx
|
|
281
|
+
? followup
|
|
282
|
+
? !!(dx.report?.trim() || dx.rootCause?.trim()) // FollowupAnswer
|
|
283
|
+
: dx.healthy && !dx.rootCause
|
|
284
|
+
? true // AllClearCard
|
|
285
|
+
: dx.inconclusive && !dx.rootCause
|
|
286
|
+
? true // InconclusiveCard
|
|
287
|
+
: !!dx.rootCause ||
|
|
288
|
+
(dx.remediation?.length ?? 0) > 0 ||
|
|
289
|
+
!!dx.report?.trim()
|
|
290
|
+
: false;
|
|
291
|
+
return (
|
|
292
|
+
<div className="space-y-2">
|
|
293
|
+
{turn.explainAssessment ? (
|
|
294
|
+
<button
|
|
295
|
+
type="button"
|
|
296
|
+
onClick={onViewExplanation}
|
|
297
|
+
className="flex items-center gap-1.5 rounded-md py-2 text-xs text-accent-text hover:underline"
|
|
298
|
+
>
|
|
299
|
+
<HelpCircle className="h-3.5 w-3.5" />
|
|
300
|
+
{turn.status === "running"
|
|
301
|
+
? "Explaining assessment…"
|
|
302
|
+
: turn.status === "error"
|
|
303
|
+
? "Explanation failed"
|
|
304
|
+
: "Plain-language explanation"}
|
|
305
|
+
<span className="text-theme-text-tertiary">· View in Findings</span>
|
|
306
|
+
</button>
|
|
307
|
+
) : (
|
|
308
|
+
turn.question &&
|
|
309
|
+
(turn.verify ? (
|
|
310
|
+
<div className="flex items-center gap-2 rounded-md border border-theme-border/60 bg-theme-base/40 px-2.5 py-2 text-xs text-theme-text-secondary">
|
|
311
|
+
<RefreshCw className="h-3.5 w-3.5 shrink-0 text-accent" />
|
|
312
|
+
<span className="font-medium text-theme-text-primary">
|
|
313
|
+
Automatic verification
|
|
314
|
+
</span>
|
|
315
|
+
<span className="text-theme-text-tertiary">
|
|
316
|
+
Re-checking after apply
|
|
317
|
+
</span>
|
|
318
|
+
</div>
|
|
319
|
+
) : (
|
|
320
|
+
<div className="flex justify-end">
|
|
321
|
+
<div className="max-w-[85%]">
|
|
322
|
+
{turn.actor && (
|
|
323
|
+
<div className="mb-0.5 text-right text-[10px] text-theme-text-tertiary">
|
|
324
|
+
{turn.actor}
|
|
325
|
+
</div>
|
|
326
|
+
)}
|
|
327
|
+
<div className="rounded-lg rounded-br-sm bg-accent/10 px-3 py-1.5 text-sm text-theme-text-primary [overflow-wrap:anywhere]">
|
|
328
|
+
{turn.question}
|
|
329
|
+
</div>
|
|
330
|
+
</div>
|
|
331
|
+
</div>
|
|
332
|
+
))
|
|
333
|
+
)}
|
|
334
|
+
<Timeline
|
|
335
|
+
items={turn.timeline}
|
|
336
|
+
running={turn.status === "running"}
|
|
337
|
+
applyMode={turn.apply}
|
|
338
|
+
followup={followup}
|
|
339
|
+
startup={turn.startup}
|
|
340
|
+
agentLabel={agentLabel}
|
|
341
|
+
turnIndex={turnIndex}
|
|
342
|
+
evidenceStepIds={evidenceStepIds}
|
|
343
|
+
onViewEvidence={onViewEvidence}
|
|
344
|
+
sourceRevealRequest={sourceRevealRequest}
|
|
345
|
+
/>
|
|
346
|
+
{!turn.explainAssessment &&
|
|
347
|
+
turn.status === "done" &&
|
|
348
|
+
(turn.apply ? (
|
|
349
|
+
<ApplyOutcomeCard
|
|
350
|
+
diagnosis={turn.diagnosis}
|
|
351
|
+
applyOutcome={turn.applyOutcome}
|
|
352
|
+
onCheckStatus={onCheckStatus}
|
|
353
|
+
animate={turn.animateResult !== false}
|
|
354
|
+
/>
|
|
355
|
+
) : hideConclusion && hasResult ? (
|
|
356
|
+
assessment ? (
|
|
357
|
+
<p
|
|
358
|
+
data-turn-assessment-pointer
|
|
359
|
+
className="flex items-center gap-1.5 text-xs text-theme-text-tertiary"
|
|
360
|
+
>
|
|
361
|
+
<Sparkles className="h-3.5 w-3.5 text-accent" aria-hidden />
|
|
362
|
+
Assessment · shown in Findings
|
|
363
|
+
</p>
|
|
364
|
+
) : null
|
|
365
|
+
) : hasResult ? (
|
|
366
|
+
<ResultCard
|
|
367
|
+
diagnosis={turn.diagnosis!}
|
|
368
|
+
onApply={onApply}
|
|
369
|
+
followup={followup}
|
|
370
|
+
onCheckStatus={onCheckStatus}
|
|
371
|
+
animate={turn.animateResult !== false}
|
|
372
|
+
assessmentSources={assessmentSources}
|
|
373
|
+
explanation={explanation}
|
|
374
|
+
storyInline={assessment}
|
|
375
|
+
readOnlyAssessment={assessment}
|
|
376
|
+
/>
|
|
377
|
+
) : (
|
|
378
|
+
<EmptyResult animate={turn.animateResult !== false} />
|
|
379
|
+
))}
|
|
380
|
+
{!turn.explainAssessment &&
|
|
381
|
+
!turn.apply &&
|
|
382
|
+
turn.status === "done" &&
|
|
383
|
+
turn.diagnosis?.notes ? (
|
|
384
|
+
<WorkingNotes notes={turn.diagnosis.notes} />
|
|
385
|
+
) : null}
|
|
386
|
+
{turn.explainAssessment ? null : turn.status === "error" && turn.apply ? (
|
|
387
|
+
<ApplyOutcomeCard
|
|
388
|
+
diagnosis={turn.diagnosis}
|
|
389
|
+
error={turn.error}
|
|
390
|
+
applyOutcome={turn.applyOutcome}
|
|
391
|
+
onCheckStatus={onCheckStatus}
|
|
392
|
+
animate={turn.animateResult !== false}
|
|
393
|
+
/>
|
|
394
|
+
) : turn.status === "error" && turn.error ? (
|
|
395
|
+
<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">
|
|
396
|
+
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-400" />
|
|
397
|
+
<div className="flex min-w-0 flex-col gap-2">
|
|
398
|
+
<span className="whitespace-pre-wrap break-words">
|
|
399
|
+
{turn.error}
|
|
400
|
+
</span>
|
|
401
|
+
{onRetryDiagnosis && (
|
|
402
|
+
<button
|
|
403
|
+
type="button"
|
|
404
|
+
onClick={onRetryDiagnosis}
|
|
405
|
+
className="btn-brand self-start px-3 py-1 text-xs"
|
|
406
|
+
>
|
|
407
|
+
Retry investigation
|
|
408
|
+
</button>
|
|
409
|
+
)}
|
|
410
|
+
{onCheckStatus && (
|
|
411
|
+
<button
|
|
412
|
+
type="button"
|
|
413
|
+
onClick={onCheckStatus}
|
|
414
|
+
className="btn-brand self-start px-3 py-1 text-xs"
|
|
415
|
+
>
|
|
416
|
+
Check current status
|
|
417
|
+
</button>
|
|
418
|
+
)}
|
|
419
|
+
</div>
|
|
420
|
+
</div>
|
|
421
|
+
) : null}
|
|
422
|
+
</div>
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export function Timeline({
|
|
427
|
+
items,
|
|
428
|
+
running,
|
|
429
|
+
applyMode,
|
|
430
|
+
followup,
|
|
431
|
+
startup,
|
|
432
|
+
agentLabel = "Agent",
|
|
433
|
+
turnIndex,
|
|
434
|
+
evidenceStepIds,
|
|
435
|
+
onViewEvidence,
|
|
436
|
+
sourceRevealRequest,
|
|
437
|
+
}: {
|
|
438
|
+
items: TimelineItem[];
|
|
439
|
+
running: boolean;
|
|
440
|
+
applyMode?: boolean;
|
|
441
|
+
followup?: boolean;
|
|
442
|
+
startup?: StartupSignal;
|
|
443
|
+
agentLabel?: string;
|
|
444
|
+
turnIndex?: number;
|
|
445
|
+
evidenceStepIds?: ReadonlySet<string>;
|
|
446
|
+
onViewEvidence?: (sourceId: string) => void;
|
|
447
|
+
sourceRevealRequest?: {
|
|
448
|
+
sourceId: string;
|
|
449
|
+
requestId: number;
|
|
450
|
+
excerpt?: InvestigationSourceExcerpt;
|
|
451
|
+
};
|
|
452
|
+
}) {
|
|
453
|
+
const heading = applyMode
|
|
454
|
+
? "Applying fix"
|
|
455
|
+
: followup
|
|
456
|
+
? "Working"
|
|
457
|
+
: "Investigation";
|
|
458
|
+
// The live status verb tracks the running tool ("Reading logs…") so the wait is
|
|
459
|
+
// informative, not a generic spinner; before the first item it reports the
|
|
460
|
+
// startup phases the server actually observed, never a timer-based guess.
|
|
461
|
+
const activeTool = [...items]
|
|
462
|
+
.reverse()
|
|
463
|
+
.find((it) => it.kind === "tool" && it.status !== "done") as
|
|
464
|
+
Extract<TimelineItem, { kind: "tool" }> | undefined;
|
|
465
|
+
const runningLabel = applyMode
|
|
466
|
+
? "Applying the fix…"
|
|
467
|
+
: activeTool
|
|
468
|
+
? toolActivity(activeTool.tool)
|
|
469
|
+
: items.length > 0
|
|
470
|
+
? "Working…"
|
|
471
|
+
: startup
|
|
472
|
+
? startupLabel(startup, agentLabel)
|
|
473
|
+
: followup
|
|
474
|
+
? "Thinking…"
|
|
475
|
+
: "Starting investigation…";
|
|
476
|
+
const failedServers = (startup?.mcpServers ?? []).filter(
|
|
477
|
+
(server) => server.status !== "connected",
|
|
478
|
+
);
|
|
479
|
+
const radarServer = failedServers.find((server) => server.name === "radar");
|
|
480
|
+
const allDefiniteFailures = failedServers.every((server) =>
|
|
481
|
+
mcpStatusIsFailure(server.status),
|
|
482
|
+
);
|
|
483
|
+
return (
|
|
484
|
+
<div className="space-y-1.5">
|
|
485
|
+
{items.length > 0 && (
|
|
486
|
+
<div className="text-[11px] font-medium uppercase tracking-wide text-theme-text-tertiary">
|
|
487
|
+
{heading}
|
|
488
|
+
</div>
|
|
489
|
+
)}
|
|
490
|
+
{failedServers.length > 0 && (
|
|
491
|
+
<div
|
|
492
|
+
role="status"
|
|
493
|
+
className="flex items-start gap-1.5 rounded border border-semantic-warning/40 bg-semantic-warning/10 p-2 text-[11px] leading-snug text-theme-text-secondary"
|
|
494
|
+
>
|
|
495
|
+
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0 text-semantic-warning" />
|
|
496
|
+
<span>
|
|
497
|
+
{failedServers.map((server, i) => (
|
|
498
|
+
<span key={server.name}>
|
|
499
|
+
{i > 0 ? "; " : ""}
|
|
500
|
+
MCP server{" "}
|
|
501
|
+
<span className="font-medium text-theme-text-primary">
|
|
502
|
+
{server.name}
|
|
503
|
+
</span>{" "}
|
|
504
|
+
{mcpStatusPhrase(server.status)}
|
|
505
|
+
</span>
|
|
506
|
+
))}
|
|
507
|
+
{radarServer
|
|
508
|
+
? mcpStatusIsFailure(radarServer.status)
|
|
509
|
+
? ` at startup. ${agentLabel} had no Radar tools this turn, so it could not use Radar's cluster evidence.`
|
|
510
|
+
: ` at startup. Radar's tools may not have been available to ${agentLabel} this turn.`
|
|
511
|
+
: allDefiniteFailures
|
|
512
|
+
? ` at startup. ${agentLabel} ran this turn without those tools.`
|
|
513
|
+
: ` at startup. Those tools may not have been available to ${agentLabel} this turn.`}
|
|
514
|
+
</span>
|
|
515
|
+
</div>
|
|
516
|
+
)}
|
|
517
|
+
{items.map((it, i) => {
|
|
518
|
+
if (it.kind === "thinking") {
|
|
519
|
+
return (
|
|
520
|
+
<ThinkingBlock
|
|
521
|
+
key={i}
|
|
522
|
+
text={it.text}
|
|
523
|
+
animate={it.animate !== false}
|
|
524
|
+
live={running && i === items.length - 1}
|
|
525
|
+
/>
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
const sourceId =
|
|
529
|
+
turnIndex === undefined
|
|
530
|
+
? undefined
|
|
531
|
+
: investigationEvidenceSourceId(turnIndex, it.id);
|
|
532
|
+
return (
|
|
533
|
+
<ToolRow
|
|
534
|
+
key={it.id}
|
|
535
|
+
step={it}
|
|
536
|
+
sourceId={sourceId}
|
|
537
|
+
hasEvidence={evidenceStepIds?.has(it.id) ?? false}
|
|
538
|
+
onViewEvidence={onViewEvidence}
|
|
539
|
+
revealRequestId={
|
|
540
|
+
sourceRevealRequest && sourceId === sourceRevealRequest.sourceId
|
|
541
|
+
? sourceRevealRequest.requestId
|
|
542
|
+
: undefined
|
|
543
|
+
}
|
|
544
|
+
sourceExcerpt={
|
|
545
|
+
sourceRevealRequest && sourceRevealRequest.sourceId === sourceId
|
|
546
|
+
? sourceRevealRequest.excerpt
|
|
547
|
+
: undefined
|
|
548
|
+
}
|
|
549
|
+
animate={it.animate !== false}
|
|
550
|
+
/>
|
|
551
|
+
);
|
|
552
|
+
})}
|
|
553
|
+
{running && <RunningStatus label={runningLabel} />}
|
|
554
|
+
</div>
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// The model's reasoning between tool calls — muted + subordinate to the tool
|
|
559
|
+
// rows, and clamped once its beat is over so the chronology stays scannable.
|
|
560
|
+
// The final beat remains fully visible while it is streaming; completed prose
|
|
561
|
+
// is still available through an overflow-aware disclosure.
|
|
562
|
+
function ThinkingBlock({
|
|
563
|
+
text,
|
|
564
|
+
animate,
|
|
565
|
+
live,
|
|
566
|
+
}: {
|
|
567
|
+
text: string;
|
|
568
|
+
animate: boolean;
|
|
569
|
+
live: boolean;
|
|
570
|
+
}) {
|
|
571
|
+
const [expanded, setExpanded] = useState(false);
|
|
572
|
+
const [contentHeight, setContentHeight] = useState<number>();
|
|
573
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
574
|
+
const contentId = useId();
|
|
575
|
+
const reveal = useDisclosureReveal<HTMLDivElement>();
|
|
576
|
+
// Two lines of 12px/19.5px prose plus 8px paragraph/container spacing. Unlike a disclosure
|
|
577
|
+
// this starts with a visible preview, so animate measured height on the
|
|
578
|
+
// same clock as Collapse (DURATION_DISCLOSURE + the one curve).
|
|
579
|
+
const previewHeight = 47;
|
|
580
|
+
const clamped = !live && !expanded;
|
|
581
|
+
useEffect(() => {
|
|
582
|
+
const content = contentRef.current;
|
|
583
|
+
if (!content) return;
|
|
584
|
+
const measure = () => setContentHeight(content.scrollHeight);
|
|
585
|
+
measure();
|
|
586
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
587
|
+
const observer = new ResizeObserver(measure);
|
|
588
|
+
observer.observe(content);
|
|
589
|
+
return () => observer.disconnect();
|
|
590
|
+
}, [text]);
|
|
591
|
+
|
|
592
|
+
return (
|
|
593
|
+
<div
|
|
594
|
+
ref={reveal.elementRef}
|
|
595
|
+
className={animate ? "animate-transcript-enter" : ""}
|
|
596
|
+
>
|
|
597
|
+
<div
|
|
598
|
+
id={contentId}
|
|
599
|
+
className={`overflow-hidden transition-[height] duration-300 ${TW_EASE_UI} motion-reduce:transition-none`}
|
|
600
|
+
style={{
|
|
601
|
+
height: clamped
|
|
602
|
+
? Math.min(contentHeight ?? previewHeight, previewHeight)
|
|
603
|
+
: contentHeight,
|
|
604
|
+
}}
|
|
605
|
+
>
|
|
606
|
+
<div ref={contentRef}>
|
|
607
|
+
<AIMarkdown className="py-0.5 text-xs leading-relaxed text-theme-text-tertiary [overflow-wrap:anywhere] [&_li]:text-theme-text-tertiary [&_p]:my-0.5 [&_strong]:font-medium [&_strong]:text-theme-text-secondary">
|
|
608
|
+
{text}
|
|
609
|
+
</AIMarkdown>
|
|
610
|
+
</div>
|
|
611
|
+
</div>
|
|
612
|
+
{!live && ((contentHeight ?? 0) > previewHeight || expanded) ? (
|
|
613
|
+
<button
|
|
614
|
+
type="button"
|
|
615
|
+
aria-controls={contentId}
|
|
616
|
+
aria-expanded={expanded}
|
|
617
|
+
onClick={() => {
|
|
618
|
+
setExpanded(!expanded);
|
|
619
|
+
reveal.revealAfterToggle(!expanded);
|
|
620
|
+
}}
|
|
621
|
+
className="text-[11px] font-medium text-theme-text-tertiary hover:text-accent-text"
|
|
622
|
+
>
|
|
623
|
+
{expanded ? "Show less" : "Show reasoning"}
|
|
624
|
+
</button>
|
|
625
|
+
) : null}
|
|
626
|
+
</div>
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// The live "working" line: spinner + shimmering activity verb, plus an elapsed
|
|
631
|
+
// counter and — if the same activity sits with no update for a while — a soft
|
|
632
|
+
// "still working" reassurance, so a long investigation reads as progress and a
|
|
633
|
+
// genuine hang is at least legible (a non-expert can't otherwise tell them apart).
|
|
634
|
+
// Self-contained: counts from when this line mounts; the stall timer resets each
|
|
635
|
+
// time the label changes (i.e. whenever the agent moves to a new tool/phase).
|
|
636
|
+
function RunningStatus({ label }: { label: string }) {
|
|
637
|
+
const [elapsed, setElapsed] = useState(0);
|
|
638
|
+
const elapsedRef = useRef(0);
|
|
639
|
+
const lastChangeRef = useRef(0);
|
|
640
|
+
const prevLabelRef = useRef(label);
|
|
641
|
+
// Reset the stall timer synchronously when the label changes (i.e. the agent moved
|
|
642
|
+
// to a new tool/phase) — doing it during render, not in an effect, so an already-
|
|
643
|
+
// stalled line never flashes "no update for Ns" for a tick before resetting.
|
|
644
|
+
if (prevLabelRef.current !== label) {
|
|
645
|
+
prevLabelRef.current = label;
|
|
646
|
+
lastChangeRef.current = elapsedRef.current;
|
|
647
|
+
}
|
|
648
|
+
useEffect(() => {
|
|
649
|
+
const id = setInterval(() => {
|
|
650
|
+
elapsedRef.current += 1;
|
|
651
|
+
setElapsed(elapsedRef.current);
|
|
652
|
+
}, 1000);
|
|
653
|
+
return () => clearInterval(id);
|
|
654
|
+
}, []);
|
|
655
|
+
const sinceChange = elapsed - lastChangeRef.current;
|
|
656
|
+
const stalled = elapsed >= 30 && sinceChange >= 30;
|
|
657
|
+
const counter = runningElapsedLabel(elapsed);
|
|
658
|
+
return (
|
|
659
|
+
<div className="flex items-center gap-2 pt-1 text-xs">
|
|
660
|
+
<Loader2 className="h-3 w-3 shrink-0 animate-spin text-accent" />
|
|
661
|
+
<span className="ai-shimmer min-w-0 truncate">{label}</span>
|
|
662
|
+
{stalled && (
|
|
663
|
+
<span className="shrink-0 text-theme-text-tertiary">
|
|
664
|
+
· still working — no update for {sinceChange}s
|
|
665
|
+
</span>
|
|
666
|
+
)}
|
|
667
|
+
{counter && (
|
|
668
|
+
<span className="ml-auto shrink-0 tabular-nums text-theme-text-tertiary">
|
|
669
|
+
{counter}
|
|
670
|
+
</span>
|
|
671
|
+
)}
|
|
672
|
+
</div>
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// The wait the operator feels is the whole turn's, so the counter is a
|
|
677
|
+
// row-level figure set apart from the label: "Connected to Radar's tools"
|
|
678
|
+
// followed by "10s" read as if the handshake took that long.
|
|
679
|
+
export function runningElapsedLabel(elapsed: number): string | undefined {
|
|
680
|
+
return elapsed >= 3 ? `${elapsed}s elapsed` : undefined;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Claude Code reports each MCP server as connected, failed, needs-auth, or
|
|
684
|
+
// pending. Only the first two are definite outcomes; anything else is left as
|
|
685
|
+
// the CLI's own word so the warning never claims more than it knows.
|
|
686
|
+
function mcpStatusIsFailure(status: string): boolean {
|
|
687
|
+
return status === "failed" || status === "needs-auth";
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function mcpStatusPhrase(status: string): string {
|
|
691
|
+
if (status === "failed") return "failed to connect";
|
|
692
|
+
if (status === "needs-auth") return "needs authentication";
|
|
693
|
+
return `is ${status}`;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Maps a running tool to a human verb so the status line reads as activity, not
|
|
697
|
+
// machinery. Falls back to the prettified tool name for anything unmapped.
|
|
698
|
+
function toolActivity(tool: string): string {
|
|
699
|
+
const t = tool.toLowerCase();
|
|
700
|
+
if (t.includes("log")) return "Reading logs…";
|
|
701
|
+
if (t.includes("event")) return "Checking recent events…";
|
|
702
|
+
if (t.includes("list")) return "Scanning related resources…";
|
|
703
|
+
if (t.includes("describe") || t.includes("get_resource"))
|
|
704
|
+
return "Inspecting the resource…";
|
|
705
|
+
if (t.includes("resource")) return "Inspecting the resource…";
|
|
706
|
+
if (t.includes("metric") || t.includes("top")) return "Checking metrics…";
|
|
707
|
+
if (t.includes("topology") || t.includes("graph"))
|
|
708
|
+
return "Tracing dependencies…";
|
|
709
|
+
return `${prettyTool(tool)}…`;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function ToolRow({
|
|
713
|
+
step,
|
|
714
|
+
sourceId,
|
|
715
|
+
hasEvidence,
|
|
716
|
+
onViewEvidence,
|
|
717
|
+
revealRequestId,
|
|
718
|
+
sourceExcerpt,
|
|
719
|
+
animate,
|
|
720
|
+
}: {
|
|
721
|
+
step: Extract<TimelineItem, { kind: "tool" }>;
|
|
722
|
+
sourceId?: string;
|
|
723
|
+
hasEvidence?: boolean;
|
|
724
|
+
onViewEvidence?: (sourceId: string) => void;
|
|
725
|
+
revealRequestId?: number;
|
|
726
|
+
sourceExcerpt?: InvestigationSourceExcerpt;
|
|
727
|
+
animate: boolean;
|
|
728
|
+
}) {
|
|
729
|
+
const [open, setOpen] = useState(revealRequestId !== undefined);
|
|
730
|
+
const [showFull, setShowFull] = useState(false);
|
|
731
|
+
const [argumentsOpen, setArgumentsOpen] = useState(false);
|
|
732
|
+
const toolReveal = useDisclosureReveal<HTMLDivElement>();
|
|
733
|
+
const argumentsReveal = useDisclosureReveal<HTMLDivElement>();
|
|
734
|
+
const argumentsId = useId();
|
|
735
|
+
const argumentsPreview = step.summary ? compactArgs(step.summary) : "";
|
|
736
|
+
const inlineArguments =
|
|
737
|
+
argumentsPreview.length <= 240 && !argumentsPreview.includes("\n");
|
|
738
|
+
const detailId = `investigation-tool-detail-${useId().replaceAll(":", "")}`;
|
|
739
|
+
const hasDetail = !!(step.summary || step.result);
|
|
740
|
+
useEffect(() => {
|
|
741
|
+
if (revealRequestId !== undefined && hasDetail) setOpen(true);
|
|
742
|
+
}, [hasDetail, revealRequestId]);
|
|
743
|
+
// Offer the rich dialog when the result is structured or non-trivial in size.
|
|
744
|
+
const richResult =
|
|
745
|
+
!!step.result && (isJsonPayload(step.result) || step.result.length > 200);
|
|
746
|
+
const done = step.status === "done";
|
|
747
|
+
const durationLabel = toolDurationLabel(step.ms);
|
|
748
|
+
const errorReason =
|
|
749
|
+
step.isError === true ? toolErrorReason(step.result) : undefined;
|
|
750
|
+
const outcomeLabel = !done
|
|
751
|
+
? "Running"
|
|
752
|
+
: step.isError === true
|
|
753
|
+
? "Tool failed"
|
|
754
|
+
: step.isError === false
|
|
755
|
+
? "Tool completed"
|
|
756
|
+
: "Tool finished; outcome not recorded";
|
|
757
|
+
const rowContent = (
|
|
758
|
+
<>
|
|
759
|
+
{!done ? (
|
|
760
|
+
<Loader2
|
|
761
|
+
aria-label={outcomeLabel}
|
|
762
|
+
className="h-3.5 w-3.5 shrink-0 animate-spin text-accent"
|
|
763
|
+
/>
|
|
764
|
+
) : step.isError === true ? (
|
|
765
|
+
<AlertTriangle
|
|
766
|
+
aria-label={outcomeLabel}
|
|
767
|
+
className="h-3.5 w-3.5 shrink-0 text-red-400"
|
|
768
|
+
/>
|
|
769
|
+
) : step.isError === false ? (
|
|
770
|
+
<CheckCircle2
|
|
771
|
+
aria-label={outcomeLabel}
|
|
772
|
+
className="h-3.5 w-3.5 shrink-0 text-emerald-400"
|
|
773
|
+
/>
|
|
774
|
+
) : (
|
|
775
|
+
<HelpCircle
|
|
776
|
+
aria-label={outcomeLabel}
|
|
777
|
+
className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary"
|
|
778
|
+
/>
|
|
779
|
+
)}
|
|
780
|
+
<span className="shrink-0 font-mono text-xs text-theme-text-secondary">
|
|
781
|
+
{prettyTool(step.tool)}
|
|
782
|
+
</span>
|
|
783
|
+
{step.summary && !open && (
|
|
784
|
+
<span className="min-w-0 flex-1 shrink-[4] truncate font-mono text-[11px] text-theme-text-tertiary">
|
|
785
|
+
{argumentsPreview}
|
|
786
|
+
</span>
|
|
787
|
+
)}
|
|
788
|
+
{errorReason && !open && (
|
|
789
|
+
// The arguments give way first: they are still readable expanded,
|
|
790
|
+
// while the reason is the one thing this row exists to say. It still
|
|
791
|
+
// clips at the row's edge; the full text is a hover and a click away.
|
|
792
|
+
<Tooltip content={errorReason} wrapperClassName="min-w-0 truncate">
|
|
793
|
+
<span className="investigation-tool-reason block min-w-0 truncate text-[11px] text-semantic-error">
|
|
794
|
+
{middleTruncate(errorReason)}
|
|
795
|
+
</span>
|
|
796
|
+
</Tooltip>
|
|
797
|
+
)}
|
|
798
|
+
{durationLabel && (
|
|
799
|
+
<span className="ml-auto shrink-0 text-[11px] text-theme-text-tertiary">
|
|
800
|
+
{durationLabel}
|
|
801
|
+
</span>
|
|
802
|
+
)}
|
|
803
|
+
{hasDetail && <CollapseChevron open={open} className="h-3.5 w-3.5" />}
|
|
804
|
+
</>
|
|
805
|
+
);
|
|
806
|
+
return (
|
|
807
|
+
<div
|
|
808
|
+
onMouseEnter={(event) =>
|
|
809
|
+
highlightRelatedEvidence(event.currentTarget, sourceId)
|
|
810
|
+
}
|
|
811
|
+
onMouseLeave={(event) => highlightRelatedEvidence(event.currentTarget)}
|
|
812
|
+
ref={toolReveal.elementRef}
|
|
813
|
+
id={sourceId ? investigationActivitySourceDomId(sourceId) : undefined}
|
|
814
|
+
tabIndex={sourceId ? -1 : undefined}
|
|
815
|
+
role={sourceId ? "group" : undefined}
|
|
816
|
+
aria-label={
|
|
817
|
+
sourceId
|
|
818
|
+
? `${prettyTool(step.tool)} ${outcomeLabel.toLowerCase()}`
|
|
819
|
+
: undefined
|
|
820
|
+
}
|
|
821
|
+
className={`${animate ? "animate-transcript-enter" : ""} scroll-mt-3 rounded-md border border-theme-border/60 bg-theme-base/40 outline-none focus:ring-2 focus:ring-accent/50`}
|
|
822
|
+
>
|
|
823
|
+
<div className="flex min-w-0 items-stretch">
|
|
824
|
+
{hasDetail ? (
|
|
825
|
+
<button
|
|
826
|
+
type="button"
|
|
827
|
+
onClick={() => {
|
|
828
|
+
setOpen(!open);
|
|
829
|
+
toolReveal.revealAfterToggle(!open);
|
|
830
|
+
}}
|
|
831
|
+
aria-expanded={open}
|
|
832
|
+
aria-controls={detailId}
|
|
833
|
+
className="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-left text-sm hover:bg-theme-hover"
|
|
834
|
+
>
|
|
835
|
+
{rowContent}
|
|
836
|
+
</button>
|
|
837
|
+
) : (
|
|
838
|
+
<div className="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-left text-sm">
|
|
839
|
+
{rowContent}
|
|
840
|
+
</div>
|
|
841
|
+
)}
|
|
842
|
+
{hasEvidence && sourceId && onViewEvidence ? (
|
|
843
|
+
<button
|
|
844
|
+
type="button"
|
|
845
|
+
onClick={() => onViewEvidence(sourceId)}
|
|
846
|
+
aria-label={`View evidence from ${prettyTool(step.tool)}`}
|
|
847
|
+
className="investigation-evidence-jump shrink-0 px-2 text-[11px] font-medium text-accent-text hover:bg-theme-hover"
|
|
848
|
+
>
|
|
849
|
+
Show evidence
|
|
850
|
+
</button>
|
|
851
|
+
) : null}
|
|
852
|
+
</div>
|
|
853
|
+
{hasDetail && (
|
|
854
|
+
<div id={detailId}>
|
|
855
|
+
<Collapse open={open}>
|
|
856
|
+
<div className="space-y-2 border-t border-theme-border/60 px-2 py-2">
|
|
857
|
+
{step.summary &&
|
|
858
|
+
(inlineArguments ? (
|
|
859
|
+
<div
|
|
860
|
+
className="break-words font-mono text-[11px] leading-relaxed text-theme-text-secondary [overflow-wrap:anywhere]"
|
|
861
|
+
aria-label="Tool arguments"
|
|
862
|
+
>
|
|
863
|
+
{argumentsPreview}
|
|
864
|
+
</div>
|
|
865
|
+
) : (
|
|
866
|
+
<div ref={argumentsReveal.elementRef}>
|
|
867
|
+
<button
|
|
868
|
+
type="button"
|
|
869
|
+
aria-expanded={argumentsOpen}
|
|
870
|
+
aria-controls={argumentsId}
|
|
871
|
+
onClick={() => {
|
|
872
|
+
setArgumentsOpen(!argumentsOpen);
|
|
873
|
+
argumentsReveal.revealAfterToggle(!argumentsOpen);
|
|
874
|
+
}}
|
|
875
|
+
className="flex items-center gap-1 py-1 text-xs text-theme-text-tertiary hover:text-theme-text-primary"
|
|
876
|
+
>
|
|
877
|
+
<CollapseChevron
|
|
878
|
+
open={argumentsOpen}
|
|
879
|
+
className="h-3.5 w-3.5"
|
|
880
|
+
/>
|
|
881
|
+
{argumentsOpen ? "Hide arguments" : "Show arguments"}
|
|
882
|
+
</button>
|
|
883
|
+
<div id={argumentsId}>
|
|
884
|
+
<Collapse open={argumentsOpen} mountLazily>
|
|
885
|
+
<PayloadBlock label="Arguments" text={step.summary} />
|
|
886
|
+
</Collapse>
|
|
887
|
+
</div>
|
|
888
|
+
</div>
|
|
889
|
+
))}
|
|
890
|
+
{step.result && (
|
|
891
|
+
<PayloadBlock
|
|
892
|
+
label="Original result"
|
|
893
|
+
detail={step.ms != null ? `${step.ms}ms` : undefined}
|
|
894
|
+
text={step.result}
|
|
895
|
+
sourceExcerpt={sourceExcerpt}
|
|
896
|
+
revealRequestId={revealRequestId}
|
|
897
|
+
truncated={step.truncated}
|
|
898
|
+
action={
|
|
899
|
+
richResult ? (
|
|
900
|
+
<button
|
|
901
|
+
onClick={() => setShowFull(true)}
|
|
902
|
+
className="flex items-center gap-1 text-[11px] text-accent hover:underline"
|
|
903
|
+
>
|
|
904
|
+
<Maximize2 className="h-3 w-3" />
|
|
905
|
+
View payload
|
|
906
|
+
</button>
|
|
907
|
+
) : undefined
|
|
908
|
+
}
|
|
909
|
+
/>
|
|
910
|
+
)}
|
|
911
|
+
</div>
|
|
912
|
+
</Collapse>
|
|
913
|
+
</div>
|
|
914
|
+
)}
|
|
915
|
+
{step.result && (
|
|
916
|
+
<ToolResultDialog
|
|
917
|
+
open={showFull}
|
|
918
|
+
onClose={() => setShowFull(false)}
|
|
919
|
+
title={prettyTool(step.tool)}
|
|
920
|
+
text={step.result}
|
|
921
|
+
truncated={step.truncated}
|
|
922
|
+
/>
|
|
923
|
+
)}
|
|
924
|
+
</div>
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// Only outliers carry information on the collapsed row: a slow log fetch or a
|
|
929
|
+
// probe that hung. Sub-second calls stay silent; the exact figure lives in the
|
|
930
|
+
// expanded result header.
|
|
931
|
+
export function toolDurationLabel(ms: number | undefined): string | undefined {
|
|
932
|
+
if (ms == null || ms < 2000) return undefined;
|
|
933
|
+
return `${Math.round(ms / 1000)}s`;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// The producer's own words for a failed call, reduced to one line. Radar's MCP
|
|
937
|
+
// errors are plain text; a JSON envelope with an `error` field is unwrapped.
|
|
938
|
+
// Radar's not-found errors append retry hints for the agent after an em dash;
|
|
939
|
+
// only the clause before it says what failed, so the hints are dropped.
|
|
940
|
+
export function toolErrorReason(
|
|
941
|
+
result: string | undefined,
|
|
942
|
+
): string | undefined {
|
|
943
|
+
if (!result) return undefined;
|
|
944
|
+
let text = result;
|
|
945
|
+
try {
|
|
946
|
+
const parsed: unknown = JSON.parse(result);
|
|
947
|
+
if (
|
|
948
|
+
parsed &&
|
|
949
|
+
typeof parsed === "object" &&
|
|
950
|
+
typeof (parsed as { error?: unknown }).error === "string"
|
|
951
|
+
) {
|
|
952
|
+
text = (parsed as { error: string }).error;
|
|
953
|
+
}
|
|
954
|
+
} catch {
|
|
955
|
+
// plain text
|
|
956
|
+
}
|
|
957
|
+
const line = text
|
|
958
|
+
.split("\n")
|
|
959
|
+
.map((part) => part.trim())
|
|
960
|
+
.find((part) => part.length > 0);
|
|
961
|
+
if (!line) return undefined;
|
|
962
|
+
const clause = line.split(" — ")[0].trim();
|
|
963
|
+
return clause || line;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
const COLLAPSED_REASON_MAX = 100;
|
|
967
|
+
|
|
968
|
+
const COLLAPSED_REASON_TAIL = 36;
|
|
969
|
+
|
|
970
|
+
// Keeps both ends of a long reason: the leading words say what failed and the
|
|
971
|
+
// tail usually carries the identifier, which a plain end-truncation would lose.
|
|
972
|
+
export function middleTruncate(
|
|
973
|
+
text: string,
|
|
974
|
+
max = COLLAPSED_REASON_MAX,
|
|
975
|
+
tail = COLLAPSED_REASON_TAIL,
|
|
976
|
+
): string {
|
|
977
|
+
if (text.length <= max) return text;
|
|
978
|
+
const head = Math.max(1, max - tail - 1);
|
|
979
|
+
return `${text.slice(0, head).trimEnd()}…${text.slice(-tail).trimStart()}`;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// isJsonPayload / formatJson — a tool result is "structured" if it parses as JSON.
|
|
983
|
+
function isJsonPayload(text: string): boolean {
|
|
984
|
+
try {
|
|
985
|
+
JSON.parse(text);
|
|
986
|
+
return true;
|
|
987
|
+
} catch {
|
|
988
|
+
return false;
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
function formatJson(text: string): string | null {
|
|
993
|
+
try {
|
|
994
|
+
return JSON.stringify(JSON.parse(text), null, 2);
|
|
995
|
+
} catch {
|
|
996
|
+
return null;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// PayloadBlock — compact inline view of a tool input/result: pretty JSON (scrolled
|
|
1001
|
+
// to keep indentation) or wrapped text (logs/prose), with copy + optional action.
|
|
1002
|
+
function PayloadBlock({
|
|
1003
|
+
label,
|
|
1004
|
+
detail,
|
|
1005
|
+
text,
|
|
1006
|
+
truncated,
|
|
1007
|
+
action,
|
|
1008
|
+
sourceExcerpt,
|
|
1009
|
+
revealRequestId,
|
|
1010
|
+
}: {
|
|
1011
|
+
label: string;
|
|
1012
|
+
detail?: string;
|
|
1013
|
+
text: string;
|
|
1014
|
+
truncated?: boolean;
|
|
1015
|
+
action?: ReactNode;
|
|
1016
|
+
sourceExcerpt?: InvestigationSourceExcerpt;
|
|
1017
|
+
revealRequestId?: number;
|
|
1018
|
+
}) {
|
|
1019
|
+
const json = formatJson(text);
|
|
1020
|
+
const display = json ?? text;
|
|
1021
|
+
const range = locateSourceExcerpt(display, sourceExcerpt);
|
|
1022
|
+
const rangeStart = range?.start;
|
|
1023
|
+
const rangeEnd = range?.end;
|
|
1024
|
+
const preRef = useRef<HTMLPreElement>(null);
|
|
1025
|
+
const markRef = useRef<HTMLElement>(null);
|
|
1026
|
+
useEffect(() => {
|
|
1027
|
+
if (rangeStart === undefined || revealRequestId === undefined) return;
|
|
1028
|
+
const frame = requestAnimationFrame(() => {
|
|
1029
|
+
const pre = preRef.current,
|
|
1030
|
+
mark = markRef.current;
|
|
1031
|
+
if (pre && mark)
|
|
1032
|
+
pre.scrollTop +=
|
|
1033
|
+
mark.getBoundingClientRect().top -
|
|
1034
|
+
pre.getBoundingClientRect().top -
|
|
1035
|
+
pre.clientHeight / 3;
|
|
1036
|
+
});
|
|
1037
|
+
return () => cancelAnimationFrame(frame);
|
|
1038
|
+
}, [revealRequestId, rangeStart, rangeEnd]);
|
|
1039
|
+
return (
|
|
1040
|
+
<div>
|
|
1041
|
+
<div className="mb-0.5 flex items-center justify-between gap-2">
|
|
1042
|
+
<span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary">
|
|
1043
|
+
{label}
|
|
1044
|
+
{detail && (
|
|
1045
|
+
<span className="ml-1.5 normal-case tracking-normal">
|
|
1046
|
+
· {detail}
|
|
1047
|
+
</span>
|
|
1048
|
+
)}
|
|
1049
|
+
</span>
|
|
1050
|
+
<div className="flex items-center gap-2">
|
|
1051
|
+
{action}
|
|
1052
|
+
<CopyButton
|
|
1053
|
+
text={json ?? text}
|
|
1054
|
+
label={`Copy ${label.toLowerCase()}`}
|
|
1055
|
+
/>
|
|
1056
|
+
</div>
|
|
1057
|
+
</div>
|
|
1058
|
+
<pre
|
|
1059
|
+
ref={preRef}
|
|
1060
|
+
className={`max-h-64 overflow-auto rounded bg-theme-elevated p-1.5 font-mono text-[11px] text-theme-text-secondary ${json && !range ? "" : "whitespace-pre-wrap [overflow-wrap:anywhere]"}`}
|
|
1061
|
+
>
|
|
1062
|
+
{range ? (
|
|
1063
|
+
<>
|
|
1064
|
+
{display.slice(0, range.start)}
|
|
1065
|
+
<mark
|
|
1066
|
+
ref={markRef}
|
|
1067
|
+
className="bg-accent-muted text-theme-text-primary ring-1 ring-accent/40"
|
|
1068
|
+
>
|
|
1069
|
+
{display.slice(range.start, range.end)}
|
|
1070
|
+
</mark>
|
|
1071
|
+
{display.slice(range.end)}
|
|
1072
|
+
</>
|
|
1073
|
+
) : (
|
|
1074
|
+
display
|
|
1075
|
+
)}
|
|
1076
|
+
</pre>
|
|
1077
|
+
{truncated && (
|
|
1078
|
+
<div className="mt-0.5 text-[10px] text-amber-500">
|
|
1079
|
+
Capped at 32 KB — partial output.
|
|
1080
|
+
</div>
|
|
1081
|
+
)}
|
|
1082
|
+
</div>
|
|
1083
|
+
);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// ToolResultDialog — the rich payload viewer: syntax-highlighted + searchable via
|
|
1087
|
+
// CodeViewer, with a JSON⇄YAML toggle for structured results (YAML default — k8s
|
|
1088
|
+
// reads better) and plain text for non-JSON (logs/prose).
|
|
1089
|
+
function ToolResultDialog({
|
|
1090
|
+
open,
|
|
1091
|
+
onClose,
|
|
1092
|
+
title,
|
|
1093
|
+
text,
|
|
1094
|
+
truncated,
|
|
1095
|
+
}: {
|
|
1096
|
+
open: boolean;
|
|
1097
|
+
onClose: () => void;
|
|
1098
|
+
title: string;
|
|
1099
|
+
text: string;
|
|
1100
|
+
truncated?: boolean;
|
|
1101
|
+
}) {
|
|
1102
|
+
const { theme } = useTheme();
|
|
1103
|
+
const [fmt, setFmt] = useState<"yaml" | "json">("yaml");
|
|
1104
|
+
const parsed = useMemo<{ ok: boolean; value?: unknown }>(() => {
|
|
1105
|
+
try {
|
|
1106
|
+
return { ok: true, value: JSON.parse(text) };
|
|
1107
|
+
} catch {
|
|
1108
|
+
return { ok: false };
|
|
1109
|
+
}
|
|
1110
|
+
}, [text]);
|
|
1111
|
+
|
|
1112
|
+
const display = !parsed.ok
|
|
1113
|
+
? text
|
|
1114
|
+
: fmt === "yaml"
|
|
1115
|
+
? safeYaml(parsed.value)
|
|
1116
|
+
: JSON.stringify(parsed.value, null, 2);
|
|
1117
|
+
const language = parsed.ok ? fmt : "text";
|
|
1118
|
+
|
|
1119
|
+
// Progressive syntax highlighting: render the plain text instantly, then swap in
|
|
1120
|
+
// shiki's highlighted HTML once it resolves. A slow/failed highlighter load never
|
|
1121
|
+
// blocks the payload (unlike CodeViewer's "Loading…" gate) — worst case it stays
|
|
1122
|
+
// plain. Native browser find still works on the rendered text.
|
|
1123
|
+
const [html, setHtml] = useState<string | null>(null);
|
|
1124
|
+
useEffect(() => {
|
|
1125
|
+
if (!open) return;
|
|
1126
|
+
setHtml(null);
|
|
1127
|
+
let alive = true;
|
|
1128
|
+
codeToHtml(display, {
|
|
1129
|
+
lang: language,
|
|
1130
|
+
theme: theme === "light" ? "github-light" : "github-dark",
|
|
1131
|
+
})
|
|
1132
|
+
.then((h) => alive && setHtml(h))
|
|
1133
|
+
.catch(() => {}); // keep the plain pre on failure
|
|
1134
|
+
return () => {
|
|
1135
|
+
alive = false;
|
|
1136
|
+
};
|
|
1137
|
+
}, [open, display, language, theme]);
|
|
1138
|
+
return (
|
|
1139
|
+
<DialogPortal open={open} onClose={onClose} className="w-[min(90vw,820px)]">
|
|
1140
|
+
<div className="flex items-center justify-between gap-3 border-b border-theme-border p-3">
|
|
1141
|
+
<div className="min-w-0">
|
|
1142
|
+
<div className="truncate font-mono text-sm text-theme-text-primary">
|
|
1143
|
+
{title}
|
|
1144
|
+
</div>
|
|
1145
|
+
{truncated && (
|
|
1146
|
+
<div className="text-[11px] text-amber-500">
|
|
1147
|
+
Capped at 32 KB — partial output.
|
|
1148
|
+
</div>
|
|
1149
|
+
)}
|
|
1150
|
+
</div>
|
|
1151
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
1152
|
+
{parsed.ok && (
|
|
1153
|
+
<div className="w-32">
|
|
1154
|
+
<Segmented<"yaml" | "json">
|
|
1155
|
+
value={fmt}
|
|
1156
|
+
onChange={setFmt}
|
|
1157
|
+
options={[
|
|
1158
|
+
{ value: "yaml", label: "YAML" },
|
|
1159
|
+
{ value: "json", label: "JSON" },
|
|
1160
|
+
]}
|
|
1161
|
+
/>
|
|
1162
|
+
</div>
|
|
1163
|
+
)}
|
|
1164
|
+
<CopyButton text={display} label="Copy tool result" />
|
|
1165
|
+
</div>
|
|
1166
|
+
</div>
|
|
1167
|
+
{html ? (
|
|
1168
|
+
<div
|
|
1169
|
+
className="animate-code-colorize m-3 max-h-[60vh] overflow-auto rounded-md border border-theme-border bg-theme-base p-3 text-xs leading-relaxed [&_pre]:!m-0 [&_pre]:!bg-transparent [&_pre]:font-mono [&_pre]:!text-xs [&_pre]:!leading-relaxed"
|
|
1170
|
+
dangerouslySetInnerHTML={{ __html: html }}
|
|
1171
|
+
/>
|
|
1172
|
+
) : (
|
|
1173
|
+
<pre className="m-3 max-h-[60vh] overflow-auto rounded-md border border-theme-border bg-theme-base p-3 font-mono text-xs leading-relaxed text-theme-text-secondary">
|
|
1174
|
+
{display}
|
|
1175
|
+
</pre>
|
|
1176
|
+
)}
|
|
1177
|
+
</DialogPortal>
|
|
1178
|
+
);
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
function safeYaml(value: unknown): string {
|
|
1182
|
+
try {
|
|
1183
|
+
return toYaml(value, { lineWidth: 0 });
|
|
1184
|
+
} catch {
|
|
1185
|
+
return JSON.stringify(value, null, 2);
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
function compactArgs(raw: string): string {
|
|
1190
|
+
try {
|
|
1191
|
+
const o = JSON.parse(raw);
|
|
1192
|
+
return Object.entries(o)
|
|
1193
|
+
.map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`)
|
|
1194
|
+
.join(" ");
|
|
1195
|
+
} catch {
|
|
1196
|
+
return raw;
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// A follow-up reply: the agent answering a question, not re-diagnosing. Plain
|
|
1201
|
+
// neutral block — no root-cause anchor, no remediation/apply.
|
|
1202
|
+
export function FollowupAnswer({
|
|
1203
|
+
diagnosis,
|
|
1204
|
+
animate,
|
|
1205
|
+
}: {
|
|
1206
|
+
diagnosis: Diagnosis;
|
|
1207
|
+
animate: boolean;
|
|
1208
|
+
}) {
|
|
1209
|
+
const text = diagnosis.report || diagnosis.rootCause;
|
|
1210
|
+
if (!text) return null;
|
|
1211
|
+
return (
|
|
1212
|
+
<div
|
|
1213
|
+
className={`mt-1 rounded-lg border border-theme-border bg-theme-elevated p-3 ${animate ? "animate-result-in" : ""}`}
|
|
1214
|
+
>
|
|
1215
|
+
<div className="mb-1.5 flex items-center justify-between gap-2">
|
|
1216
|
+
<div className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-theme-text-tertiary">
|
|
1217
|
+
<Sparkles className="h-3.5 w-3.5 text-accent" />
|
|
1218
|
+
Answer
|
|
1219
|
+
</div>
|
|
1220
|
+
<CopyButton text={text} label="Copy answer" />
|
|
1221
|
+
</div>
|
|
1222
|
+
<AIMarkdown className="text-sm [overflow-wrap:anywhere] [&_code]:font-normal [&_h2:first-child]:mt-0 [&_h2]:mb-1.5 [&_h2]:mt-3 [&_h2]:text-xs [&_h2]:font-semibold [&_h2]:uppercase [&_h2]:tracking-wide [&_h2]:text-theme-text-tertiary [&_h3]:text-sm [&_li]:text-theme-text-secondary [&_p]:my-1.5 [&_p]:text-theme-text-secondary [&_p:first-child]:mt-0">
|
|
1223
|
+
{text}
|
|
1224
|
+
</AIMarkdown>
|
|
1225
|
+
</div>
|
|
1226
|
+
);
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
// A done turn that produced no renderable result at all (empty diagnosis, no
|
|
1230
|
+
// narration). Without this the turn would render blank — which reads as "the tool
|
|
1231
|
+
// broke." Make the dead-end explicit and point at the recovery (a follow-up).
|
|
1232
|
+
function EmptyResult({ animate }: { animate: boolean }) {
|
|
1233
|
+
return (
|
|
1234
|
+
<div
|
|
1235
|
+
className={`mt-1 flex items-start gap-2 rounded-lg border border-theme-border bg-theme-elevated p-3 text-sm text-theme-text-secondary ${animate ? "animate-result-in" : ""}`}
|
|
1236
|
+
>
|
|
1237
|
+
<ShieldCheck className="mt-0.5 h-4 w-4 shrink-0 text-theme-text-tertiary" />
|
|
1238
|
+
<span>
|
|
1239
|
+
The investigation finished without a clear conclusion. Try a follow-up
|
|
1240
|
+
question, or investigate again.
|
|
1241
|
+
</span>
|
|
1242
|
+
</div>
|
|
1243
|
+
);
|
|
1244
|
+
}
|