@skyhook-io/radar-app 1.14.2 → 1.14.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/api/client.ts +30 -0
- package/src/components/CloudConnectFlow.tsx +21 -7
- package/src/components/CloudFunnelButton.tsx +85 -8
- package/src/components/cloudConnectHandoff.test.ts +10 -1
- package/src/components/cloudConnectHandoff.ts +15 -1
- package/src/components/diagnose/AIMarkdown.tsx +87 -0
- package/src/components/diagnose/ActivityTurn.tsx +1244 -0
- package/src/components/diagnose/AgentControls.tsx +603 -0
- package/src/components/diagnose/AnalysisStory.test.tsx +31 -25
- package/src/components/diagnose/AnalysisStory.tsx +13 -18
- package/src/components/diagnose/ApplyDialog.tsx +289 -0
- package/src/components/diagnose/AssessmentCard.tsx +1560 -0
- package/src/components/diagnose/DiagnoseSurface.test.tsx +9 -9
- package/src/components/diagnose/DiagnoseSurface.tsx +5 -1
- package/src/components/diagnose/EvidenceCard.tsx +611 -0
- package/src/components/diagnose/Home.test.tsx +1 -3
- package/src/components/diagnose/Home.tsx +2 -1
- package/src/components/diagnose/InvestigationEvidencePane.story.test.tsx +6 -8
- package/src/components/diagnose/InvestigationEvidencePane.test.tsx +5 -3
- package/src/components/diagnose/InvestigationEvidencePane.tsx +31 -3445
- package/src/components/diagnose/InvestigationEvidenceSections.tsx +523 -0
- package/src/components/diagnose/InvestigationView.tsx +11 -28
- package/src/components/diagnose/StoryExcerpt.tsx +118 -0
- package/src/components/diagnose/assessmentCopy.ts +169 -0
- package/src/components/diagnose/investigationCase.test.tsx +631 -2
- package/src/components/diagnose/investigationCase.ts +132 -24
- package/src/components/diagnose/investigationEvidence/adapters/coverage.test.tsx +660 -0
- package/src/components/diagnose/investigationEvidence/adapters/{helm.test.ts → helm.test.tsx} +63 -0
- package/src/components/diagnose/investigationEvidence/adapters/helm.ts +70 -1
- package/src/components/diagnose/investigationEvidence/adapters/listings.ts +134 -0
- package/src/components/diagnose/investigationEvidence/adapters/{permissions.test.ts → permissions.test.tsx} +50 -0
- package/src/components/diagnose/investigationEvidence/adapters/permissions.ts +18 -0
- package/src/components/diagnose/investigationEvidence/adapters/posture.ts +272 -0
- package/src/components/diagnose/investigationEvidence/adapters/prometheus.ts +49 -0
- package/src/components/diagnose/investigationEvidence/adapters/rankings.ts +147 -0
- package/src/components/diagnose/investigationEvidence/adapters/resource.ts +20 -10
- package/src/components/diagnose/investigationEvidence/bodies/index.tsx +127 -0
- package/src/components/diagnose/investigationEvidence/bodies/inventory.tsx +158 -0
- package/src/components/diagnose/investigationEvidence/bodies/metrics.tsx +373 -0
- package/src/components/diagnose/investigationEvidence/bodies/network.tsx +274 -0
- package/src/components/diagnose/investigationEvidence/bodies/platform.tsx +475 -0
- package/src/components/diagnose/investigationEvidence/bodies/posture.tsx +60 -0
- package/src/components/diagnose/investigationEvidence/bodies/ranking.tsx +76 -0
- package/src/components/diagnose/investigationEvidence/bodies/streams.tsx +212 -0
- package/src/components/diagnose/investigationEvidence/bodies/workload.tsx +335 -0
- package/src/components/diagnose/investigationEvidence/builder.ts +15 -0
- package/src/components/diagnose/investigationEvidence/cardParts.tsx +312 -0
- package/src/components/diagnose/investigationEvidence/excerpt.test.ts +105 -0
- package/src/components/diagnose/investigationEvidence/excerpt.ts +92 -0
- package/src/components/diagnose/investigationEvidence/identity.test.ts +5 -7
- package/src/components/diagnose/investigationEvidence/index.ts +1 -1
- package/src/components/diagnose/investigationEvidence/navigation.tsx +26 -0
- package/src/components/diagnose/investigationEvidence/observations.ts +11 -0
- package/src/components/diagnose/investigationEvidence/projection.test.ts +1 -1
- package/src/components/diagnose/investigationEvidence/types.ts +97 -2
- package/src/components/diagnose/investigationEvidenceKinds.ts +22 -0
- package/src/components/diagnose/investigationEvidencePartition.ts +296 -0
- package/src/components/diagnose/parts.test.tsx +3 -1
- package/src/components/diagnose/parts.tsx +26 -3904
- package/src/components/diagnose/target.ts +9 -3
- package/src/components/diagnose/toolCallLabel.test.ts +47 -1
- package/src/components/diagnose/toolCallLabel.ts +41 -2
- package/src/components/helm/HelmReleaseDrawer.tsx +1 -1
- package/src/components/home/MCPSetupDialog.tsx +1 -1
- package/src/components/resource/PrometheusChartsGrid.tsx +1 -1
- package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
- package/src/components/resource/WorkloadMetricsSection.tsx +1 -1
- package/src/components/resources/PodFilePreview.tsx +1 -1
- package/src/components/ui/CommandPalette.tsx +12 -8
- package/src/components/ui/DiagnosticsOverlay.tsx +7 -5
- package/src/components/ui/Omnibar.tsx +33 -10
- package/src/components/ui/ShortcutHelpOverlay.tsx +8 -5
- package/src/components/ui/Disclosure.tsx +0 -47
|
@@ -69,7 +69,9 @@ export type InvestigationEvidenceKind =
|
|
|
69
69
|
| "alerts"
|
|
70
70
|
| "helm"
|
|
71
71
|
| "permissions"
|
|
72
|
-
| "metrics"
|
|
72
|
+
| "metrics"
|
|
73
|
+
| "ranking"
|
|
74
|
+
| "posture";
|
|
73
75
|
export type InvestigationSemanticDomain = "issue" | "startup" | "crash" | "dns";
|
|
74
76
|
export interface InvestigationEvidenceSource {
|
|
75
77
|
/** DOM-safe stable identity derived from turn index + the agent step ID. */
|
|
@@ -138,6 +140,8 @@ export interface InvestigationResourceSummary {
|
|
|
138
140
|
name: string;
|
|
139
141
|
namespace?: string;
|
|
140
142
|
status?: string;
|
|
143
|
+
/** For a search hit: the field that matched and the text around it. */
|
|
144
|
+
match?: string;
|
|
141
145
|
ready?: string;
|
|
142
146
|
issue?: string;
|
|
143
147
|
age?: string;
|
|
@@ -265,6 +269,74 @@ export interface InvestigationHelmRelease {
|
|
|
265
269
|
managedByFluxHelmRelease?: string;
|
|
266
270
|
lastOperation?: InvestigationHelmOperation;
|
|
267
271
|
resources: InvestigationHelmOwnedResource[];
|
|
272
|
+
/** Newest first; the tool returns the release's stored revisions. */
|
|
273
|
+
history?: InvestigationHelmRevision[];
|
|
274
|
+
hooks?: InvestigationHelmHook[];
|
|
275
|
+
/** User-supplied values as the tool returned them, secrets already redacted. */
|
|
276
|
+
values?: Record<string, unknown>;
|
|
277
|
+
}
|
|
278
|
+
export interface InvestigationHelmRevision {
|
|
279
|
+
revision: number;
|
|
280
|
+
status: string;
|
|
281
|
+
chart: string;
|
|
282
|
+
appVersion?: string;
|
|
283
|
+
description?: string;
|
|
284
|
+
updated: string;
|
|
285
|
+
}
|
|
286
|
+
export interface InvestigationHelmHook {
|
|
287
|
+
name: string;
|
|
288
|
+
kind: string;
|
|
289
|
+
namespace?: string;
|
|
290
|
+
events: string[];
|
|
291
|
+
weight: number;
|
|
292
|
+
status?: string;
|
|
293
|
+
startedAt?: string;
|
|
294
|
+
completedAt?: string;
|
|
295
|
+
}
|
|
296
|
+
/** One row of a live-metrics ranking, as the tool returned it. */
|
|
297
|
+
export interface InvestigationRankingRow {
|
|
298
|
+
kind: string;
|
|
299
|
+
namespace?: string;
|
|
300
|
+
name: string;
|
|
301
|
+
cpu: string;
|
|
302
|
+
memory: string;
|
|
303
|
+
cpuLimit?: string;
|
|
304
|
+
memoryLimit?: string;
|
|
305
|
+
restarts?: number;
|
|
306
|
+
ready?: string;
|
|
307
|
+
status?: string;
|
|
308
|
+
/** The investigated resource itself, or one of its own pods. */
|
|
309
|
+
target: boolean;
|
|
310
|
+
/** The workload a ranked pod belongs to, when the tool reports one. */
|
|
311
|
+
owner?: { kind: string; group?: string; namespace?: string; name: string };
|
|
312
|
+
}
|
|
313
|
+
/** A configuration or upgrade finding about one resource. */
|
|
314
|
+
export interface InvestigationPostureFinding {
|
|
315
|
+
kind: string;
|
|
316
|
+
group?: string;
|
|
317
|
+
namespace?: string;
|
|
318
|
+
name: string;
|
|
319
|
+
check: string;
|
|
320
|
+
severity: string;
|
|
321
|
+
category?: string;
|
|
322
|
+
message: string;
|
|
323
|
+
remediation?: string;
|
|
324
|
+
/** The investigated resource itself. */
|
|
325
|
+
target: boolean;
|
|
326
|
+
/** The workload that owns the finding's resource, when the scan names one. */
|
|
327
|
+
managedBy?: {
|
|
328
|
+
kind: string;
|
|
329
|
+
group?: string;
|
|
330
|
+
namespace?: string;
|
|
331
|
+
name: string;
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
export interface InvestigationPermissionRule {
|
|
335
|
+
verbs: string[];
|
|
336
|
+
apiGroups: string[];
|
|
337
|
+
resources: string[];
|
|
338
|
+
resourceNames?: string[];
|
|
339
|
+
nonResourceURLs?: string[];
|
|
268
340
|
}
|
|
269
341
|
export interface InvestigationPermissionSubject {
|
|
270
342
|
kind: string;
|
|
@@ -377,10 +449,31 @@ export type InvestigationEvidenceData =
|
|
|
377
449
|
resources: InvestigationResourceSummary[];
|
|
378
450
|
scope: string;
|
|
379
451
|
}
|
|
452
|
+
| {
|
|
453
|
+
type: "ranking";
|
|
454
|
+
/** What was ranked: pods, workloads or nodes. */
|
|
455
|
+
kind: string;
|
|
456
|
+
sort: string;
|
|
457
|
+
rows: InvestigationRankingRow[];
|
|
458
|
+
scope: string;
|
|
459
|
+
}
|
|
460
|
+
| {
|
|
461
|
+
type: "posture";
|
|
462
|
+
source: "audit" | "upgrade";
|
|
463
|
+
findings: InvestigationPostureFinding[];
|
|
464
|
+
scope: string;
|
|
465
|
+
}
|
|
380
466
|
| {
|
|
381
467
|
type: "receipt";
|
|
382
468
|
checked:
|
|
383
|
-
|
|
469
|
+
| "issues"
|
|
470
|
+
| "events"
|
|
471
|
+
| "changes"
|
|
472
|
+
| "inventory"
|
|
473
|
+
| "logs"
|
|
474
|
+
| "alerts"
|
|
475
|
+
| "metrics"
|
|
476
|
+
| "posture";
|
|
384
477
|
scope: string;
|
|
385
478
|
/**
|
|
386
479
|
* Only when there is something to add. The card already shows the title
|
|
@@ -405,6 +498,8 @@ export type InvestigationEvidenceData =
|
|
|
405
498
|
/** Present for the subject-permissions response shape. */
|
|
406
499
|
bindings?: InvestigationPermissionBinding[];
|
|
407
500
|
flatRulesCount?: number;
|
|
501
|
+
/** The effective rules themselves, up to the tool's cap. */
|
|
502
|
+
rules?: InvestigationPermissionRule[];
|
|
408
503
|
truncated?: boolean;
|
|
409
504
|
usedByPods?: string[];
|
|
410
505
|
podsTotal?: number;
|
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
Package,
|
|
15
15
|
ScrollText,
|
|
16
16
|
ShieldAlert,
|
|
17
|
+
ShieldCheck,
|
|
18
|
+
BarChart3,
|
|
17
19
|
type LucideIcon,
|
|
18
20
|
} from "lucide-react";
|
|
19
21
|
|
|
@@ -154,6 +156,26 @@ export const EVIDENCE_KIND_TRAITS: Readonly<
|
|
|
154
156
|
icon: ListTree,
|
|
155
157
|
caveat: undefined,
|
|
156
158
|
},
|
|
159
|
+
// A ranking is live numbers at the moment of the read; the target's own row
|
|
160
|
+
// is a fact about it, the rest is where it stands among neighbours.
|
|
161
|
+
ranking: {
|
|
162
|
+
adverse: false,
|
|
163
|
+
focused: false,
|
|
164
|
+
fullRow: true,
|
|
165
|
+
icon: BarChart3,
|
|
166
|
+
caveat:
|
|
167
|
+
"Ranked by live metrics at the moment of the read; a position is not a cause.",
|
|
168
|
+
},
|
|
169
|
+
// Posture findings are configuration checks, never an active failure; they
|
|
170
|
+
// stay in their own lane so a check is not read as an outage.
|
|
171
|
+
posture: {
|
|
172
|
+
adverse: false,
|
|
173
|
+
focused: false,
|
|
174
|
+
fullRow: true,
|
|
175
|
+
icon: ShieldCheck,
|
|
176
|
+
caveat:
|
|
177
|
+
"Posture findings are configuration checks, not evidence of an active failure.",
|
|
178
|
+
},
|
|
157
179
|
// A receipt records a check that found nothing, so it can never contradict.
|
|
158
180
|
receipt: {
|
|
159
181
|
adverse: false,
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EVIDENCE_KIND_TRAITS,
|
|
3
|
+
evidenceKindIsFocused,
|
|
4
|
+
} from "./investigationEvidenceKinds";
|
|
5
|
+
import { evidenceDisplaySnapshot } from "./investigationEvidencePresentation";
|
|
6
|
+
import {
|
|
7
|
+
investigationSourceArgs,
|
|
8
|
+
type InvestigationEvidenceData,
|
|
9
|
+
type InvestigationEvidenceGroup,
|
|
10
|
+
type InvestigationEvidenceObservation,
|
|
11
|
+
type InvestigationEvidenceProjection,
|
|
12
|
+
type InvestigationRootCauseEvidenceResolution,
|
|
13
|
+
type InvestigationEvidenceSource,
|
|
14
|
+
} from "./investigationEvidence";
|
|
15
|
+
import type {
|
|
16
|
+
InvestigationCaseItem,
|
|
17
|
+
InvestigationCaseResolution,
|
|
18
|
+
} from "./investigationCase";
|
|
19
|
+
import type { DiagnosisEvidenceRole } from "../../api/diagnose";
|
|
20
|
+
|
|
21
|
+
/** Item identity for the excluded-hypothesis lookup; index alone repeats across turns. */
|
|
22
|
+
export function investigationCaseItemKey(item: InvestigationCaseItem): string {
|
|
23
|
+
return `${item.source.id}\u0000${item.index}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const EVIDENCE_ROLE_RANK: Readonly<Record<DiagnosisEvidenceRole, number>> = {
|
|
27
|
+
cause: 0,
|
|
28
|
+
symptom: 1,
|
|
29
|
+
// A ruled-out card keeps Radar's place in the list; the role only labels it.
|
|
30
|
+
rules_out: 2,
|
|
31
|
+
context: 3,
|
|
32
|
+
benign: 4,
|
|
33
|
+
demoted: 5,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const UNLABELLED_RANK = 2;
|
|
37
|
+
|
|
38
|
+
function sourceNamesOneResource(source: InvestigationEvidenceSource): boolean {
|
|
39
|
+
const args = investigationSourceArgs(source);
|
|
40
|
+
return (
|
|
41
|
+
typeof args?.kind === "string" &&
|
|
42
|
+
args.kind !== "" &&
|
|
43
|
+
typeof args.name === "string" &&
|
|
44
|
+
args.name !== ""
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function investigationCaseByGroup(
|
|
49
|
+
investigationCase?: InvestigationCaseResolution,
|
|
50
|
+
): Map<string, InvestigationCaseItem[]> {
|
|
51
|
+
const byGroup = new Map<string, InvestigationCaseItem[]>();
|
|
52
|
+
for (const item of investigationCase?.items ?? []) {
|
|
53
|
+
if (!item.groupId || item.placement === "source") continue;
|
|
54
|
+
const items = byGroup.get(item.groupId) ?? [];
|
|
55
|
+
items.push(item);
|
|
56
|
+
byGroup.set(item.groupId, items);
|
|
57
|
+
}
|
|
58
|
+
return byGroup;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function evidenceTypePrefersFullRow(
|
|
62
|
+
type: InvestigationEvidenceData["type"],
|
|
63
|
+
): boolean {
|
|
64
|
+
return EVIDENCE_KIND_TRAITS[type].fullRow;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Supporting evidence becomes a two-column grid when the pane is wide enough.
|
|
68
|
+
// Keep compact cards paired only with an adjacent compact card. Otherwise a
|
|
69
|
+
// full-row card between them strands a conspicuous empty half-row (and makes the
|
|
70
|
+
// visual order look accidental), as does an odd card at the end of a run.
|
|
71
|
+
export function investigationEvidenceFullRowFlags(
|
|
72
|
+
types: readonly InvestigationEvidenceData["type"][],
|
|
73
|
+
): boolean[] {
|
|
74
|
+
const fullRow = types.map(evidenceTypePrefersFullRow);
|
|
75
|
+
let compactRunStart = 0;
|
|
76
|
+
|
|
77
|
+
for (let index = 0; index <= types.length; index += 1) {
|
|
78
|
+
if (index < types.length && !fullRow[index]) continue;
|
|
79
|
+
const compactRunLength = index - compactRunStart;
|
|
80
|
+
if (compactRunLength % 2 === 1) fullRow[index - 1] = true;
|
|
81
|
+
compactRunStart = index + 1;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return fullRow;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
type EvidenceCollection = "main" | "workload" | "earlier";
|
|
88
|
+
|
|
89
|
+
export function partitionInvestigationEvidence(
|
|
90
|
+
groups: InvestigationEvidenceGroup[],
|
|
91
|
+
resolution?: InvestigationRootCauseEvidenceResolution,
|
|
92
|
+
investigationCase?: InvestigationCaseResolution,
|
|
93
|
+
/**
|
|
94
|
+
* Under a story every captured result about the target belongs in the one
|
|
95
|
+
* inventory the reader opens to see what Radar recorded; nothing about the
|
|
96
|
+
* target is folded into a second tier. Broader results stay cited-only.
|
|
97
|
+
*/
|
|
98
|
+
inventory = false,
|
|
99
|
+
) {
|
|
100
|
+
// Selection: legacy root-cause links plus every placed agent item, of any
|
|
101
|
+
// role (placement promotes a group into main the way a citation does; the
|
|
102
|
+
// role does not matter here). Ordering below is the only place roles act,
|
|
103
|
+
// and nothing the agent sends can remove a group from a collection.
|
|
104
|
+
const caseByGroup = investigationCaseByGroup(investigationCase);
|
|
105
|
+
const selected = new Set([
|
|
106
|
+
...(resolution?.status === "linked"
|
|
107
|
+
? resolution.links.map((link) => link.originalGroupId)
|
|
108
|
+
: []),
|
|
109
|
+
...caseByGroup.keys(),
|
|
110
|
+
]);
|
|
111
|
+
const collections: Record<EvidenceCollection, InvestigationEvidenceGroup[]> =
|
|
112
|
+
{
|
|
113
|
+
main: [],
|
|
114
|
+
workload: [],
|
|
115
|
+
earlier: [],
|
|
116
|
+
};
|
|
117
|
+
const collectionByGroup = new Map<string, EvidenceCollection>();
|
|
118
|
+
const adverse = (group: InvestigationEvidenceGroup) =>
|
|
119
|
+
["error", "alert", "warning"].includes(group.latest.tone);
|
|
120
|
+
// Broader cards are facts about something other than the target. They are
|
|
121
|
+
// withheld unless cited, and the pane says how many were withheld so a
|
|
122
|
+
// reader knows the agent looked at things it did not build its case on.
|
|
123
|
+
let hiddenBroader = 0;
|
|
124
|
+
// Broader metrics are facts about something other than the target. They are
|
|
125
|
+
// withheld unless cited, and the pane says how many were withheld so a
|
|
126
|
+
// reader knows the agent ran queries it did not build its case on.
|
|
127
|
+
let hiddenMetrics = 0;
|
|
128
|
+
for (const group of groups) {
|
|
129
|
+
const broader = group.latest.relevance === "broader";
|
|
130
|
+
// Citations select tool results, not individual rows in a broad search.
|
|
131
|
+
// Only a focused, unambiguous fact can be promoted by a source citation.
|
|
132
|
+
// An agent item names this observation when its subject does, or when the
|
|
133
|
+
// call it cites was itself scoped to one resource; a subject-less item on
|
|
134
|
+
// a broad query only proves the query returned one row and is gated like
|
|
135
|
+
// a citation of its source.
|
|
136
|
+
const namedByAgent = (caseByGroup.get(group.id) ?? []).some(
|
|
137
|
+
(item) => item.subject || sourceNamesOneResource(item.source),
|
|
138
|
+
);
|
|
139
|
+
if (broader && !namedByAgent) {
|
|
140
|
+
const citingSource =
|
|
141
|
+
resolution?.links.find((item) => item.originalGroupId === group.id)
|
|
142
|
+
?.source ?? caseByGroup.get(group.id)?.[0]?.source;
|
|
143
|
+
const focused = evidenceKindIsFocused(group.latest.data.type);
|
|
144
|
+
const sourceGroups = citingSource
|
|
145
|
+
? groups.filter(
|
|
146
|
+
(candidate) =>
|
|
147
|
+
evidenceKindIsFocused(candidate.latest.data.type) &&
|
|
148
|
+
candidate.observations.some(
|
|
149
|
+
(observation) => observation.source.id === citingSource.id,
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
: [];
|
|
153
|
+
if (!selected.has(group.id) || !focused || sourceGroups.length !== 1) {
|
|
154
|
+
// The two counts head separate lines in the pane, so they have to be
|
|
155
|
+
// disjoint: a withheld chart announced by both would read as two
|
|
156
|
+
// withheld results.
|
|
157
|
+
if (!group.historical) {
|
|
158
|
+
if (group.latest.data.type === "metrics") hiddenMetrics += 1;
|
|
159
|
+
else hiddenBroader += 1;
|
|
160
|
+
}
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const main =
|
|
165
|
+
!group.historical &&
|
|
166
|
+
(selected.has(group.id) ||
|
|
167
|
+
(!broader &&
|
|
168
|
+
(inventory ||
|
|
169
|
+
group.latest.tier === "key" ||
|
|
170
|
+
(group.latest.tier === "supporting" && adverse(group)))));
|
|
171
|
+
const collection = main
|
|
172
|
+
? "main"
|
|
173
|
+
: group.historical
|
|
174
|
+
? "earlier"
|
|
175
|
+
: "workload";
|
|
176
|
+
collections[collection].push(group);
|
|
177
|
+
collectionByGroup.set(group.id, collection);
|
|
178
|
+
}
|
|
179
|
+
// A group takes its strongest role (lowest rank); within one role the
|
|
180
|
+
// agent's own item order decides. Unlabelled and ruled-out groups keep
|
|
181
|
+
// Radar's order among themselves.
|
|
182
|
+
const roleOrder = (group: InvestigationEvidenceGroup) => {
|
|
183
|
+
let rank: number | undefined;
|
|
184
|
+
let itemIndex = Number.POSITIVE_INFINITY;
|
|
185
|
+
for (const item of caseByGroup.get(group.id) ?? []) {
|
|
186
|
+
const itemRank = EVIDENCE_ROLE_RANK[item.role];
|
|
187
|
+
if (rank === undefined || itemRank < rank) {
|
|
188
|
+
rank = itemRank;
|
|
189
|
+
itemIndex = item.index;
|
|
190
|
+
} else if (itemRank === rank) {
|
|
191
|
+
itemIndex = Math.min(itemIndex, item.index);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (rank === undefined || rank === UNLABELLED_RANK) {
|
|
195
|
+
return { rank: UNLABELLED_RANK, itemIndex: Number.POSITIVE_INFINITY };
|
|
196
|
+
}
|
|
197
|
+
return { rank, itemIndex };
|
|
198
|
+
};
|
|
199
|
+
collections.main.sort((left, right) => {
|
|
200
|
+
const leftRole = roleOrder(left);
|
|
201
|
+
const rightRole = roleOrder(right);
|
|
202
|
+
const agentOrder =
|
|
203
|
+
Number.isFinite(leftRole.itemIndex) ||
|
|
204
|
+
Number.isFinite(rightRole.itemIndex)
|
|
205
|
+
? leftRole.itemIndex - rightRole.itemIndex
|
|
206
|
+
: 0;
|
|
207
|
+
return (
|
|
208
|
+
leftRole.rank - rightRole.rank ||
|
|
209
|
+
agentOrder ||
|
|
210
|
+
Number(right.latest.tier === "key") -
|
|
211
|
+
Number(left.latest.tier === "key") ||
|
|
212
|
+
Number(adverse(right)) - Number(adverse(left)) ||
|
|
213
|
+
left.firstOrder - right.firstOrder
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
// A healthy workload's collection opens with several "nothing here"
|
|
217
|
+
// receipts; the cards that carry facts (its vitals above all) come first,
|
|
218
|
+
// and the receipts keep their order among themselves.
|
|
219
|
+
collections.workload.sort(
|
|
220
|
+
(left, right) =>
|
|
221
|
+
Number(left.kind === "receipt") - Number(right.kind === "receipt"),
|
|
222
|
+
);
|
|
223
|
+
return { ...collections, collectionByGroup, hiddenBroader, hiddenMetrics };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function investigationEvidenceRevealCollection(
|
|
227
|
+
projection: InvestigationEvidenceProjection,
|
|
228
|
+
sourceId: string,
|
|
229
|
+
partition = partitionInvestigationEvidence(projection.groups),
|
|
230
|
+
): Exclude<EvidenceCollection, "main"> | "coverage" | undefined {
|
|
231
|
+
const source = projection.sources.find((item) => item.id === sourceId);
|
|
232
|
+
const collection = source?.primaryGroupId
|
|
233
|
+
? partition.collectionByGroup.get(source.primaryGroupId)
|
|
234
|
+
: undefined;
|
|
235
|
+
if (collection) return collection === "main" ? undefined : collection;
|
|
236
|
+
if (
|
|
237
|
+
projection.limitations.some((limitation) =>
|
|
238
|
+
limitation.sources.some((source) => source.id === sourceId),
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
return "coverage";
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function previousDifferentObservations(
|
|
246
|
+
group: InvestigationEvidenceGroup,
|
|
247
|
+
citedOrder?: number,
|
|
248
|
+
boundOrders: ReadonlySet<number> = new Set(),
|
|
249
|
+
) {
|
|
250
|
+
const seen = new Set([evidenceDisplaySnapshot(group.latest)]);
|
|
251
|
+
const pinned = (observation: InvestigationEvidenceObservation) =>
|
|
252
|
+
observation.source.order === citedOrder ||
|
|
253
|
+
boundOrders.has(observation.source.order);
|
|
254
|
+
return [...group.observations].reverse().filter((observation) => {
|
|
255
|
+
if (observation === group.latest) return false;
|
|
256
|
+
// An observation bound to an agent item always keeps its own row: display
|
|
257
|
+
// equivalence is a folding rule for unpinned rechecks, never a reason to
|
|
258
|
+
// move a claim onto different content.
|
|
259
|
+
if (boundOrders.has(observation.source.order)) return true;
|
|
260
|
+
// A pinned observation wins over an unpinned twin with the same display
|
|
261
|
+
// content.
|
|
262
|
+
if (
|
|
263
|
+
!pinned(observation) &&
|
|
264
|
+
group.observations.some(
|
|
265
|
+
(other) =>
|
|
266
|
+
pinned(other) &&
|
|
267
|
+
evidenceDisplaySnapshot(other) ===
|
|
268
|
+
evidenceDisplaySnapshot(observation),
|
|
269
|
+
)
|
|
270
|
+
)
|
|
271
|
+
return false;
|
|
272
|
+
const snapshot = evidenceDisplaySnapshot(observation);
|
|
273
|
+
if (seen.has(snapshot)) return false;
|
|
274
|
+
seen.add(snapshot);
|
|
275
|
+
return true;
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function investigationEvidenceShouldRevealHistory(
|
|
280
|
+
group: InvestigationEvidenceGroup,
|
|
281
|
+
sourceId?: string,
|
|
282
|
+
/** Sources whose observation carries an agent item and so always has a row. */
|
|
283
|
+
boundSourceIds: ReadonlySet<string> = new Set(),
|
|
284
|
+
): boolean {
|
|
285
|
+
return (
|
|
286
|
+
Boolean(sourceId) &&
|
|
287
|
+
group.latest.source.id !== sourceId &&
|
|
288
|
+
group.observations.some(
|
|
289
|
+
(observation) =>
|
|
290
|
+
observation.source.id === sourceId &&
|
|
291
|
+
(boundSourceIds.has(observation.source.id) ||
|
|
292
|
+
evidenceDisplaySnapshot(observation) !==
|
|
293
|
+
evidenceDisplaySnapshot(group.latest)),
|
|
294
|
+
)
|
|
295
|
+
);
|
|
296
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
2
|
+
import { TW_EASE_UI } from "../../utils/animation";
|
|
2
3
|
import { describe, expect, it, vi } from "vitest";
|
|
3
4
|
import {
|
|
4
5
|
AgentControls,
|
|
@@ -349,8 +350,9 @@ describe("Timeline reasoning density", () => {
|
|
|
349
350
|
);
|
|
350
351
|
|
|
351
352
|
expect(html).toContain("height:47px");
|
|
353
|
+
// The clamp animates on the shared disclosure clock, not a local timing.
|
|
352
354
|
expect(html).toContain(
|
|
353
|
-
|
|
355
|
+
`transition-[height] duration-300 ${TW_EASE_UI} motion-reduce:transition-none`,
|
|
354
356
|
);
|
|
355
357
|
expect(html).not.toContain("animate-transcript-enter");
|
|
356
358
|
expect(html).not.toContain("Show reasoning");
|