@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.
- package/package.json +2 -2
- package/src/App.tsx +6 -4
- package/src/api/client.ts +3 -0
- package/src/api/diagnose.ts +31 -1
- package/src/components/CloudConnectFlow.tsx +173 -13
- package/src/components/CloudFunnelButton.tsx +4 -2
- package/src/components/ConnectionErrorView.tsx +9 -10
- package/src/components/DebugOverlay.tsx +10 -6
- package/src/components/cloudConnectHandoff.test.ts +109 -4
- package/src/components/cloudConnectHandoff.ts +154 -2
- package/src/components/curl/ServiceCurlButton.tsx +19 -26
- package/src/components/diagnose/AgentCase.tsx +18 -5
- package/src/components/diagnose/AnalysisStory.test.tsx +308 -0
- package/src/components/diagnose/AnalysisStory.tsx +564 -0
- package/src/components/diagnose/DiagnoseContext.test.ts +10 -0
- package/src/components/diagnose/DiagnoseContext.tsx +25 -8
- package/src/components/diagnose/DiagnoseSurface.tsx +20 -14
- package/src/components/diagnose/InvestigationEvidencePane.story.test.tsx +771 -0
- package/src/components/diagnose/InvestigationEvidencePane.test.tsx +8 -33
- package/src/components/diagnose/InvestigationEvidencePane.tsx +809 -168
- package/src/components/diagnose/InvestigationResourceEvidence.test.tsx +30 -0
- package/src/components/diagnose/InvestigationResourceEvidence.tsx +20 -0
- package/src/components/diagnose/InvestigationView.tsx +493 -529
- package/src/components/diagnose/investigationCase.test.tsx +267 -129
- package/src/components/diagnose/investigationCase.ts +122 -77
- package/src/components/diagnose/investigationEvidence/adapters/resource.test.ts +27 -0
- package/src/components/diagnose/investigationEvidence/adapters/resource.ts +28 -0
- package/src/components/diagnose/investigationEvidence/builder.ts +11 -2
- package/src/components/diagnose/investigationEvidence/identity.test.ts +25 -7
- package/src/components/diagnose/investigationEvidence/identity.ts +4 -4
- package/src/components/diagnose/investigationEvidence/observations.ts +24 -0
- package/src/components/diagnose/investigationEvidence/projection.test.ts +36 -3
- package/src/components/diagnose/investigationEvidence/types.ts +2 -0
- package/src/components/diagnose/investigationEvidencePresentation.test.ts +2 -0
- package/src/components/diagnose/investigationEvidencePresentation.ts +3 -0
- package/src/components/diagnose/investigationState.test.ts +316 -58
- package/src/components/diagnose/investigationState.ts +257 -44
- package/src/components/diagnose/investigationStory.test.ts +161 -0
- package/src/components/diagnose/investigationStory.ts +207 -0
- package/src/components/diagnose/parts.test.tsx +9 -6
- package/src/components/diagnose/parts.tsx +977 -193
- package/src/components/diagnose/storyParts.test.tsx +426 -0
- package/src/components/diagnose/toolCallLabel.test.ts +51 -0
- package/src/components/diagnose/toolCallLabel.ts +135 -0
- package/src/components/diagnose/useDisclosureReveal.ts +10 -11
- package/src/components/helm/HelmCompareRoute.tsx +18 -4
- package/src/components/helm/HelmReleaseDrawer.tsx +11 -26
- package/src/components/helm/InstallWizard.tsx +8 -3
- package/src/components/home/MCPSetupDialog.tsx +15 -9
- package/src/components/portforward/PortForwardManager.tsx +6 -13
- package/src/components/resource/PrometheusChartsGrid.tsx +3 -3
- package/src/components/resource/WorkloadMetricsHelpDialog.tsx +3 -3
- package/src/components/resource/WorkloadMetricsSection.tsx +15 -21
- package/src/components/resources/PodFilePreview.tsx +3 -3
- package/src/components/resources/renderers/ServiceRenderer.tsx +2 -1
- package/src/components/settings/SettingsDialog.tsx +4 -2
- package/src/components/ui/CommandPalette.tsx +10 -6
- package/src/components/ui/DiagnosticsOverlay.tsx +10 -6
- package/src/components/ui/Disclosure.tsx +47 -0
- package/src/components/ui/Markdown.tsx +23 -11
- package/src/components/ui/ShortcutHelpOverlay.tsx +3 -1
- package/src/components/ui/UpdateNotification.tsx +18 -2
- 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(
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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,
|