@skyhook-io/radar-app 1.16.1 → 1.16.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 (73) hide show
  1. package/package.json +5 -5
  2. package/src/App.tsx +51 -9
  3. package/src/RadarApp.tsx +10 -2
  4. package/src/api/client.ts +61 -49
  5. package/src/api/client.updateResource.test.tsx +58 -0
  6. package/src/api/cnpg.ts +95 -0
  7. package/src/api/diagnose.ts +26 -0
  8. package/src/api/httpErrors.ts +12 -0
  9. package/src/api/timelineSource.ts +1 -1
  10. package/src/components/ConnectionErrorView.test.tsx +2 -0
  11. package/src/components/ConnectionErrorView.tsx +2 -1
  12. package/src/components/NoClusterAccessBanner.tsx +68 -0
  13. package/src/components/cnpg/CNPGClusterActivity.tsx +49 -0
  14. package/src/components/cnpg/CNPGClusterLogs.tsx +62 -0
  15. package/src/components/cnpg/CNPGDeclarations.tsx +311 -0
  16. package/src/components/cnpg/CNPGDetailPage.tsx +228 -0
  17. package/src/components/cnpg/CNPGDrawerTrail.tsx +37 -0
  18. package/src/components/cnpg/CNPGOperator.tsx +234 -0
  19. package/src/components/cnpg/CNPGOverview.tsx +307 -0
  20. package/src/components/cnpg/CNPGPooling.tsx +116 -0
  21. package/src/components/cnpg/CNPGProtection.tsx +320 -0
  22. package/src/components/cnpg/CNPGSummaryHost.tsx +143 -0
  23. package/src/components/cnpg/CNPGView.tsx +156 -0
  24. package/src/components/cnpg/paths.ts +13 -0
  25. package/src/components/cnpg/routes.test.ts +60 -0
  26. package/src/components/cnpg/routes.ts +112 -0
  27. package/src/components/cnpg/shared.tsx +290 -0
  28. package/src/components/cnpg/useCNPGSidebarWorkspace.ts +88 -0
  29. package/src/components/diagnose/AgentControls.tsx +1 -1
  30. package/src/components/diagnose/AssessmentCard.tsx +11 -1
  31. package/src/components/diagnose/DiagnoseContext.tsx +35 -22
  32. package/src/components/diagnose/DiagnoseSurface.tsx +12 -8
  33. package/src/components/diagnose/Home.tsx +1 -1
  34. package/src/components/diagnose/InvestigationEvidencePane.tsx +1 -1
  35. package/src/components/diagnose/InvestigationRefusalAction.test.tsx +156 -0
  36. package/src/components/diagnose/InvestigationRefusalAction.tsx +14 -0
  37. package/src/components/diagnose/InvestigationView.tsx +199 -65
  38. package/src/components/diagnose/investigationSourceFocus.ts +6 -6
  39. package/src/components/diagnose/investigationState.test.ts +83 -1
  40. package/src/components/diagnose/investigationState.ts +43 -0
  41. package/src/components/diagnose/launch.ts +2 -2
  42. package/src/components/diagnose/useDisclosureReveal.ts +1 -1
  43. package/src/components/dock/BottomDock.tsx +1 -1
  44. package/src/components/dock/DockContext.tsx +27 -2
  45. package/src/components/dock/LocalTerminalContext.test.tsx +210 -0
  46. package/src/components/dock/LocalTerminalTab.test.tsx +122 -0
  47. package/src/components/dock/LocalTerminalTab.tsx +81 -6
  48. package/src/components/helm/ChartBrowser.tsx +6 -7
  49. package/src/components/helm/HelmCompareRoute.tsx +108 -115
  50. package/src/components/helm/HelmReleaseDrawer.tsx +26 -44
  51. package/src/components/helm/InstallWizard.tsx +5 -2
  52. package/src/components/helm/TrackChartSourceDialog.tsx +10 -2
  53. package/src/components/helm/ValuesViewer.tsx +1 -1
  54. package/src/components/logs/LogsViewer.tsx +3 -0
  55. package/src/components/logs/WorkloadLogsViewer.tsx +3 -0
  56. package/src/components/resources/ResourceDetailDrawer.tsx +6 -0
  57. package/src/components/resources/ResourcesView.tsx +6 -38
  58. package/src/components/settings/LocalConfigurationDetails.tsx +21 -15
  59. package/src/components/settings/LocalConnectionSettings.tsx +41 -10
  60. package/src/components/timeline/TimelineList.test.ts +47 -29
  61. package/src/components/timeline/TimelineList.tsx +57 -11
  62. package/src/components/timeline/TimelineView.tsx +9 -2
  63. package/src/components/timeline/TimelineView.urlparams.test.ts +6 -0
  64. package/src/components/whats-new/WhatsNew.test.ts +107 -28
  65. package/src/components/whats-new/WhatsNew.tsx +146 -86
  66. package/src/components/whats-new/WhatsNewDialog.test.tsx +132 -10
  67. package/src/components/whats-new/releaseNotes.ts +156 -25
  68. package/src/components/workload/WorkloadView.tsx +30 -4
  69. package/src/context/DiagnoseCustomization.tsx +13 -3
  70. package/src/contexts/CapabilitiesContext.tsx +9 -1
  71. package/src/hooks/useResourceCounts.ts +49 -0
  72. package/src/index.ts +4 -2
  73. package/src/components/helm/RoleGatedPanel.tsx +0 -47
