@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,564 @@
1
+ import { useEffect, useId, useMemo, useState, type ReactNode } from "react";
2
+ import { clsx } from "clsx";
3
+ import { TRANSITION_CHEVRON } from "@skyhook-io/k8s-ui/utils/animation";
4
+ import { AlertTriangle, ChevronDown, FileSearch } from "lucide-react";
5
+ import { Tooltip } from "../ui/Tooltip";
6
+ import { Collapse } from "@skyhook-io/k8s-ui";
7
+
8
+ import { Markdown } from "../ui/Markdown";
9
+ import type { InvestigationCaseItem } from "./investigationCase";
10
+ import {
11
+ MAX_STORY_PLACEMENTS,
12
+ splitStory,
13
+ storyReferenceIndex,
14
+ type StorySegment,
15
+ type StoryRefResolver,
16
+ } from "./investigationStory";
17
+
18
+ /**
19
+ * Why a placement could not render. Each is shown at the sentence it belongs
20
+ * to — a vanished marker would leave the assertion standing with no sign that
21
+ * its support failed — and counted once per evidence item, however many
22
+ * markers point at it.
23
+ */
24
+ export type StoryPlacementLoss =
25
+ | "invalid" // no such index, or not a number Radar recognises
26
+ | "unlinked" // the server could not bind the item's ref
27
+ | "unplaced" // bound, but resolves to no single observation
28
+ | { kind: "nocard"; sourceId: string }; // bound, but Radar renders no card for that call
29
+
30
+ export interface StoryPlacementTarget {
31
+ item: InvestigationCaseItem;
32
+ /** DOM id of the card rendered for this placement, or of the inventory card when it was not placed. */
33
+ domId: string;
34
+ }
35
+
36
+ type StoryPlacementResolution =
37
+ | { kind: "placed"; target: StoryPlacementTarget }
38
+ | { kind: "reference"; target: StoryPlacementTarget }
39
+ | { kind: "lost"; reason: StoryPlacementLoss };
40
+
41
+ function isLoss(
42
+ value: StoryPlacementTarget | StoryPlacementLoss,
43
+ ): value is StoryPlacementLoss {
44
+ return typeof value === "string" || "kind" in value;
45
+ }
46
+
47
+ /**
48
+ * Decides what every marker in the story renders as: the first block marker
49
+ * for an observation places its card, later block markers and every inline
50
+ * marker reference it, and anything unresolvable is a visible loss. Dedupe is
51
+ * by resolved observation, not by index — two items can address one read.
52
+ */
53
+ export function resolveStoryPlacements(
54
+ report: string,
55
+ resolveItem: (index: number) => StoryPlacementTarget | StoryPlacementLoss,
56
+ resolveRef?: StoryRefResolver,
57
+ ): {
58
+ segments: StorySegment[];
59
+ byIndex: Map<number, StoryPlacementResolution>;
60
+ placedAt: Map<number, number>;
61
+ placedCount: number;
62
+ lostItems: number;
63
+ } {
64
+ const { segments: written } = splitStory(report, resolveRef);
65
+ const byIndex = new Map<number, StoryPlacementResolution>();
66
+ // The segment position that renders each placed index's card; a repeated
67
+ // marker for the same index is a reference back to it, not a second card.
68
+ const placedAt = new Map<number, number>();
69
+ const lostIndexes = new Set<number>();
70
+ let placedCount = 0;
71
+ // A marker on its own line places its card there. A marker inside a
72
+ // sentence places the card under that paragraph when nothing else does:
73
+ // the reader wants the receipt beside the claim, and whether the agent
74
+ // put the marker on its own line is a formatting detail the page must not
75
+ // depend on. An index the agent does place on its own line somewhere keeps
76
+ // that spot, so an early inline mention of it stays a reference.
77
+ const blockIndexes = new Set(
78
+ written
79
+ .filter((segment) => segment.kind === "placement")
80
+ .map((segment) => (segment as { index: number }).index),
81
+ );
82
+ const target = (index: number): StoryPlacementTarget | undefined => {
83
+ const existing = byIndex.get(index);
84
+ if (existing) return existing.kind === "lost" ? undefined : existing.target;
85
+ const resolved = resolveItem(index);
86
+ if (isLoss(resolved)) {
87
+ byIndex.set(index, { kind: "lost", reason: resolved });
88
+ lostIndexes.add(index);
89
+ return undefined;
90
+ }
91
+ byIndex.set(index, { kind: "reference", target: resolved });
92
+ return resolved;
93
+ };
94
+ // Revisions count within a group, so one call's resource, logs and events
95
+ // share a source and a revision; the group tells them apart.
96
+ const observationKey = (index: number, resolved: StoryPlacementTarget) =>
97
+ resolved.item.observation
98
+ ? `${resolved.item.groupId ?? ""}|${resolved.item.observation.source.id}#${resolved.item.observation.revision}`
99
+ : `item-${index}`;
100
+ // Two items on one observation (a twin) share its card: the second points
101
+ // at the card the first placed, wherever that is.
102
+ const positionByKey = new Map<string, number>();
103
+ const place = (
104
+ index: number,
105
+ resolved: StoryPlacementTarget,
106
+ position: number,
107
+ ): boolean => {
108
+ const key = observationKey(index, resolved);
109
+ const twin = positionByKey.get(key);
110
+ if (twin !== undefined) {
111
+ placedAt.set(index, twin);
112
+ return false;
113
+ }
114
+ if (placedCount >= MAX_STORY_PLACEMENTS) return false;
115
+ positionByKey.set(key, position);
116
+ placedCount += 1;
117
+ placedAt.set(index, position);
118
+ byIndex.set(index, { kind: "placed", target: resolved });
119
+ return true;
120
+ };
121
+ // An observation the agent places on its own line somewhere keeps that
122
+ // spot even when a twin item on it is mentioned inline first.
123
+ const blockKeys = new Set<string>();
124
+ for (const index of blockIndexes) {
125
+ const resolved = target(index);
126
+ if (resolved) blockKeys.add(observationKey(index, resolved));
127
+ }
128
+ const segments: StorySegment[] = [];
129
+ for (const segment of written) {
130
+ if (segment.kind === "placement") {
131
+ const resolved = target(segment.index);
132
+ const position = segments.length;
133
+ segments.push(segment);
134
+ if (resolved && !placedAt.has(segment.index))
135
+ place(segment.index, resolved, position);
136
+ continue;
137
+ }
138
+ let markdown = segment.markdown;
139
+ const autoPlaced: number[] = [];
140
+ const autoCompact = new Set<number>();
141
+ for (const match of markdown.matchAll(
142
+ /\[\[\[radar:evidence([^\]]*)\]\]\]\(#radar-evidence-(-?\d+)\)/g,
143
+ )) {
144
+ const index = Number(match[2]);
145
+ if (match[1].endsWith("|compact")) autoCompact.add(index);
146
+ const resolved = target(index);
147
+ if (
148
+ !resolved ||
149
+ blockIndexes.has(index) ||
150
+ blockKeys.has(observationKey(index, resolved)) ||
151
+ placedAt.has(index)
152
+ )
153
+ continue;
154
+ // The card lands right under this paragraph, so the sentence keeps its
155
+ // words and drops the marker: the card's own title says what it is.
156
+ if (place(index, resolved, segments.length + 1 + autoPlaced.length))
157
+ autoPlaced.push(index);
158
+ }
159
+ for (const index of autoPlaced)
160
+ markdown = removeInlineMarker(markdown, index);
161
+ segments.push({ kind: "prose", markdown });
162
+ for (const index of autoPlaced)
163
+ segments.push({
164
+ kind: "placement",
165
+ index,
166
+ auto: true,
167
+ ...(autoCompact.has(index) ? { compact: true } : {}),
168
+ });
169
+ }
170
+ // A twin item that was only ever mentioned inline still points at the card
171
+ // its sibling placed.
172
+ for (const [index, resolution] of byIndex) {
173
+ if (resolution.kind !== "reference" || placedAt.has(index)) continue;
174
+ const at = positionByKey.get(observationKey(index, resolution.target));
175
+ if (at !== undefined) placedAt.set(index, at);
176
+ }
177
+ return {
178
+ segments,
179
+ byIndex,
180
+ placedAt,
181
+ placedCount,
182
+ lostItems: lostIndexes.size,
183
+ };
184
+ }
185
+
186
+ // Only the hole the marker leaves is tidied: a space before punctuation or a
187
+ // doubled space there. The rest of the paragraph, including a two-space hard
188
+ // line break, is the agent's.
189
+ function removeInlineMarker(markdown: string, index: number): string {
190
+ const match = new RegExp(
191
+ `\\[\\[\\[radar:evidence[^\\]]*\\]\\]\\]\\(#radar-evidence-${index}\\)`,
192
+ ).exec(markdown);
193
+ if (!match) return markdown;
194
+ let before = markdown.slice(0, match.index);
195
+ let after = markdown.slice(match.index + match[0].length);
196
+ // Spaces and tabs only: a newline, and the two spaces of a hard break
197
+ // before it, are the agent's.
198
+ if (/^[ \t]{2,}\n/.test(after)) before = before.replace(/[ \t]+$/, "");
199
+ else if (
200
+ /[ \t]$/.test(before) &&
201
+ (/^[,.;:!?]/.test(after) || after === "" || after.startsWith("\n"))
202
+ )
203
+ before = before.replace(/[ \t]+$/, "");
204
+ else if (
205
+ /^[ \t]/.test(after) &&
206
+ (/[ \t]$/.test(before) || before === "" || before.endsWith("\n"))
207
+ )
208
+ after = after.replace(/^[ \t]+/, "");
209
+ return before + after;
210
+ }
211
+
212
+ const LOSS_COPY: Record<Exclude<StoryPlacementLoss, object>, string> = {
213
+ invalid: "The agent cited a result Radar cannot identify.",
214
+ unlinked: "Radar could not verify this citation against its record.",
215
+ unplaced:
216
+ "Radar could not pin this citation to one result; see Captured results.",
217
+ };
218
+
219
+ /**
220
+ * A citation that cannot render at the claim still says so at the claim. A
221
+ * bound result Radar has no card for is the common case — a cluster-wide
222
+ * search, a metrics table — and is one click from its raw result in
223
+ * Activity, so that case carries the link rather than a dead end.
224
+ */
225
+ function LostSupport({
226
+ reason,
227
+ onViewSource,
228
+ }: {
229
+ reason: StoryPlacementLoss;
230
+ onViewSource?: (sourceId: string) => void;
231
+ }) {
232
+ if (typeof reason !== "string") {
233
+ return (
234
+ // A result with no card is not a loss the reader must weigh; the
235
+ // sentence above already makes its claim. One quiet link to the raw
236
+ // result, in the register of an inline reference.
237
+ <span
238
+ role="note"
239
+ data-story-lost-support="nocard"
240
+ className="inline-flex items-center gap-1 text-[11px] text-theme-text-tertiary"
241
+ >
242
+ <FileSearch className="h-3 w-3 shrink-0" aria-hidden />
243
+ {onViewSource ? (
244
+ <button
245
+ type="button"
246
+ onClick={() => onViewSource(reason.sourceId)}
247
+ className="hover:text-accent-text hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
248
+ >
249
+ Result in Activity
250
+ </button>
251
+ ) : (
252
+ "Result in Activity"
253
+ )}
254
+ </span>
255
+ );
256
+ }
257
+ // Loud enough to notice, quiet enough to read past: the reason on hover.
258
+ return (
259
+ <Tooltip content={LOSS_COPY[reason]} wrapperClassName="align-baseline">
260
+ <span
261
+ role="note"
262
+ data-story-lost-support={reason}
263
+ className="inline-flex items-center gap-1 rounded border border-dashed border-theme-border px-1 py-px align-baseline text-[11px] text-theme-text-tertiary"
264
+ >
265
+ <AlertTriangle
266
+ className="h-3 w-3 shrink-0 text-amber-500"
267
+ aria-hidden
268
+ />
269
+ unverified
270
+ </span>
271
+ </Tooltip>
272
+ );
273
+ }
274
+
275
+ function ReferenceChip({
276
+ target,
277
+ onReveal,
278
+ direction = "up",
279
+ }: {
280
+ target: StoryPlacementTarget;
281
+ onReveal: (target: StoryPlacementTarget) => void;
282
+ /** Where the card is from here: above, below, or in Captured results. */
283
+ direction?: "up" | "down";
284
+ }) {
285
+ const title = target.item.observation?.title ?? "the cited result";
286
+ return (
287
+ <button
288
+ type="button"
289
+ onClick={() => onReveal(target)}
290
+ data-story-ref-direction={direction}
291
+ className="inline-flex max-w-full items-baseline gap-1 rounded border border-theme-border bg-theme-base px-1.5 py-px align-baseline text-[11px] font-medium text-accent-text hover:bg-theme-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
292
+ aria-label={`Show ${title}`}
293
+ >
294
+ <span aria-hidden>{direction === "down" ? "↓" : "↑"}</span>
295
+ <span className="truncate">{title}</span>
296
+ </button>
297
+ );
298
+ }
299
+
300
+ const STORY_PROSE_CLASS =
301
+ "text-sm leading-relaxed text-theme-text-primary [overflow-wrap:anywhere] [&_code]:font-normal [&_h2]:mb-1 [&_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-primary [&_p]:my-1.5 [&_p]:text-theme-text-primary [&_p:first-child]:mt-0 [&_p:last-child]:mb-0";
302
+
303
+ /**
304
+ * The agent's story with Radar's results placed where it cites them. The
305
+ * prose is the agent's; every placed card is rendered by the host from Radar's
306
+ * record, so a reader checks each sentence against the fact beneath it.
307
+ *
308
+ * Collapsed by default to a bounded preview: the paragraph before the first
309
+ * placed card, line-clamped, plus that card in compact form — never a sliced
310
+ * card, and never more than the agent's prose decides. The fold bounds the
311
+ * scroll to the next steps below it; it does not decide what matters, which is
312
+ * why the summary and the unresolved list live above it.
313
+ */
314
+ export function AnalysisStory({
315
+ report,
316
+ resolveItem,
317
+ resolveRef,
318
+ renderPlacement,
319
+ onReveal,
320
+ onViewSource,
321
+ trailing,
322
+ openRequest = 0,
323
+ className,
324
+ }: {
325
+ report: string;
326
+ resolveItem: (index: number) => StoryPlacementTarget | StoryPlacementLoss;
327
+ /** Maps a ledger ref an agent wrote as a marker to its evidence index. */
328
+ resolveRef?: StoryRefResolver;
329
+ /** Renders the card for a placed item; `compact` when it sits in the collapsed preview. */
330
+ renderPlacement: (
331
+ target: StoryPlacementTarget,
332
+ options: { compact: boolean },
333
+ ) => ReactNode;
334
+ onReveal: (target: StoryPlacementTarget) => void;
335
+ /** Opens a cited call's raw result in Activity when Radar renders no card for it. */
336
+ onViewSource?: (sourceId: string) => void;
337
+ /** Rendered at the end of the full analysis, behind the fold. */
338
+ trailing?: ReactNode;
339
+ /** Bumped by the host when a reveal needs the story open first. */
340
+ openRequest?: number;
341
+ className?: string;
342
+ }) {
343
+ // An open requested before mount opens the story from its first render.
344
+ const [open, setOpen] = useState(openRequest > 0);
345
+ // The wide layout is measured after mount; when it asks for the story open,
346
+ // open it. Narrowing never closes what the reader opened.
347
+ useEffect(() => {
348
+ if (openRequest > 0) setOpen(true);
349
+ }, [openRequest]);
350
+ const regionId = useId();
351
+ const story = useMemo(
352
+ () => resolveStoryPlacements(report, resolveItem, resolveRef),
353
+ [report, resolveItem, resolveRef],
354
+ );
355
+ const firstPlacedAt = story.segments.findIndex(
356
+ (segment) =>
357
+ segment.kind === "placement" &&
358
+ story.byIndex.get(segment.index)?.kind === "placed",
359
+ );
360
+ // The preview is the prose immediately before the first placed card plus
361
+ // that card; with nothing placed, the first prose block. Everything else
362
+ // is what "Expand" reveals.
363
+ const firstProseAt = story.segments.findIndex(
364
+ (segment) => segment.kind === "prose",
365
+ );
366
+ // With nothing placed, the preview is the first paragraph — and the
367
+ // placement right after it when there is one, so a citation Radar could
368
+ // not render is visible as such rather than hidden behind the fold.
369
+ const previewEnd =
370
+ firstPlacedAt >= 0
371
+ ? firstPlacedAt
372
+ : firstProseAt >= 0 &&
373
+ story.segments[firstProseAt + 1]?.kind === "placement"
374
+ ? firstProseAt + 1
375
+ : firstProseAt;
376
+ const previewStart =
377
+ firstPlacedAt > 0 && story.segments[firstPlacedAt - 1]?.kind === "prose"
378
+ ? firstPlacedAt - 1
379
+ : firstPlacedAt >= 0
380
+ ? firstPlacedAt
381
+ : Math.max(firstProseAt, 0);
382
+ const hiddenSegments =
383
+ story.segments.length - (previewEnd - previewStart + 1);
384
+ const foldable = hiddenSegments > 0 || previewStart > 0 || !!trailing;
385
+ const linkRendererAt = (position: number) => (href: string | undefined) => {
386
+ const index = storyReferenceIndex(href);
387
+ if (index === undefined) return null;
388
+ const resolution = story.byIndex.get(index);
389
+ if (!resolution) return null;
390
+ if (resolution.kind === "lost")
391
+ return (
392
+ <LostSupport reason={resolution.reason} onViewSource={onViewSource} />
393
+ );
394
+ const at = story.placedAt.get(index);
395
+ return (
396
+ <ReferenceChip
397
+ target={resolution.target}
398
+ onReveal={onReveal}
399
+ direction={at === undefined || at > position ? "down" : "up"}
400
+ />
401
+ );
402
+ };
403
+ const renderSegment = (
404
+ segment: StorySegment,
405
+ position: number,
406
+ compact: boolean,
407
+ ) => {
408
+ if (segment.kind === "prose") {
409
+ const prose = (
410
+ <Markdown
411
+ key={`prose-${position}`}
412
+ className={clsx(STORY_PROSE_CLASS, compact && "[&_p]:line-clamp-6")}
413
+ linkRenderer={linkRendererAt(position)}
414
+ >
415
+ {segment.markdown}
416
+ </Markdown>
417
+ );
418
+ return prose;
419
+ }
420
+ const resolution = story.byIndex.get(segment.index);
421
+ if (!resolution || resolution.kind === "lost") {
422
+ return (
423
+ <div key={`lost-${position}`}>
424
+ <LostSupport
425
+ reason={resolution?.reason ?? "invalid"}
426
+ onViewSource={onViewSource}
427
+ />
428
+ </div>
429
+ );
430
+ }
431
+ const at = story.placedAt.get(segment.index);
432
+ if (resolution.kind === "reference" || at !== position) {
433
+ return (
434
+ <div key={`ref-${position}`}>
435
+ <ReferenceChip
436
+ target={resolution.target}
437
+ onReveal={onReveal}
438
+ direction={at === undefined || at > position ? "down" : "up"}
439
+ />
440
+ </div>
441
+ );
442
+ }
443
+ return (
444
+ <div
445
+ key={`placed-${position}`}
446
+ data-story-placement={segment.index}
447
+ data-story-placement-compact={segment.compact ? "1" : undefined}
448
+ >
449
+ {renderPlacement(resolution.target, {
450
+ compact: compact || segment.compact === true,
451
+ })}
452
+ </div>
453
+ );
454
+ };
455
+ // The preview stays mounted in both states; what the fold hides sits in
456
+ // animated collapses on either side of it, mounted on first open so the
457
+ // collapsed page carries no hidden prose.
458
+ const before = story.segments
459
+ .slice(0, previewStart)
460
+ .map((segment, position) => renderSegment(segment, position, false));
461
+ const folded = foldable && !open;
462
+ const preview = story.segments
463
+ .slice(previewStart, previewEnd + 1)
464
+ .map((segment, offset) =>
465
+ renderSegment(segment, previewStart + offset, folded),
466
+ );
467
+ // The hint that more follows: the first two lines of the hidden paragraph
468
+ // after the preview, faded out. It points at what the fold hides instead of
469
+ // greying text the reader can already see.
470
+ const teaser =
471
+ folded && story.segments[previewEnd + 1]?.kind === "prose"
472
+ ? story.segments[previewEnd + 1]
473
+ : undefined;
474
+ const after = story.segments
475
+ .slice(previewEnd + 1)
476
+ .map((segment, offset) =>
477
+ renderSegment(segment, previewEnd + 1 + offset, false),
478
+ );
479
+ return (
480
+ <section
481
+ aria-label="Analysis"
482
+ data-story
483
+ data-story-open={open || !foldable ? "true" : "false"}
484
+ className={clsx("space-y-2", className)}
485
+ >
486
+ <div id={regionId}>
487
+ {before.length > 0 ? (
488
+ <Collapse open={open} mountLazily>
489
+ <div className="space-y-2 pb-2">{before}</div>
490
+ </Collapse>
491
+ ) : null}
492
+ <div className="space-y-2">{preview}</div>
493
+ {teaser && teaser.kind === "prose" ? (
494
+ // The inert inner block keeps the teaser out of the tab order and
495
+ // hit-testing; the click lands on this wrapper instead.
496
+ <div
497
+ data-story-teaser
498
+ className="relative mt-2 cursor-pointer"
499
+ onClick={() => setOpen(true)}
500
+ >
501
+ <div
502
+ aria-hidden
503
+ inert
504
+ className="relative max-h-12 overflow-hidden"
505
+ >
506
+ <Markdown
507
+ className={clsx(STORY_PROSE_CLASS, "[&_p]:line-clamp-2")}
508
+ linkRenderer={(href) => {
509
+ const index = storyReferenceIndex(href);
510
+ const resolution =
511
+ index === undefined ? undefined : story.byIndex.get(index);
512
+ return (
513
+ <span>
514
+ {resolution && resolution.kind !== "lost"
515
+ ? (resolution.target.item.observation?.title ?? "")
516
+ : ""}
517
+ </span>
518
+ );
519
+ }}
520
+ >
521
+ {teaser.markdown}
522
+ </Markdown>
523
+ <div className="pointer-events-none absolute inset-x-0 bottom-0 h-10 bg-gradient-to-t from-[var(--color-investigation-evidence)] to-transparent" />
524
+ </div>
525
+ </div>
526
+ ) : null}
527
+ {after.length > 0 || trailing ? (
528
+ <Collapse open={open} mountLazily>
529
+ <div className="space-y-2 pt-2">
530
+ {after}
531
+ {trailing}
532
+ </div>
533
+ </Collapse>
534
+ ) : null}
535
+ </div>
536
+ {story.lostItems > 0 ? (
537
+ <p className="text-[11px] text-theme-text-tertiary">
538
+ {story.lostItems === 1
539
+ ? "1 cited result could not be shown here."
540
+ : `${story.lostItems} cited results could not be shown here.`}
541
+ </p>
542
+ ) : null}
543
+ {foldable ? (
544
+ <button
545
+ type="button"
546
+ aria-expanded={open}
547
+ aria-controls={regionId}
548
+ onClick={() => setOpen(!open)}
549
+ className="flex w-full items-center justify-center gap-1.5 rounded-md py-1.5 text-xs font-medium text-accent-text hover:bg-theme-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
550
+ >
551
+ <ChevronDown
552
+ aria-hidden
553
+ className={clsx(
554
+ "h-3.5 w-3.5",
555
+ TRANSITION_CHEVRON,
556
+ open && "rotate-180",
557
+ )}
558
+ />
559
+ {open ? "Collapse" : "Expand"}
560
+ </button>
561
+ ) : null}
562
+ </section>
563
+ );
564
+ }
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import {
3
+ investigationPanelFillsViewport,
3
4
  investigationWorkspaceNavigationState,
4
5
  investigationWorkspacePath,
5
6
  investigationWorkspaceRestorePath,
@@ -93,3 +94,12 @@ describe("investigation workspace routes", () => {
93
94
  expect(workspaceRunIDFromPath("/investigations/%ZZ")).toBeNull();
94
95
  });
95
96
  });
