@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.
Files changed (74) hide show
  1. package/package.json +2 -2
  2. package/src/api/client.ts +30 -0
  3. package/src/components/CloudConnectFlow.tsx +21 -7
  4. package/src/components/CloudFunnelButton.tsx +85 -8
  5. package/src/components/cloudConnectHandoff.test.ts +10 -1
  6. package/src/components/cloudConnectHandoff.ts +15 -1
  7. package/src/components/diagnose/AIMarkdown.tsx +87 -0
  8. package/src/components/diagnose/ActivityTurn.tsx +1244 -0
  9. package/src/components/diagnose/AgentControls.tsx +603 -0
  10. package/src/components/diagnose/AnalysisStory.test.tsx +31 -25
  11. package/src/components/diagnose/AnalysisStory.tsx +13 -18
  12. package/src/components/diagnose/ApplyDialog.tsx +289 -0
  13. package/src/components/diagnose/AssessmentCard.tsx +1560 -0
  14. package/src/components/diagnose/DiagnoseSurface.test.tsx +9 -9
  15. package/src/components/diagnose/DiagnoseSurface.tsx +5 -1
  16. package/src/components/diagnose/EvidenceCard.tsx +611 -0
  17. package/src/components/diagnose/Home.test.tsx +1 -3
  18. package/src/components/diagnose/Home.tsx +2 -1
  19. package/src/components/diagnose/InvestigationEvidencePane.story.test.tsx +6 -8
  20. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +5 -3
  21. package/src/components/diagnose/InvestigationEvidencePane.tsx +31 -3445
  22. package/src/components/diagnose/InvestigationEvidenceSections.tsx +523 -0
  23. package/src/components/diagnose/InvestigationView.tsx +11 -28
  24. package/src/components/diagnose/StoryExcerpt.tsx +118 -0
  25. package/src/components/diagnose/assessmentCopy.ts +169 -0
  26. package/src/components/diagnose/investigationCase.test.tsx +631 -2
  27. package/src/components/diagnose/investigationCase.ts +132 -24
  28. package/src/components/diagnose/investigationEvidence/adapters/coverage.test.tsx +660 -0
  29. package/src/components/diagnose/investigationEvidence/adapters/{helm.test.ts → helm.test.tsx} +63 -0
  30. package/src/components/diagnose/investigationEvidence/adapters/helm.ts +70 -1
  31. package/src/components/diagnose/investigationEvidence/adapters/listings.ts +134 -0
  32. package/src/components/diagnose/investigationEvidence/adapters/{permissions.test.ts → permissions.test.tsx} +50 -0
  33. package/src/components/diagnose/investigationEvidence/adapters/permissions.ts +18 -0
  34. package/src/components/diagnose/investigationEvidence/adapters/posture.ts +272 -0
  35. package/src/components/diagnose/investigationEvidence/adapters/prometheus.ts +49 -0
  36. package/src/components/diagnose/investigationEvidence/adapters/rankings.ts +147 -0
  37. package/src/components/diagnose/investigationEvidence/adapters/resource.ts +20 -10
  38. package/src/components/diagnose/investigationEvidence/bodies/index.tsx +127 -0
  39. package/src/components/diagnose/investigationEvidence/bodies/inventory.tsx +158 -0
  40. package/src/components/diagnose/investigationEvidence/bodies/metrics.tsx +373 -0
  41. package/src/components/diagnose/investigationEvidence/bodies/network.tsx +274 -0
  42. package/src/components/diagnose/investigationEvidence/bodies/platform.tsx +475 -0
  43. package/src/components/diagnose/investigationEvidence/bodies/posture.tsx +60 -0
  44. package/src/components/diagnose/investigationEvidence/bodies/ranking.tsx +76 -0
  45. package/src/components/diagnose/investigationEvidence/bodies/streams.tsx +212 -0
  46. package/src/components/diagnose/investigationEvidence/bodies/workload.tsx +335 -0
  47. package/src/components/diagnose/investigationEvidence/builder.ts +15 -0
  48. package/src/components/diagnose/investigationEvidence/cardParts.tsx +312 -0
  49. package/src/components/diagnose/investigationEvidence/excerpt.test.ts +105 -0
  50. package/src/components/diagnose/investigationEvidence/excerpt.ts +92 -0
  51. package/src/components/diagnose/investigationEvidence/identity.test.ts +5 -7
  52. package/src/components/diagnose/investigationEvidence/index.ts +1 -1
  53. package/src/components/diagnose/investigationEvidence/navigation.tsx +26 -0
  54. package/src/components/diagnose/investigationEvidence/observations.ts +11 -0
  55. package/src/components/diagnose/investigationEvidence/projection.test.ts +1 -1
  56. package/src/components/diagnose/investigationEvidence/types.ts +97 -2
  57. package/src/components/diagnose/investigationEvidenceKinds.ts +22 -0
  58. package/src/components/diagnose/investigationEvidencePartition.ts +296 -0
  59. package/src/components/diagnose/parts.test.tsx +3 -1
  60. package/src/components/diagnose/parts.tsx +26 -3904
  61. package/src/components/diagnose/target.ts +9 -3
  62. package/src/components/diagnose/toolCallLabel.test.ts +47 -1
  63. package/src/components/diagnose/toolCallLabel.ts +41 -2
  64. package/src/components/helm/HelmReleaseDrawer.tsx +1 -1
  65. package/src/components/home/MCPSetupDialog.tsx +1 -1
  66. package/src/components/resource/PrometheusChartsGrid.tsx +1 -1
  67. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
  68. package/src/components/resource/WorkloadMetricsSection.tsx +1 -1
  69. package/src/components/resources/PodFilePreview.tsx +1 -1
  70. package/src/components/ui/CommandPalette.tsx +12 -8
  71. package/src/components/ui/DiagnosticsOverlay.tsx +7 -5
  72. package/src/components/ui/Omnibar.tsx +33 -10
  73. package/src/components/ui/ShortcutHelpOverlay.tsx +8 -5
  74. 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
- "issues" | "events" | "changes" | "inventory" | "logs" | "alerts";
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
- "transition-[height] duration-200 ease-out motion-reduce:transition-none",
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");