@@ -20,6 +20,7 @@ import {
20
20
  investigationApplyAttemptVerified,
21
21
  investigationAssessmentNeedsCurrentStateVerification,
22
22
  investigationApplyRejectionIsDefinitive,
23
+ investigationFailureShown,
23
24
  investigationApplyCompletionEffects,
24
25
  investigationTurnWithTerminalEvent,
25
26
  investigationApplyTerminalNeedsClusterRefresh,
@@ -74,10 +75,13 @@ import {
74
75
  addTurn,
75
76
  stopRun,
76
77
  DiagnoseError,
78
+ investigationRefusal,
77
79
  type DiagnoseStreamEvent,
80
+ type InvestigationRefusal,
78
81
  type RunSummary,
79
82
  } from "../../api/diagnose";
80
83
  import { useDiagnose } from "./DiagnoseContext";
84
+ import { InvestigationRefusalAction } from "./InvestigationRefusalAction";
81
85
  import {
82
86
  TurnView,
83
87
  ResultCard,
@@ -114,9 +118,11 @@ const RECHECK_QUESTION =
114
118
 
115
119
  export function InvestigationStartErrorAlert({
116
120
  error,
121
+ refusal = null,
117
122
  onDismiss,
118
123
  }: {
119
124
  error: string;
125
+ refusal?: InvestigationRefusal | null;
120
126
  onDismiss: () => void;
121
127
  }) {
122
128
  return (
@@ -130,6 +136,11 @@ export function InvestigationStartErrorAlert({
130
136
  Couldn't start a new investigation
131
137
  </div>
132
138
  <div className="text-theme-text-secondary">{error}</div>
139
+ {refusal ? (
140
+ <div className="mt-1.5">
141
+ <InvestigationRefusalAction refusal={refusal} />
142
+ </div>
143
+ ) : null}
133
144
  </div>
134
145
  <button
135
146
  type="button"
@@ -142,6 +153,99 @@ export function InvestigationStartErrorAlert({
142
153
  );
143
154
  }
144
155
 
156
+ // The activity pane's one failure notice: a status warning (with its retry) or
157
+ // a failed request, the host's action beside a refusal, and a newer failed
158
+ // follow-up shown under a status warning it can't replace.
159
+ export function InvestigationFailureNotice({
160
+ message,
161
+ refusal,
162
+ statusCheck,
163
+ followUp,
164
+ }: {
165
+ message: string;
166
+ refusal: InvestigationRefusal | null;
167
+ statusCheck: { label: string; onCheck: () => void; disabled: boolean } | null;
168
+ followUp: { message: string; refusal: InvestigationRefusal | null } | null;
169
+ }) {
170
+ return (
171
+ <div className="flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-sm text-theme-text-primary">
172
+ <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-400" />
173
+ <div className="min-w-0 flex-1">
174
+ <span>{message}</span>
175
+ {refusal ? (
176
+ <div className="mt-2">
177
+ <InvestigationRefusalAction refusal={refusal} />
178
+ </div>
179
+ ) : null}
180
+ {statusCheck ? (
181
+ <button
182
+ type="button"
183
+ onClick={statusCheck.onCheck}
184
+ disabled={statusCheck.disabled}
185
+ className="mt-2 block rounded-md border border-red-500/30 px-2 py-1 text-xs font-medium text-theme-text-primary hover:bg-red-500/10 disabled:opacity-50"
186
+ >
187
+ {statusCheck.label}
188
+ </button>
189
+ ) : null}
190
+ {followUp ? (
191
+ <div className="mt-3 border-t border-red-500/20 pt-2">
192
+ <span>{followUp.message}</span>
193
+ {followUp.refusal ? (
194
+ <div className="mt-2">
195
+ <InvestigationRefusalAction refusal={followUp.refusal} />
196
+ </div>
197
+ ) : null}
198
+ </div>
199
+ ) : null}
200
+ </div>
201
+ </div>
202
+ );
203
+ }
204
+
205
+ // The Findings pane's status line. A refused status check never ran, so it
206
+ // says it couldn't check, and offers the host's action like Activity does.
207
+ export function InvestigationStatusCheckNotice({
208
+ message,
209
+ uncertainOnly,
210
+ refusal,
211
+ onCheck,
212
+ disabled,
213
+ }: {
214
+ message: string;
215
+ uncertainOnly: boolean;
216
+ refusal: InvestigationRefusal | null;
217
+ onCheck: () => void;
218
+ disabled: boolean;
219
+ }) {
220
+ return (
221
+ <div className="mt-2 flex items-center gap-2 rounded-lg border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-theme-text-secondary">
222
+ <AlertTriangle className="h-3.5 w-3.5 shrink-0 text-red-400" />
223
+ <div className="min-w-0 flex-1">
224
+ <span>
225
+ {refusal
226
+ ? `Couldn't check status: ${message}`
227
+ : uncertainOnly
228
+ ? message
229
+ : `Verification did not complete: ${message}`}
230
+ </span>
231
+ {refusal ? (
232
+ <div className="mt-1.5">
233
+ <InvestigationRefusalAction refusal={refusal} />
234
+ </div>
235
+ ) : null}
236
+ </div>
237
+ <button
238
+ type="button"
239
+ onClick={onCheck}
240
+ disabled={disabled}
241
+ className="shrink-0 rounded-md border border-theme-border px-2 py-1 font-medium text-theme-text-primary hover:bg-theme-hover disabled:opacity-50"
242
+ >
243
+ {uncertainOnly ? "Check current status" : "Check current status again"}
244
+ </button>
245
+ </div>
246
+ );
247
+ }
248
+
145
249
  function captureEvidenceCardLayout(container: HTMLElement) {
146
250
  const containerTop = container.getBoundingClientRect().top;
147
251
  return new Map(
@@ -176,8 +280,14 @@ export function InvestigationView({
176
280
  onOpenTimeline?: (scope: InvestigationTimelineScope) => void;
177
281
  }) {
178
282
  const { kind, namespace, name } = run;
179
- const { refreshRuns, openInvestigation, startError, dismissError, agents } =
180
- useDiagnose();
283
+ const {
284
+ refreshRuns,
285
+ openInvestigation,
286
+ startError,
287
+ startRefusal,
288
+ dismissError,
289
+ agents,
290
+ } = useDiagnose();
181
291
  // Capabilities are the declared ones of the agent that ran this run, not
182
292
  // the picker's: a reopened run keeps the backend it was made with.
183
293
  const runAgent = agents.find((agent) => agent.name === run.agent);
@@ -215,6 +325,7 @@ export function InvestigationView({
215
325
  sequence: number;
216
326
  status: "running" | "error";
217
327
  error?: string;
328
+ refusal?: InvestigationRefusal | null;
218
329
  } | null>(null);
219
330
  const [explanationReveal, setExplanationReveal] = useState<{
220
331
  sequence: number;
@@ -226,6 +337,12 @@ export function InvestigationView({
226
337
  useState<InvestigationHistoryUnavailableState | null>(null);
227
338
  const [input, setInput] = useState("");
228
339
  const [actionError, setActionError] = useState<string | null>(null);
340
+ // The host's refusal behind a failed follow-up, for its action beside the error.
341
+ const [actionRefusal, setActionRefusal] =
342
+ useState<InvestigationRefusal | null>(null);
343
+ // The host's refusal behind a failed status check, for its action beside it.
344
+ const [verificationRefusal, setVerificationRefusal] =
345
+ useState<InvestigationRefusal | null>(null);
229
346
  const [verificationError, setVerificationError] = useState<string | null>(
230
347
  null,
231
348
  );
@@ -242,7 +359,7 @@ export function InvestigationView({
242
359
  // the server-owned verification turn arrives. This is presentation state only:
243
360
  // the durable server job, never the browser, schedules verification.
244
361
  const [verificationPending, setVerificationPending] = useState(false);
245
- // The panes are simultaneous above the workspace breakpoint and tabs below it.
362
+ // On the full page the panes sit side by side; in the panel they are tabs.
246
363
  // Successful/stale history opens on its outcome; running and ended-early runs
247
364
  // open on Activity, where the user can immediately see what happened.
248
365
  const [narrowPane, setNarrowPane] = useState<"activity" | "evidence">(() =>
@@ -294,11 +411,11 @@ export function InvestigationView({
294
411
  const paneSelectionTouchedRef = useRef(false);
295
412
  const evidenceRevealRequestIdRef = useRef(0);
296
413
  const activityRevealRequestIdRef = useRef(0);
297
- const workspaceId = useId();
298
- const activityTabId = `${workspaceId}-activity-tab`;
299
- const activityPaneId = `${workspaceId}-activity-pane`;
300
- const findingsTabId = `${workspaceId}-findings-tab`;
301
- const findingsPaneId = `${workspaceId}-findings-pane`;
414
+ const viewId = useId();
415
+ const activityTabId = `${viewId}-activity-tab`;
416
+ const activityPaneId = `${viewId}-activity-pane`;
417
+ const findingsTabId = `${viewId}-findings-tab`;
418
+ const findingsPaneId = `${viewId}-findings-pane`;
302
419
  // Stick-to-bottom: follow streaming output while the user is at/near the bottom,
303
420
  // detach the moment they scroll up to read history, re-attach when they return.
304
421
  // Tracked from scroll events (the user's intent) — NOT post-render geometry, which
@@ -419,7 +536,9 @@ export function InvestigationView({
419
536
  setExplanationReveal(null);
420
537
  setHistoryUnavailable(null);
421
538
  setActionError(null);
539
+ setActionRefusal(null);
422
540
  setVerificationError(null);
541
+ setVerificationRefusal(null);
423
542
  setVerificationPending(false);
424
543
  setLocalApplyAttemptAssessmentIdx(-1);
425
544
  setApplyOutcomeUncertain(null);
@@ -468,6 +587,7 @@ export function InvestigationView({
468
587
  if (ev.verify) {
469
588
  setVerificationPending(false);
470
589
  setVerificationError(null);
590
+ setVerificationRefusal(null);
471
591
  }
472
592
  streamInFlightRef.current = true;
473
593
  if (live) setBusy(true);
@@ -565,6 +685,7 @@ export function InvestigationView({
565
685
  setVerificationError(
566
686
  ev.error || "The verification could not be completed.",
567
687
  );
688
+ setVerificationRefusal(null);
568
689
  }
569
690
  }
570
691
  updateLast((t) => investigationTurnWithTerminalEvent(t, ev, live));
@@ -745,6 +866,9 @@ export function InvestigationView({
745
866
  if (!q || interactionsBlocked) return;
746
867
  setInput("");
747
868
  setActionError(null);
869
+ setActionRefusal(null);
870
+ setVerificationError(null);
871
+ setVerificationRefusal(null);
748
872
  setNarrowPane("activity");
749
873
  suppressEvidenceMotionRef.current = false;
750
874
  pinnedRef.current = true; // a user-initiated turn always follows to the bottom
@@ -752,6 +876,7 @@ export function InvestigationView({
752
876
  addTurn(run.id, { question: q }).catch((e) => {
753
877
  setRequestPending(false);
754
878
  setActionError(e instanceof DiagnoseError ? e.message : "Couldn't send.");
879
+ setActionRefusal(investigationRefusal(e));
755
880
  });
756
881
  };
757
882
  const stop = () => stopRun(run.id);
@@ -759,6 +884,7 @@ export function InvestigationView({
759
884
  const askExplanation = (sequence: number) => {
760
885
  if (interactionsBlocked) return;
761
886
  setActionError(null);
887
+ setActionRefusal(null);
762
888
  const serial = ++explanationRequestSerial.current;
763
889
  const previousTurns = turnsRef.current.length;
764
890
  setExplanationRequest({ sequence, status: "running" });
@@ -780,6 +906,7 @@ export function InvestigationView({
780
906
  e instanceof DiagnoseError
781
907
  ? e.message
782
908
  : "Couldn't request an explanation.",
909
+ refusal: investigationRefusal(e),
783
910
  });
784
911
  });
785
912
  };
@@ -832,7 +959,9 @@ export function InvestigationView({
832
959
  setConfirmApply(false);
833
960
  if (interactionsBlocked) return;
834
961
  setActionError(null);
962
+ setActionRefusal(null);
835
963
  setVerificationError(null);
964
+ setVerificationRefusal(null);
836
965
  setApplyOutcomeUncertain(null);
837
966
  setNarrowPane("activity");
838
967
  suppressEvidenceMotionRef.current = false;
@@ -852,6 +981,7 @@ export function InvestigationView({
852
981
  setLocalApplyAttemptAssessmentIdx(-1);
853
982
  setApplyOutcomeUncertain(null);
854
983
  setActionError(e.message.trim() || "Couldn't apply.");
984
+ setActionRefusal(investigationRefusal(e));
855
985
  return;
856
986
  }
857
987
  refreshClusterState();
@@ -866,7 +996,9 @@ export function InvestigationView({
866
996
  const checkStatus = () => {
867
997
  if (interactionsBlocked) return Promise.resolve();
868
998
  setActionError(null);
999
+ setActionRefusal(null);
869
1000
  setVerificationError(null);
1001
+ setVerificationRefusal(null);
870
1002
  setNarrowPane("activity");
871
1003
  suppressEvidenceMotionRef.current = false;
872
1004
  pinnedRef.current = true;
@@ -881,6 +1013,7 @@ export function InvestigationView({
881
1013
  ? error.message
882
1014
  : "Couldn't check status.",
883
1015
  );
1016
+ setVerificationRefusal(investigationRefusal(error));
884
1017
  });
885
1018
  };
886
1019
 
@@ -1280,12 +1413,23 @@ export function InvestigationView({
1280
1413
  0,
1281
1414
  );
1282
1415
  const latestVerification = [...turns].reverse().find((turn) => turn.verify);
1283
- const displayedVerificationError =
1284
- latestVerification?.status === "error"
1285
- ? latestVerification.error || "The verification could not be completed."
1286
- : verificationError;
1287
- const displayedStatusCheckError =
1288
- displayedVerificationError || applyOutcomeUncertain;
1416
+ const {
1417
+ verificationError: displayedVerificationError,
1418
+ statusCheckError: displayedStatusCheckError,
1419
+ message: shownFailure,
1420
+ refusal: shownActionRefusal,
1421
+ followUp: shownFollowUp,
1422
+ } = investigationFailureShown({
1423
+ actionError,
1424
+ actionRefusal,
1425
+ verificationError,
1426
+ verificationRefusal,
1427
+ savedVerificationError:
1428
+ latestVerification?.status === "error"
1429
+ ? latestVerification.error || "The verification could not be completed."
1430
+ : null,
1431
+ applyOutcomeUncertain,
1432
+ });
1289
1433
  const findingsTabAccessibleLabel =
1290
1434
  "Findings: current assessment, Radar evidence, and next steps";
1291
1435
  const currentAssessmentCoverageLimited = investigationEvidenceCoverageLimited(
@@ -1476,17 +1620,17 @@ export function InvestigationView({
1476
1620
  ? Math.round((Date.now() - runningSinceRef.current) / 1000)
1477
1621
  : 0,
1478
1622
  });
1479
- const showSplitWorkspace = maximized;
1480
- const splitGridClass = showSplitWorkspace
1623
+ const showSidePanes = maximized;
1624
+ const splitGridClass = showSidePanes
1481
1625
  ? "@min-[1000px]/investigation:grid-cols-[minmax(320px,min(30%,520px))_minmax(0,1fr)]"
1482
1626
  : "";
1483
- const splitTabClass = showSplitWorkspace
1627
+ const splitTabClass = showSidePanes
1484
1628
  ? "@min-[1000px]/investigation:hidden"
1485
1629
  : "";
1486
- const splitPaneClass = showSplitWorkspace
1630
+ const splitPaneClass = showSidePanes
1487
1631
  ? "@min-[1000px]/investigation:flex"
1488
1632
  : "";
1489
- const splitActivityBorderClass = showSplitWorkspace
1633
+ const splitActivityBorderClass = showSidePanes
1490
1634
  ? "@min-[1000px]/investigation:border-r @min-[1000px]/investigation:border-theme-border"
1491
1635
  : "";
1492
1636
 
@@ -1637,11 +1781,11 @@ export function InvestigationView({
1637
1781
  <section
1638
1782
  ref={nextStepsRef}
1639
1783
  tabIndex={-1}
1640
- aria-labelledby={`${workspaceId}-next-steps`}
1784
+ aria-labelledby={`${viewId}-next-steps`}
1641
1785
  className="investigation-next-steps rounded-xl border p-4 outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
1642
1786
  >
1643
1787
  <h2
1644
- id={`${workspaceId}-next-steps`}
1788
+ id={`${viewId}-next-steps`}
1645
1789
  className="text-lg font-semibold text-theme-text-primary"
1646
1790
  >
1647
1791
  {earlierPlan ? "Earlier proposed steps" : "Next steps"}
@@ -1678,7 +1822,7 @@ export function InvestigationView({
1678
1822
 
1679
1823
  return (
1680
1824
  <div
1681
- data-investigation-workspace
1825
+ data-investigation-view
1682
1826
  className={`@container/investigation relative flex min-h-0 flex-1 flex-col bg-theme-surface ${maximized ? "investigation-split-enabled" : ""}`}
1683
1827
  >
1684
1828
  {stale ? (
@@ -1740,6 +1884,7 @@ export function InvestigationView({
1740
1884
  {startError ? (
1741
1885
  <InvestigationStartErrorAlert
1742
1886
  error={startError}
1887
+ refusal={startRefusal}
1743
1888
  onDismiss={dismissError}
1744
1889
  />
1745
1890
  ) : null}
@@ -1769,7 +1914,7 @@ export function InvestigationView({
1769
1914
 
1770
1915
  <div
1771
1916
  role="group"
1772
- aria-label="Investigation workspace"
1917
+ aria-label="Investigation view"
1773
1918
  className={`grid grid-cols-2 border-b border-theme-border bg-theme-base/40 p-1 ${splitTabClass}`}
1774
1919
  >
1775
1920
  <button
@@ -1989,7 +2134,7 @@ export function InvestigationView({
1989
2134
  ) : undefined;
1990
2135
  })()}
1991
2136
  />
1992
- {showSplitWorkspace &&
2137
+ {showSidePanes &&
1993
2138
  index === currentAssessmentIdx &&
1994
2139
  turn.timeline.length === 0 ? (
1995
2140
  <p className="hidden text-sm text-theme-text-tertiary @min-[1000px]/investigation:block">
@@ -2039,26 +2184,25 @@ export function InvestigationView({
2039
2184
  </Fragment>
2040
2185
  );
2041
2186
  })}
2042
- {(actionError || displayedStatusCheckError) && (
2043
- <div className="flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-sm text-theme-text-primary">
2044
- <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-400" />
2045
- <div className="min-w-0 flex-1">
2046
- <span>{displayedStatusCheckError || actionError}</span>
2047
- {displayedStatusCheckError ? (
2048
- <button
2049
- type="button"
2050
- onClick={checkStatus}
2051
- disabled={interactionsBlocked}
2052
- className="mt-2 block rounded-md border border-red-500/30 px-2 py-1 text-xs font-medium text-theme-text-primary hover:bg-red-500/10 disabled:opacity-50"
2053
- >
2054
- {applyOutcomeUncertain &&
2055
- !displayedVerificationError
2056
- ? "Check current status"
2057
- : "Check current status again"}
2058
- </button>
2059
- ) : null}
2060
- </div>
2061
- </div>
2187
+ {shownFailure && (
2188
+ <InvestigationFailureNotice
2189
+ message={shownFailure}
2190
+ refusal={shownActionRefusal}
2191
+ statusCheck={
2192
+ displayedStatusCheckError
2193
+ ? {
2194
+ label:
2195
+ applyOutcomeUncertain &&
2196
+ !displayedVerificationError
2197
+ ? "Check current status"
2198
+ : "Check current status again",
2199
+ onCheck: checkStatus,
2200
+ disabled: interactionsBlocked,
2201
+ }
2202
+ : null
2203
+ }
2204
+ followUp={shownFollowUp}
2205
+ />
2062
2206
  )}
2063
2207
  </div>
2064
2208
  </div>
@@ -2169,12 +2313,12 @@ export function InvestigationView({
2169
2313
  not a fifth, and it fills the pane it is given. */}
2170
2314
  <div className="space-y-3">
2171
2315
  <section
2172
- aria-labelledby={`${workspaceId}-assessment-heading`}
2316
+ aria-labelledby={`${viewId}-assessment-heading`}
2173
2317
  className="investigation-assessment rounded-xl border p-3"
2174
2318
  >
2175
2319
  <div className="flex flex-wrap items-center gap-1.5">
2176
2320
  <h2
2177
- id={`${workspaceId}-assessment-heading`}
2321
+ id={`${viewId}-assessment-heading`}
2178
2322
  className="text-lg font-semibold text-theme-text-primary"
2179
2323
  >
2180
2324
  {assessmentNeedsCurrentStateVerification
@@ -2314,26 +2458,16 @@ export function InvestigationView({
2314
2458
  </div>
2315
2459
  )}
2316
2460
  {displayedStatusCheckError ? (
2317
- <div className="mt-2 flex items-center gap-2 rounded-lg border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-theme-text-secondary">
2318
- <AlertTriangle className="h-3.5 w-3.5 shrink-0 text-red-400" />
2319
- <span className="min-w-0 flex-1">
2320
- {applyOutcomeUncertain &&
2461
+ <InvestigationStatusCheckNotice
2462
+ message={displayedStatusCheckError}
2463
+ uncertainOnly={
2464
+ !!applyOutcomeUncertain &&
2321
2465
  !displayedVerificationError
2322
- ? displayedStatusCheckError
2323
- : `Verification did not complete: ${displayedStatusCheckError}`}
2324
- </span>
2325
- <button
2326
- type="button"
2327
- onClick={checkStatus}
2328
- disabled={interactionsBlocked}
2329
- className="shrink-0 rounded-md border border-theme-border px-2 py-1 font-medium text-theme-text-primary hover:bg-theme-hover disabled:opacity-50"
2330
- >
2331
- {applyOutcomeUncertain &&
2332
- !displayedVerificationError
2333
- ? "Check current status"
2334
- : "Check current status again"}
2335
- </button>
2336
- </div>
2466
+ }
2467
+ refusal={shownActionRefusal}
2468
+ onCheck={checkStatus}
2469
+ disabled={interactionsBlocked}
2470
+ />
2337
2471
  ) : null}
2338
2472
  </section>
2339
2473
 
@@ -70,20 +70,20 @@ export function highlightRelatedEvidence(
70
70
  row: HTMLElement,
71
71
  sourceId?: string,
72
72
  ): void {
73
- const workspace = row.closest("[data-investigation-workspace]");
74
- if (!workspace) return;
75
- workspace
73
+ const view = row.closest("[data-investigation-view]");
74
+ if (!view) return;
75
+ view
76
76
  .querySelectorAll("[data-source-related]")
77
77
  .forEach((node) => node.removeAttribute("data-source-related"));
78
- const findings = workspace.querySelector<HTMLElement>(
78
+ const findings = view.querySelector<HTMLElement>(
79
79
  "[data-investigation-findings-scroll]",
80
80
  );
81
- const activity = workspace.querySelector<HTMLElement>(
81
+ const activity = view.querySelector<HTMLElement>(
82
82
  "[data-investigation-activity-scroll]",
83
83
  );
84
84
  if (!sourceId || !findings?.offsetParent || !activity?.offsetParent) return;
85
85
  const viewport = findings.getBoundingClientRect();
86
- workspace
86
+ view
87
87
  .querySelectorAll<HTMLElement>("[data-evidence-source]")
88
88
  .forEach((card) => {
89
89
  if (
@@ -9,6 +9,7 @@ import {
9
9
  investigationApplyAttemptVerified,
10
10
  investigationAssessmentNeedsCurrentStateVerification,
11
11
  investigationApplyRejectionIsDefinitive,
12
+ investigationFailureShown,
12
13
  investigationApplyTerminalNeedsClusterRefresh,
13
14
  investigationTurnWithTerminalEvent,
14
15
  investigationClosedEventIsLive,
@@ -618,7 +619,9 @@ describe("investigation action gating", () => {
618
619
  expect(
619
620
  canOfferInvestigationApply({ ...base, hasNewerEvidence: true }),
620
621
  ).toBe(false);
621
- expect(canOfferInvestigationApply({ ...base, canApply: false })).toBe(false);
622
+ expect(canOfferInvestigationApply({ ...base, canApply: false })).toBe(
623
+ false,
624
+ );
622
625
  expect(
623
626
  canOfferInvestigationApply({ ...base, interactionsBlocked: true }),
624
627
  ).toBe(false);
@@ -1273,3 +1276,82 @@ describe("investigationHealthSignals twins across scopes", () => {
1273
1276
  ]);
1274
1277
  });
1275
1278
  });
1279
+
1280
+ describe("investigationFailureShown", () => {
1281
+ const quota = {
1282
+ status: 402,
1283
+ message: "This month's investigations are used up.",
1284
+ code: "ai_quota_exhausted",
1285
+ reason: "allowance_used_free",
1286
+ action: "upgrade",
1287
+ };
1288
+ const none = {
1289
+ actionError: null,
1290
+ actionRefusal: null,
1291
+ verificationError: null,
1292
+ verificationRefusal: null,
1293
+ savedVerificationError: null,
1294
+ applyOutcomeUncertain: null,
1295
+ };
1296
+
1297
+ it("shows a refused status-check retry over the failed verification it retries", () => {
1298
+ const shown = investigationFailureShown({
1299
+ ...none,
1300
+ savedVerificationError: "The agent stopped before checking.",
1301
+ verificationError: quota.message,
1302
+ verificationRefusal: quota,
1303
+ });
1304
+ expect(shown.message).toBe(quota.message);
1305
+ expect(shown.refusal).toBe(quota);
1306
+ });
1307
+
1308
+ it("offers no action for a verification that ran and failed", () => {
1309
+ const shown = investigationFailureShown({
1310
+ ...none,
1311
+ savedVerificationError: "The agent stopped before checking.",
1312
+ });
1313
+ expect(shown.message).toBe("The agent stopped before checking.");
1314
+ expect(shown.refusal).toBeNull();
1315
+ });
1316
+
1317
+ it("keeps an uncertain apply first and shows a newer refused follow-up beside it", () => {
1318
+ const shown = investigationFailureShown({
1319
+ ...none,
1320
+ applyOutcomeUncertain: "Check current status before applying again.",
1321
+ actionError: quota.message,
1322
+ actionRefusal: quota,
1323
+ });
1324
+ expect(shown.message).toBe("Check current status before applying again.");
1325
+ expect(shown.statusCheckError).toBe(
1326
+ "Check current status before applying again.",
1327
+ );
1328
+ expect(shown.refusal).toBeNull();
1329
+ expect(shown.followUp).toEqual({ message: quota.message, refusal: quota });
1330
+ });
1331
+
1332
+ it("shows a refused follow-up beside a failed verification turn too", () => {
1333
+ const shown = investigationFailureShown({
1334
+ ...none,
1335
+ savedVerificationError: "The agent stopped before checking.",
1336
+ actionError: quota.message,
1337
+ actionRefusal: quota,
1338
+ });
1339
+ expect(shown.message).toBe("The agent stopped before checking.");
1340
+ expect(shown.followUp).toEqual({ message: quota.message, refusal: quota });
1341
+ });
1342
+
1343
+ it("shows a refused follow-up with its action", () => {
1344
+ const shown = investigationFailureShown({
1345
+ ...none,
1346
+ actionError: quota.message,
1347
+ actionRefusal: quota,
1348
+ });
1349
+ expect(shown).toEqual({
1350
+ verificationError: null,
1351
+ statusCheckError: null,
1352
+ message: quota.message,
1353
+ refusal: quota,
1354
+ followUp: null,
1355
+ });
1356
+ });
1357
+ });
@@ -5,6 +5,7 @@ import { evidenceKindIsAdverse } from "./investigationEvidenceKinds";
5
5
  import {
6
6
  DiagnoseError,
7
7
  type DiagnoseStreamEvent,
8
+ type InvestigationRefusal,
8
9
  type RunSummary,
9
10
  } from "../../api/diagnose";
10
11
  import type { Turn } from "./parts";
@@ -123,6 +124,48 @@ export function investigationAssessmentNeedsCurrentStateVerification(input: {
123
124
  return input.localApplyAttemptAssessmentIdx >= input.currentAssessmentIdx;
124
125
  }
125
126
 
127
+ // Which failure the activity pane shows, and the host refusal behind it. A
128
+ // request's own error is newer than a saved verification turn's: it is cleared
129
+ // when the next request starts and when a new verification turn streams.
130
+ // A status warning keeps first place, since it is what stops a second Apply
131
+ // before anyone checks; a newer failed follow-up shows beside it, not instead.
132
+ export function investigationFailureShown(input: {
133
+ actionError: string | null;
134
+ actionRefusal: InvestigationRefusal | null;
135
+ verificationError: string | null;
136
+ verificationRefusal: InvestigationRefusal | null;
137
+ savedVerificationError: string | null;
138
+ applyOutcomeUncertain: string | null;
139
+ }): {
140
+ verificationError: string | null;
141
+ statusCheckError: string | null;
142
+ message: string | null;
143
+ refusal: InvestigationRefusal | null;
144
+ followUp: { message: string; refusal: InvestigationRefusal | null } | null;
145
+ } {
146
+ const verificationError =
147
+ input.verificationError || input.savedVerificationError;
148
+ const statusCheckError = verificationError || input.applyOutcomeUncertain;
149
+ if (statusCheckError) {
150
+ return {
151
+ verificationError,
152
+ statusCheckError,
153
+ message: statusCheckError,
154
+ refusal: input.verificationError ? input.verificationRefusal : null,
155
+ followUp: input.actionError
156
+ ? { message: input.actionError, refusal: input.actionRefusal }
157
+ : null,
158
+ };
159
+ }
160
+ return {
161
+ verificationError: null,
162
+ statusCheckError: null,
163
+ message: input.actionError,
164
+ refusal: input.actionError ? input.actionRefusal : null,
165
+ followUp: null,
166
+ };
167
+ }
168
+
126
169
  export function investigationApplyRejectionIsDefinitive(
127
170
  error: unknown,
128
171
  ): error is DiagnoseError {