97
+
98
+ describe("investigationPanelFillsViewport", () => {
99
+ it("fills the content area once less than a useful strip of the app would show beside the drawer", () => {
100
+ expect(investigationPanelFillsViewport(1440, 560)).toBe(false);
101
+ expect(investigationPanelFillsViewport(900, 560)).toBe(false);
102
+ expect(investigationPanelFillsViewport(856, 560)).toBe(true);
103
+ expect(investigationPanelFillsViewport(1200, 1000)).toBe(true);
104
+ });
105
+ });
@@ -116,6 +116,7 @@ interface DiagnoseLayoutCtx {
116
116
  dismissForNavigation: () => void;
117
117
  contentGutter: number; // px right-gutter for the content area when docked (0 = overlay/closed)
118
118
  maximized: boolean;
119
+ fullWidthForced: boolean; // the viewport cannot show the app beside the panel
119
120
  setMaximized: Dispatch<SetStateAction<boolean>>;
120
121
  panelWidth: number;
121
122
  setPanelWidth: Dispatch<SetStateAction<number>>;
@@ -161,6 +162,17 @@ const EFFORT_KEY = "radar-ai-effort";
161
162
  // panel width and could push the app to near-zero. We don't fight to keep every-
162
163
  // thing on screen on small displays — below this, the panel floats over instead.
163
164
  const MIN_APP_LEFT_OF_PANEL = 900;
165
+ // An overlay that leaves less than this much of the app visible beside it
166
+ // hides what it overlays without showing more of itself; fill the content
167
+ // area instead. Radar's own minimum width is about 856px, so this is the
168
+ // drawer's floor: below it the investigation is the screen.
169
+ const MIN_APP_PEEK_BESIDE_OVERLAY = 320;
170
+ export function investigationPanelFillsViewport(
171
+ viewportWidth: number,
172
+ panelWidth: number,
173
+ ): boolean {
174
+ return viewportWidth - panelWidth < MIN_APP_PEEK_BESIDE_OVERLAY;
175
+ }
164
176
 
165
177
  const AGENT_LABELS: Record<string, string> = {
166
178
  claude: "Claude Code",
@@ -249,8 +261,7 @@ function safeWorkspaceReturn(
249
261
  if (typeof value !== "string" || !value.startsWith("/")) return null;
250
262
  try {
251
263
  const url = new URL(value, origin);
252
- if (url.origin !== origin || url.pathname.startsWith("//"))
253
- return null;
264
+ if (url.origin !== origin || url.pathname.startsWith("//")) return null;
254
265
  return `${url.pathname}${url.search}${url.hash}`;
255
266
  } catch {
256
267
  return null;
@@ -430,6 +441,7 @@ function RoutedDiagnoseProvider({
430
441
  );
431
442
  // Too tight to push (given the current, resizable panel width) → overlay instead.
432
443
  const narrow = viewportW - width < MIN_APP_LEFT_OF_PANEL;
444
+ const fullWidthForced = investigationPanelFillsViewport(viewportW, width);
433
445
 
434
446
  useEffect(() => {
435
447
  let live = true;
@@ -661,12 +673,15 @@ function RoutedDiagnoseProvider({
661
673
  const startRunRef = useRef<(t: Target) => void>(() => {});
662
674
  startRunRef.current = (t: Target) => {
663
675
  const seq = ++startSeqRef.current;
664
- createRun({ ...t, kind: knownKindForPluralWithGroup(t.kind, t.group) ?? t.kind }, {
665
- agent: selectedAgent || undefined,
666
- profile: hosted ? undefined : effectiveProfile,
667
- model: model || undefined,
668
- effort: effort || undefined,
669
- })
676
+ createRun(
677
+ { ...t, kind: knownKindForPluralWithGroup(t.kind, t.group) ?? t.kind },
678
+ {
679
+ agent: selectedAgent || undefined,
680
+ profile: hosted ? undefined : effectiveProfile,
681
+ model: model || undefined,
682
+ effort: effort || undefined,
683
+ },
684
+ )
670
685
  .then((run) => {
671
686
  setRuns((prev) =>
672
687
  prev.some((r) => r.id === run.id) ? prev : [run, ...prev],
@@ -1042,6 +1057,7 @@ function RoutedDiagnoseProvider({
1042
1057
  dismissForNavigation,
1043
1058
  contentGutter,
1044
1059
  maximized,
1060
+ fullWidthForced,
1045
1061
  setMaximized,
1046
1062
  panelWidth: width,
1047
1063
  setPanelWidth: setWidth,
@@ -1057,6 +1073,7 @@ function RoutedDiagnoseProvider({
1057
1073
  dismissForNavigation,
1058
1074
  contentGutter,
1059
1075
  maximized,
1076
+ fullWidthForced,
1060
1077
  width,
1061
1078
  narrow,
1062
1079
  runningKeys,