@skyhook-io/radar-app 1.14.0 → 1.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/package.json +2 -2
  2. package/src/App.tsx +6 -4
  3. package/src/api/client.ts +3 -0
  4. package/src/api/diagnose.ts +31 -1
  5. package/src/components/CloudConnectFlow.tsx +173 -13
  6. package/src/components/CloudFunnelButton.tsx +4 -2
  7. package/src/components/ConnectionErrorView.tsx +9 -10
  8. package/src/components/DebugOverlay.tsx +10 -6
  9. package/src/components/cloudConnectHandoff.test.ts +109 -4
  10. package/src/components/cloudConnectHandoff.ts +154 -2
  11. package/src/components/curl/ServiceCurlButton.tsx +19 -26
  12. package/src/components/diagnose/AgentCase.tsx +18 -5
  13. package/src/components/diagnose/AnalysisStory.test.tsx +308 -0
  14. package/src/components/diagnose/AnalysisStory.tsx +564 -0
  15. package/src/components/diagnose/DiagnoseContext.test.ts +10 -0
  16. package/src/components/diagnose/DiagnoseContext.tsx +25 -8
  17. package/src/components/diagnose/DiagnoseSurface.tsx +20 -14
  18. package/src/components/diagnose/InvestigationEvidencePane.story.test.tsx +771 -0
  19. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +8 -33
  20. package/src/components/diagnose/InvestigationEvidencePane.tsx +809 -168
  21. package/src/components/diagnose/InvestigationResourceEvidence.test.tsx +30 -0
  22. package/src/components/diagnose/InvestigationResourceEvidence.tsx +20 -0
  23. package/src/components/diagnose/InvestigationView.tsx +493 -529
  24. package/src/components/diagnose/investigationCase.test.tsx +267 -129
  25. package/src/components/diagnose/investigationCase.ts +122 -77
  26. package/src/components/diagnose/investigationEvidence/adapters/resource.test.ts +27 -0
  27. package/src/components/diagnose/investigationEvidence/adapters/resource.ts +28 -0
  28. package/src/components/diagnose/investigationEvidence/builder.ts +11 -2
  29. package/src/components/diagnose/investigationEvidence/identity.test.ts +25 -7
  30. package/src/components/diagnose/investigationEvidence/identity.ts +4 -4
  31. package/src/components/diagnose/investigationEvidence/observations.ts +24 -0
  32. package/src/components/diagnose/investigationEvidence/projection.test.ts +36 -3
  33. package/src/components/diagnose/investigationEvidence/types.ts +2 -0
  34. package/src/components/diagnose/investigationEvidencePresentation.test.ts +2 -0
  35. package/src/components/diagnose/investigationEvidencePresentation.ts +3 -0
  36. package/src/components/diagnose/investigationState.test.ts +316 -58
  37. package/src/components/diagnose/investigationState.ts +257 -44
  38. package/src/components/diagnose/investigationStory.test.ts +161 -0
  39. package/src/components/diagnose/investigationStory.ts +207 -0
  40. package/src/components/diagnose/parts.test.tsx +9 -6
  41. package/src/components/diagnose/parts.tsx +977 -193
  42. package/src/components/diagnose/storyParts.test.tsx +426 -0
  43. package/src/components/diagnose/toolCallLabel.test.ts +51 -0
  44. package/src/components/diagnose/toolCallLabel.ts +135 -0
  45. package/src/components/diagnose/useDisclosureReveal.ts +10 -11
  46. package/src/components/helm/HelmCompareRoute.tsx +18 -4
  47. package/src/components/helm/HelmReleaseDrawer.tsx +11 -26
  48. package/src/components/helm/InstallWizard.tsx +8 -3
  49. package/src/components/home/MCPSetupDialog.tsx +15 -9
  50. package/src/components/portforward/PortForwardManager.tsx +6 -13
  51. package/src/components/resource/PrometheusChartsGrid.tsx +3 -3
  52. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +3 -3
  53. package/src/components/resource/WorkloadMetricsSection.tsx +15 -21
  54. package/src/components/resources/PodFilePreview.tsx +3 -3
  55. package/src/components/resources/renderers/ServiceRenderer.tsx +2 -1
  56. package/src/components/settings/SettingsDialog.tsx +4 -2
  57. package/src/components/ui/CommandPalette.tsx +10 -6
  58. package/src/components/ui/DiagnosticsOverlay.tsx +10 -6
  59. package/src/components/ui/Disclosure.tsx +47 -0
  60. package/src/components/ui/Markdown.tsx +23 -11
  61. package/src/components/ui/ShortcutHelpOverlay.tsx +3 -1
  62. package/src/components/ui/UpdateNotification.tsx +18 -2
  63. package/src/index.css +19 -6
@@ -0,0 +1,426 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+
4
+ import type { Diagnosis } from "../../api/diagnose";
5
+ import { ResultCard, TurnView, assessmentCopyText, type Turn } from "./parts";
6
+
7
+ const storyDiagnosis: Diagnosis = {
8
+ rootCause: "Auth to MongoDB fails after revision 8.",
9
+ summary:
10
+ "The app cannot log in to its database, and it started with the last deploy.",
11
+ certainty: "likely",
12
+ unresolved: ["Whether the Atlas password was rotated on the provider side."],
13
+ report:
14
+ "The container dies on start.\n\n[[radar:evidence=0]]\n\nSo the build changed.",
15
+ remediation: [
16
+ "Roll back to revision 7",
17
+ "Test the stored password with `mongosh`",
18
+ ],
19
+ steps: [
20
+ {
21
+ text: "Roll back to revision 7",
22
+ kind: "mitigate",
23
+ precondition: "revision 7 still authenticates",
24
+ },
25
+ { text: "Test the stored password with `mongosh`", kind: "verify" },
26
+ ],
27
+ recommendedIndex: 1,
28
+ recommendedReason: "reversible",
29
+ confidence: 0.8,
30
+ };
31
+
32
+ describe("ResultCard under the story contract", () => {
33
+ it("leads with the summary, the agent's certainty and what is not established", () => {
34
+ const html = renderToStaticMarkup(
35
+ <ResultCard diagnosis={storyDiagnosis} section="conclusion" />,
36
+ );
37
+ expect(html).toContain("data-assessment-headline");
38
+ expect(html).toContain(
39
+ "The app cannot log in to its database, and it started with the last deploy.",
40
+ );
41
+ expect(html).toContain("Likely");
42
+ expect(html).toContain("Auth to MongoDB fails after revision 8.");
43
+ expect(html).toContain("Still open");
44
+ expect(html).toContain("Whether the Atlas password was rotated");
45
+ // The story is the host's to render with placed cards: no second copy.
46
+ expect(html).not.toContain("Full analysis");
47
+ expect(html).not.toContain("[[radar:evidence=0]]");
48
+ });
49
+
50
+ it("frames adverse Radar cards on a healthy verdict by the agent's position, without a coloured box", () => {
51
+ const healthy = {
52
+ ...storyDiagnosis,
53
+ healthy: true,
54
+ rootCause: "",
55
+ remediation: [],
56
+ unresolved: [],
57
+ };
58
+ const explained = renderToStaticMarkup(
59
+ <ResultCard
60
+ diagnosis={healthy}
61
+ section="conclusion"
62
+ healthSignals={[
63
+ {
64
+ title: "Readiness probe failing",
65
+ status: "explained",
66
+ claim: "never left endpoints",
67
+ sourceId: "s1",
68
+ },
69
+ ]}
70
+ onRevealSource={vi.fn()}
71
+ />,
72
+ );
73
+ expect(explained).toContain("Still open");
74
+ expect(explained).toContain(
75
+ "reads it as not a live problem: never left endpoints",
76
+ );
77
+ expect(explained).not.toContain("data-health-flag");
78
+ expect(explained).not.toContain("conflicts with captured evidence");
79
+ const flagged = renderToStaticMarkup(
80
+ <ResultCard
81
+ diagnosis={healthy}
82
+ section="conclusion"
83
+ healthSignals={[
84
+ { title: "Readiness probe failing", status: "unaddressed" },
85
+ ]}
86
+ />,
87
+ );
88
+ expect(flagged).toContain('data-health-flag="unaddressed"');
89
+ expect(flagged).toContain("no explanation is linked to it");
90
+ });
91
+
92
+ it("keeps the flag banner on a healthy verdict whose story places cards but has no summary", () => {
93
+ const html = renderToStaticMarkup(
94
+ <ResultCard
95
+ diagnosis={{
96
+ ...storyDiagnosis,
97
+ healthy: true,
98
+ summary: "",
99
+ rootCause: "",
100
+ remediation: [],
101
+ unresolved: [],
102
+ }}
103
+ section="conclusion"
104
+ healthSignals={[
105
+ { title: "Readiness probe failing", status: "unaddressed" },
106
+ ]}
107
+ />,
108
+ );
109
+ expect(html).toContain('data-health-flag="unaddressed"');
110
+ expect(html).not.toContain("[[radar:evidence=0]]");
111
+ });
112
+
113
+ it("keeps the inline story on a read-only earlier healthy assessment", () => {
114
+ const html = renderToStaticMarkup(
115
+ <ResultCard
116
+ diagnosis={{
117
+ ...storyDiagnosis,
118
+ healthy: true,
119
+ rootCause: "",
120
+ remediation: [],
121
+ unresolved: [],
122
+ report:
123
+ "The pod is fine.\n\n[[radar:evidence=0]]\n\n" +
124
+ "Detail. ".repeat(60),
125
+ }}
126
+ section="conclusion"
127
+ storyInline
128
+ readOnlyAssessment
129
+ />,
130
+ );
131
+ expect(html).toContain("Full analysis");
132
+ expect(html).not.toContain("[[radar:evidence=0]]");
133
+ });
134
+
135
+ it("lists Radar's own read limits under Still open beside the agent's items", () => {
136
+ const html = renderToStaticMarkup(
137
+ <ResultCard
138
+ diagnosis={{ ...storyDiagnosis, unresolved: [] }}
139
+ section="conclusion"
140
+ assessmentLimits={["Previous logs: could not be read"]}
141
+ />,
142
+ );
143
+ expect(html).toContain("Still open");
144
+ expect(html).toContain("Previous logs: could not be read");
145
+ });
146
+
147
+ it("says nothing when the agent listed nothing unresolved: the certainty word carries it", () => {
148
+ const html = renderToStaticMarkup(
149
+ <ResultCard
150
+ diagnosis={{
151
+ ...storyDiagnosis,
152
+ unresolved: [],
153
+ certainty: "established",
154
+ }}
155
+ section="conclusion"
156
+ />,
157
+ );
158
+ expect(html).not.toContain("nothing unresolved");
159
+ expect(html).not.toContain("data-assessment-unresolved");
160
+ });
161
+
162
+ it("renders typed steps with their kind and precondition and applies only a mitigate step", () => {
163
+ const onApply = vi.fn();
164
+ const html = renderToStaticMarkup(
165
+ <ResultCard
166
+ diagnosis={storyDiagnosis}
167
+ section="actions"
168
+ onApply={onApply}
169
+ />,
170
+ );
171
+ expect(html).toContain('data-step-kind="mitigate"');
172
+ expect(html).toContain('data-step-kind="verify"');
173
+ expect(html).toContain("Only if revision 7 still authenticates");
174
+ const doubled = renderToStaticMarkup(
175
+ <ResultCard
176
+ diagnosis={{
177
+ ...storyDiagnosis,
178
+ steps: [
179
+ {
180
+ ...storyDiagnosis.steps![0],
181
+ precondition: "if the crash is deliberate",
182
+ },
183
+ ],
184
+ remediation: [storyDiagnosis.remediation[0]],
185
+ }}
186
+ section="actions"
187
+ />,
188
+ );
189
+ expect(doubled).toContain("Only if the crash is deliberate");
190
+ expect(html).toContain("Apply…");
191
+ const full = renderToStaticMarkup(
192
+ <ResultCard
193
+ diagnosis={storyDiagnosis}
194
+ section="full"
195
+ onApply={onApply}
196
+ />,
197
+ );
198
+ expect(full).toContain("Next steps");
199
+ expect(full).not.toContain(">Remediation<");
200
+
201
+ const verifyRecommended = renderToStaticMarkup(
202
+ <ResultCard
203
+ diagnosis={{ ...storyDiagnosis, recommendedIndex: 2 }}
204
+ section="actions"
205
+ onApply={onApply}
206
+ />,
207
+ );
208
+ expect(verifyRecommended).not.toContain("Apply…");
209
+
210
+ // Folded alternatives stay readable as one-line rows with their kind.
211
+ const compact = renderToStaticMarkup(
212
+ <ResultCard
213
+ diagnosis={storyDiagnosis}
214
+ section="actions"
215
+ onApply={onApply}
216
+ compactActions
217
+ />,
218
+ );
219
+ expect(compact).toContain('data-step-folded="verify"');
220
+ expect(compact).toContain("Test the stored password with mongosh");
221
+ });
222
+
223
+ it("keeps the previous shape for a diagnosis without the story fields", () => {
224
+ const html = renderToStaticMarkup(
225
+ <ResultCard
226
+ diagnosis={{
227
+ rootCause: "Image tag is invalid.",
228
+ report: "Long analysis.",
229
+ remediation: ["Fix the tag"],
230
+ }}
231
+ section="full"
232
+ />,
233
+ );
234
+ expect(html).toContain("Likely cause");
235
+ expect(html).toContain("Full analysis");
236
+ expect(html).toContain("Remediation");
237
+ expect(html).not.toContain("data-assessment-headline");
238
+ });
239
+
240
+ it("shows an inconclusive verdict's blocking question and its verify steps", () => {
241
+ const html = renderToStaticMarkup(
242
+ <ResultCard
243
+ diagnosis={{
244
+ rootCause: "",
245
+ inconclusive: true,
246
+ summary:
247
+ "Radar could not read the pod logs, so the cause is unknown.",
248
+ unresolved: ["Whether the container logs show an error on start."],
249
+ report: "I could not read logs.",
250
+ remediation: ["Grant `get pods/log` and re-run"],
251
+ steps: [
252
+ { text: "Grant `get pods/log` and re-run", kind: "investigate" },
253
+ ],
254
+ }}
255
+ section="full"
256
+ />,
257
+ );
258
+ expect(html).toContain("What blocked a conclusion");
259
+ expect(html).toContain('data-step-kind="investigate"');
260
+ expect(html).not.toContain("Apply…");
261
+ });
262
+ });
263
+
264
+ describe("assessmentCopyText", () => {
265
+ it("carries Radar's own qualifications so the pasted text is no more confident than the screen", () => {
266
+ const text = assessmentCopyText(storyDiagnosis, {
267
+ limits: ["Change history: incomplete"],
268
+ signals: [
269
+ "Radar flagged Warning event · the agent reads it as not a live problem: probe timeout",
270
+ ],
271
+ flags: [
272
+ "Radar flagged CrashLoopBackOff · no explanation is linked to it",
273
+ ],
274
+ });
275
+ expect(text).toContain(
276
+ "Radar flagged:\n- Radar flagged CrashLoopBackOff · no explanation is linked to it\nRead those cards before treating this as an all-clear.",
277
+ );
278
+ expect(text).toContain("- Change history: incomplete");
279
+ expect(text).toContain(
280
+ "- Radar flagged Warning event · the agent reads it as not a live problem: probe timeout",
281
+ );
282
+ expect(text.indexOf("Radar flagged:")).toBeLessThan(
283
+ text.indexOf("Still open:"),
284
+ );
285
+ });
286
+ it("names the recommended step and counts the rest for a legacy remediation list", () => {
287
+ const text = assessmentCopyText(
288
+ {
289
+ ...storyDiagnosis,
290
+ steps: undefined,
291
+ remediation: ["a", "b", "c"],
292
+ recommendedIndex: 2,
293
+ },
294
+ { context: { target: "Deployment shop/api" } },
295
+ );
296
+ expect(text).toContain("Next step: b");
297
+ expect(text).toContain("2 more steps in Radar.");
298
+ });
299
+
300
+ it("puts the healthy word on the headline and the flags before Still open", () => {
301
+ const text = assessmentCopyText(
302
+ {
303
+ ...storyDiagnosis,
304
+ healthy: true,
305
+ summary: "Fine.",
306
+ rootCause: "",
307
+ unresolved: ["One thing."],
308
+ },
309
+ { flags: ["Radar flagged BackOff · no explanation is linked to it"] },
310
+ );
311
+ expect(text).toContain("**Fine.**");
312
+ expect(text).toContain("Healthy");
313
+ expect(text.indexOf("Radar flagged:")).toBeLessThan(
314
+ text.indexOf("Still open:"),
315
+ );
316
+ });
317
+
318
+ it("copies the headline with its caveats, cause, story and steps", () => {
319
+ const text = assessmentCopyText(storyDiagnosis);
320
+ expect(text).toContain("**The app cannot log in to its database");
321
+ expect(text).toContain("— Likely (agent's word)");
322
+ expect(text).toContain("Still open:\n- Whether the Atlas password");
323
+ expect(text).toContain("Auth to MongoDB fails");
324
+ expect(text).toContain("So the build changed.");
325
+ expect(text).not.toContain("[[radar:evidence=0]]");
326
+ expect(text).toContain(
327
+ "1. [Mitigate] Roll back to revision 7 (only if revision 7 still authenticates)",
328
+ );
329
+ });
330
+
331
+ it("writes a channel-ready note with context, receipts and the one next step", () => {
332
+ const text = assessmentCopyText(storyDiagnosis, {
333
+ context: {
334
+ target: "Deployment shop/api",
335
+ cluster: "prod-east",
336
+ agent: "Claude Code",
337
+ },
338
+ receipts: [
339
+ {
340
+ title: "Current logs · api-7d4 / api",
341
+ role: "Cause",
342
+ lines: ["MongoServerError: Authentication failed.", ""],
343
+ gap: "Only the previous container instance was checked.",
344
+ },
345
+ ],
346
+ });
347
+ expect(
348
+ text.startsWith("**Deployment shop/api** · prod-east · via Claude Code"),
349
+ ).toBe(true);
350
+ expect(text).toContain(
351
+ "Evidence (Radar):\n- **Current logs · api-7d4 / api** · Cause\n > MongoServerError: Authentication failed.\n Not shown: Only the previous container instance was checked.",
352
+ );
353
+ expect(text).toContain("Next step: [Mitigate] Roll back to revision 7");
354
+ expect(text).not.toContain("So the build changed.");
355
+ expect(text).toContain("open the investigation in Radar.");
356
+ });
357
+ });
358
+
359
+ describe("TurnView and assessments", () => {
360
+ const turn = (patch: Partial<Turn>): Turn =>
361
+ ({
362
+ status: "done",
363
+ timeline: [],
364
+ diagnosis: storyDiagnosis,
365
+ ...patch,
366
+ }) as Turn;
367
+
368
+ it("renders a revised question turn as an assessment, not a conversational answer", () => {
369
+ const html = renderToStaticMarkup(
370
+ <TurnView
371
+ turn={turn({ question: "could it be the build?" })}
372
+ assessment
373
+ turnIndex={1}
374
+ />,
375
+ );
376
+ expect(html).toContain("data-assessment-headline");
377
+ expect(html).toContain("Earlier assessment");
378
+ expect(html).not.toContain(">Answer<");
379
+ // Read-only: Apply is never offered on a copy kept for the record.
380
+ expect(html).not.toContain("Apply…");
381
+ // The story is inline here as plain prose, markers stripped.
382
+ expect(html).toContain("Full analysis");
383
+ expect(html).not.toContain("[[radar:evidence=0]]");
384
+ });
385
+
386
+ it("folds the agent's evidence ledger under the turn, out of Findings", () => {
387
+ const html = renderToStaticMarkup(
388
+ <TurnView
389
+ turn={turn({
390
+ diagnosis: {
391
+ rootCause: "x",
392
+ report: "The story.",
393
+ remediation: [],
394
+ notes: "Result A shows one exit; it does not cover the other two.",
395
+ },
396
+ })}
397
+ assessment
398
+ hideConclusion
399
+ turnIndex={0}
400
+ />,
401
+ );
402
+ expect(html).toContain("data-turn-working-notes");
403
+ expect(html).toContain("Evidence considered");
404
+ expect(html).toContain("does not cover the other two");
405
+ expect(html).toContain("Assessment · shown in Findings");
406
+ });
407
+
408
+ it("keeps a pointer for the current assessment and the full answer for a plain question", () => {
409
+ const pointer = renderToStaticMarkup(
410
+ <TurnView turn={turn({})} assessment hideConclusion turnIndex={0} />,
411
+ );
412
+ expect(pointer).toContain("Assessment · shown in Findings");
413
+ expect(pointer).not.toContain("data-assessment-headline");
414
+ const answer = renderToStaticMarkup(
415
+ <TurnView
416
+ turn={turn({
417
+ question: "what is a PDB?",
418
+ diagnosis: { rootCause: "", report: "A budget.", remediation: [] },
419
+ })}
420
+ turnIndex={2}
421
+ />,
422
+ );
423
+ expect(answer).toContain(">Answer<");
424
+ expect(answer).toContain("A budget.");
425
+ });
426
+ });
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { describeToolCall } from "./toolCallLabel";
3
+
4
+ describe("describeToolCall", () => {
5
+ it("never invents a singular from a kind", () => {
6
+ expect(
7
+ describeToolCall(
8
+ "get_resource",
9
+ JSON.stringify({ kind: "Ingress", namespace: "web", name: "edge" }),
10
+ ),
11
+ ).toBe("Reading Ingress web/edge");
12
+ expect(
13
+ describeToolCall("list_resources", JSON.stringify({ kind: "ingresses" })),
14
+ ).toBe("Listing Ingresses");
15
+ });
16
+
17
+ it("names the target from the call's arguments", () => {
18
+ expect(
19
+ describeToolCall(
20
+ "get_resource",
21
+ JSON.stringify({ kind: "deployment", namespace: "shop", name: "api" }),
22
+ ),
23
+ ).toBe("Reading Deployment shop/api");
24
+ expect(
25
+ describeToolCall(
26
+ "get_pod_logs",
27
+ JSON.stringify({
28
+ namespace: "shop",
29
+ name: "api-7d4",
30
+ container: "api",
31
+ previous: true,
32
+ }),
33
+ ),
34
+ ).toBe("Reading logs for pod shop/api-7d4 / api (previous instance)");
35
+ expect(
36
+ describeToolCall(
37
+ "list_resources",
38
+ JSON.stringify({ kind: "configmaps", namespace: "shop" }),
39
+ ),
40
+ ).toBe("Listing Configmaps in shop");
41
+ expect(
42
+ describeToolCall("search", JSON.stringify({ query: "metrics-server" })),
43
+ ).toBe("Searching for “metrics-server”");
44
+ });
45
+ it("falls back to the tool name when arguments are missing or malformed", () => {
46
+ expect(describeToolCall("get_changes", "not json")).toBe(
47
+ "Reading recent changes",
48
+ );
49
+ expect(describeToolCall("some_new_tool", undefined)).toBe("Some New Tool");
50
+ });
51
+ });
@@ -0,0 +1,135 @@
1
+ import { prettyTool } from "./parts";
2
+
3
+ // One readable sentence for a tool call in flight, from the call's arguments:
4
+ // "Reading logs for pod shop/api-7d4 / api" tells the reader what the agent
5
+ // is looking at; "Get Pod Logs" tells them which tool ran.
6
+ export function describeToolCall(tool: string, args?: string): string {
7
+ const a = parseArgs(args);
8
+ const kind = kindLabel(a.kind);
9
+ const target = a.name
10
+ ? `${a.namespace ? `${a.namespace}/` : ""}${a.name}`
11
+ : undefined;
12
+ const scoped = (what: string) =>
13
+ `${what}${a.namespace ? ` in ${a.namespace}` : ""}`;
14
+ switch (tool) {
15
+ case "diagnose":
16
+ return target
17
+ ? `Diagnosing ${kind ?? "resource"} ${target}`
18
+ : "Diagnosing";
19
+ case "get_resource":
20
+ return target
21
+ ? `Reading ${kind ?? "resource"} ${target}`
22
+ : "Reading a resource";
23
+ case "list_resources":
24
+ return scoped(`Listing ${a.kind ? pluralLabel(a.kind) : "resources"}`);
25
+ case "get_events":
26
+ return target ? `Reading events for ${target}` : scoped("Reading events");
27
+ case "get_pod_logs":
28
+ return `Reading logs for pod ${target ?? ""}${a.container ? ` / ${a.container}` : ""}${a.previous ? " (previous instance)" : ""}`.trim();
29
+ case "get_workload_logs":
30
+ return `Reading logs for ${kind ?? "workload"} ${target ?? ""}`.trim();
31
+ case "get_changes":
32
+ return target
33
+ ? `Reading recent changes for ${target}`
34
+ : scoped("Reading recent changes");
35
+ case "issues":
36
+ return scoped(`Listing ${a.kind ? `${kindLabel(a.kind)} ` : ""}issues`);
37
+ case "search":
38
+ return a.query ? `Searching for “${a.query}”` : "Searching";
39
+ case "query_prometheus":
40
+ return "Querying Prometheus";
41
+ case "discover_metrics":
42
+ return "Discovering metrics";
43
+ case "get_prometheus_rules":
44
+ return "Reading alert rules";
45
+ case "get_helm_release":
46
+ return `Reading Helm release ${target ?? ""}`.trim();
47
+ case "list_helm_releases":
48
+ return scoped("Listing Helm releases");
49
+ case "get_neighborhood":
50
+ return target
51
+ ? `Mapping what ${kind ?? "resource"} ${target} connects to`
52
+ : "Mapping connections";
53
+ case "get_topology":
54
+ return scoped("Mapping the topology");
55
+ case "get_subject_permissions":
56
+ return target
57
+ ? `Checking what ${kind ?? "subject"} ${target} may do`
58
+ : "Checking permissions";
59
+ case "list_namespaces":
60
+ return "Listing namespaces";
61
+ case "list_packages":
62
+ return a.chart
63
+ ? `Looking up packages matching “${a.chart}”`
64
+ : "Looking up packages";
65
+ case "top_resources":
66
+ return "Ranking resources";
67
+ case "get_dashboard":
68
+ return "Reading the cluster dashboard";
69
+ case "get_cluster_audit":
70
+ return "Running the cluster audit";
71
+ case "get_cluster_upgrade_readiness":
72
+ return "Checking upgrade readiness";
73
+ case "apply_resource":
74
+ return "Applying the change";
75
+ case "patch_resource":
76
+ return target
77
+ ? `Patching ${kind ?? "resource"} ${target}`
78
+ : "Patching a resource";
79
+ case "manage_workload":
80
+ case "manage_rollout":
81
+ case "manage_node":
82
+ case "manage_gitops":
83
+ case "manage_cronjob":
84
+ return target
85
+ ? `Changing ${kind ?? "resource"} ${target}`
86
+ : "Changing a resource";
87
+ default:
88
+ return prettyTool(tool);
89
+ }
90
+ }
91
+
92
+ interface ToolArgs {
93
+ kind?: string;
94
+ namespace?: string;
95
+ name?: string;
96
+ container?: string;
97
+ previous?: boolean;
98
+ query?: string;
99
+ chart?: string;
100
+ }
101
+
102
+ function parseArgs(args: string | undefined): ToolArgs {
103
+ if (!args) return {};
104
+ try {
105
+ const raw = JSON.parse(args) as Record<string, unknown>;
106
+ const str = (key: string) =>
107
+ typeof raw[key] === "string" && (raw[key] as string).trim()
108
+ ? (raw[key] as string).trim()
109
+ : undefined;
110
+ return {
111
+ kind: str("kind"),
112
+ namespace: str("namespace"),
113
+ name: str("name"),
114
+ container: str("container"),
115
+ previous: raw.previous === true,
116
+ query: str("query"),
117
+ chart: str("chart"),
118
+ };
119
+ } catch {
120
+ return {};
121
+ }
122
+ }
123
+
124
+ // Arguments carry kinds as "deployment", "Deployment" or "configmaps".
125
+ // Capitalise and otherwise leave the word alone: stripping an "s" turns
126
+ // Ingress into Ingres.
127
+ function kindLabel(kind: string | undefined): string | undefined {
128
+ if (!kind) return undefined;
129
+ return kind.charAt(0).toUpperCase() + kind.slice(1);
130
+ }
131
+
132
+ function pluralLabel(kind: string): string {
133
+ const base = kindLabel(kind) ?? kind;
134
+ return base.endsWith("s") ? base : `${base}s`;
135
+ }
@@ -1,16 +1,15 @@
1
1
  import { useCallback, useLayoutEffect, useRef } from "react";
2
+ import {
3
+ DURATION_DISCLOSURE,
4
+ prefersReducedMotion as sharedPrefersReducedMotion,
5
+ } from "@skyhook-io/k8s-ui/utils/animation";
2
6
 
3
- // Shared Collapse uses a 200 ms grid-row transition. Keep a small paint margin
4
- // before moving focus so the destination is stationary. Reduced-motion users get
5
- // an immediate disclosure and focus hand-off because Collapse disables motion.
6
- export const INVESTIGATION_DISCLOSURE_SETTLE_MS = 220;
7
- export function prefersReducedMotion(): boolean {
8
- return (
9
- typeof window !== "undefined" &&
10
- typeof window.matchMedia === "function" &&
11
- window.matchMedia("(prefers-reduced-motion: reduce)").matches
12
- );
13
- }
7
+ // Shared Collapse runs DURATION_DISCLOSURE. Keep a small paint margin before
8
+ // moving focus so the destination is stationary; derived from the token so a
9
+ // timing change cannot leave this stale. Reduced-motion users get an immediate
10
+ // disclosure and focus hand-off because Collapse disables motion.
11
+ export const INVESTIGATION_DISCLOSURE_SETTLE_MS = DURATION_DISCLOSURE + 20;
12
+ export const prefersReducedMotion = sharedPrefersReducedMotion;
14
13
 
15
14
  export function investigationDisclosureSettleDelay(
16
15
  reducedMotion: boolean,