@marimo-team/islands 0.25.3-dev4 → 0.25.3-dev6

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 (90) hide show
  1. package/dist/{ConnectedDataExplorerComponent-CNSxdKyJ.js → ConnectedDataExplorerComponent-CNMliVzw.js} +1 -1
  2. package/dist/{ErrorBoundary-CuU5CuVr.js → ErrorBoundary-Ca-Sa492.js} +1 -1
  3. package/dist/{abnfDiagram-VCTEODGH-DQDjNXt3.js → abnfDiagram-VCTEODGH-D-Ez91gV.js} +2 -2
  4. package/dist/{any-language-editor-B9REGlD9.js → any-language-editor-DZ_YTVus.js} +1 -1
  5. package/dist/{architectureDiagram-5GKGNRK7-Nmls7Wxn.js → architectureDiagram-5GKGNRK7-CTpcNtO8.js} +1 -1
  6. package/dist/{chat-ui-BDpymeoe.js → chat-ui-0lKdOoQ3.js} +436 -437
  7. package/dist/{chunk-IMKFNOWR-CoIFEo-1.js → chunk-IMKFNOWR-DGxi-zeo.js} +1 -1
  8. package/dist/{chunk-SHT3W25Y-lHiGx3ZC.js → chunk-SHT3W25Y-CV9J3rAB.js} +1 -1
  9. package/dist/{chunk-SVP7TREG-BvsHIrY_.js → chunk-SVP7TREG-BL-9Fqgg.js} +1 -1
  10. package/dist/{chunk-TICWLB2K-BC8YIm_n.js → chunk-TICWLB2K-Cu_XMjDM.js} +1 -1
  11. package/dist/{classDiagram-ZZMXUADV-Bt98lkkv.js → classDiagram-ZZMXUADV-BXXV13Hq.js} +2 -2
  12. package/dist/{classDiagram-v2-VYDZK3BY-D9bfbcC3.js → classDiagram-v2-VYDZK3BY-B7FXJ1Cp.js} +2 -2
  13. package/dist/{code-visibility-Lu0dkrFO.js → code-visibility-NGRjhbtA.js} +14 -14
  14. package/dist/{common-DiWGWDIM.js → common-B7_ZSg0P.js} +6426 -6256
  15. package/dist/{cynefinDiagram-5FMLGOSQ-CgWra7wt.js → cynefinDiagram-5FMLGOSQ-CH96rwba.js} +1 -1
  16. package/dist/{dagre-GXQ25YYZ-CFZ8-35c.js → dagre-GXQ25YYZ-BD3PmpFY.js} +1 -1
  17. package/dist/{data-grid-overlay-editor-ga7ytgbj.js → data-grid-overlay-editor-B_cmrkBm.js} +1 -1
  18. package/dist/{diagram-S7CK7UJ4-lCNQrW7d.js → diagram-S7CK7UJ4-BsSJJldB.js} +1 -1
  19. package/dist/{diagram-UQ7AKVKN-CQPQ6pP_.js → diagram-UQ7AKVKN-muQtNZc4.js} +1 -1
  20. package/dist/{diagram-VX7I27RA-Cf3b0llz.js → diagram-VX7I27RA-CdhuZAqR.js} +1 -1
  21. package/dist/{diagram-Z3DM3KII-Bt4QLrpn.js → diagram-Z3DM3KII-BD8mXjOj.js} +1 -1
  22. package/dist/{ebnfDiagram-PWID7BFC-D3NAyPFY.js → ebnfDiagram-PWID7BFC-BIN1sweE.js} +2 -2
  23. package/dist/{erDiagram-RLTQ6QDP-Tlu09_Nm.js → erDiagram-RLTQ6QDP-Ctp5cfoN.js} +1 -1
  24. package/dist/{error-banner-Da600cwz.js → error-banner-D-WCIiMt.js} +10 -5
  25. package/dist/{cells-C8iCYvLU.js → error-entries-DSvFWT_M.js} +2262 -2206
  26. package/dist/{feedback-button-DQA-pzxi.js → feedback-button-CTF2Nj9s.js} +115 -116
  27. package/dist/{flowDiagram-HODETNUW-CAYbpJmV.js → flowDiagram-HODETNUW-3EfLrlFb.js} +2 -2
  28. package/dist/{formats-BGh2thRc.js → formats-CeOmU1MU.js} +1 -1
  29. package/dist/{glide-data-editor-Bv75MH6g.js → glide-data-editor-BQHF9puC.js} +3 -3
  30. package/dist/{globals-EDQGFIvi.js → globals-Cj8iqMEp.js} +1 -1
  31. package/dist/{html-to-image-CZY1pIZH.js → html-to-image-B8s5SgGm.js} +23 -23
  32. package/dist/{infoDiagram-27XIBGKW-XrDxhr0x.js → infoDiagram-27XIBGKW-DipQoHxl.js} +1 -1
  33. package/dist/{ishikawaDiagram-5VMMS53U-CD1muzov.js → ishikawaDiagram-5VMMS53U-DgFMjSGz.js} +1 -1
  34. package/dist/{kanban-definition-UXKFOSKX-Dg9W142_.js → kanban-definition-UXKFOSKX-w1ACVjgl.js} +1 -1
  35. package/dist/main.js +164 -163
  36. package/dist/{mermaid-CiAXjLpA.js → mermaid-CHo49Yv-.js} +33 -33
  37. package/dist/{mindmap-definition-YA3MSWOX-DgCidgYJ.js → mindmap-definition-YA3MSWOX-CrT0KSfX.js} +1 -1
  38. package/dist/{number-overlay-editor-BCP3MxhC.js → number-overlay-editor-DBcnm0SY.js} +1 -1
  39. package/dist/{pegDiagram-XKGWAZYB-coOYYKB6.js → pegDiagram-XKGWAZYB-BqvywNJA.js} +2 -2
  40. package/dist/{pieDiagram-E7YTZNPT-BnXTc3bj.js → pieDiagram-E7YTZNPT-BfxJ_XCc.js} +1 -1
  41. package/dist/{process-output-e4jj9Ijt.js → process-output-CKRXyszb.js} +3 -3
  42. package/dist/{railroadDiagram-O6MQD6OU-B28-u83n.js → railroadDiagram-O6MQD6OU-8vFLVTPu.js} +2 -2
  43. package/dist/{requirementDiagram-BXWQKSXE-CdYCTMu0.js → requirementDiagram-BXWQKSXE-a_MxGvxe.js} +1 -1
  44. package/dist/{reveal-component-DO5BJB9l.js → reveal-component-DTBSuGkC.js} +4 -4
  45. package/dist/{sequenceDiagram-WJ2MYXX4-CM3aRAe_.js → sequenceDiagram-WJ2MYXX4-CXFMnxGh.js} +1 -1
  46. package/dist/{stateDiagram-D77RDMKH-DOZUMVj8.js → stateDiagram-D77RDMKH-DQeA_CwI.js} +2 -2
  47. package/dist/{stateDiagram-v2-MP3YSRHH-CISYxma6.js → stateDiagram-v2-MP3YSRHH-BxxQucm1.js} +2 -2
  48. package/dist/style.css +1 -1
  49. package/dist/{swimlanes-42K2YHIH-BkSppxp8.js → swimlanes-42K2YHIH-Do6L-cYg.js} +1 -1
  50. package/dist/{swimlanesDiagram-VR7AAH4N-c8G5pGxC.js → swimlanesDiagram-VR7AAH4N-DUzPvSVU.js} +2 -2
  51. package/dist/{timeline-definition-24CTP7MA-CaMQayqK.js → timeline-definition-24CTP7MA-D3c46WM5.js} +1 -1
  52. package/dist/{toDate-BNJf01u1.js → toDate-vIXljnsr.js} +39 -38
  53. package/dist/{vega-component-DG29TAB7.js → vega-component-DTG6zxaY.js} +3 -3
  54. package/dist/{vennDiagram-4TSXK5OY-CF8pfaK3.js → vennDiagram-4TSXK5OY-BSZD8JcZ.js} +1 -1
  55. package/dist/{wardleyDiagram-VM6X3IG4-dGPu39d-.js → wardleyDiagram-VM6X3IG4-48cUWkfA.js} +1 -1
  56. package/dist/{xychartDiagram-S5SC5T6Z-CP6-9q56.js → xychartDiagram-S5SC5T6Z-CswYgYeu.js} +1 -1
  57. package/package.json +1 -1
  58. package/src/__tests__/fixtures/{pair-preview.ts → pair-config.ts} +3 -2
  59. package/src/__tests__/mount.test.ts +8 -8
  60. package/src/components/editor/Output.tsx +9 -2
  61. package/src/components/editor/actions/__tests__/pair-with-agent-commands.test.ts +80 -252
  62. package/src/components/editor/actions/__tests__/pair-with-agent-modal.test.tsx +186 -34
  63. package/src/components/editor/actions/pair-with-agent-commands.ts +42 -51
  64. package/src/components/editor/actions/pair-with-agent-modal.tsx +96 -36
  65. package/src/components/editor/chrome/panels/error-panel.tsx +1 -0
  66. package/src/components/editor/chrome/wrapper/__tests__/attachment-status.test.tsx +372 -0
  67. package/src/components/editor/chrome/wrapper/footer-item.tsx +32 -24
  68. package/src/components/editor/chrome/wrapper/footer-items/attachment-status.tsx +237 -0
  69. package/src/components/editor/chrome/wrapper/footer.tsx +2 -0
  70. package/src/components/editor/errors/__tests__/send-error-report-button.test.tsx +548 -0
  71. package/src/components/editor/errors/send-error-report-button.tsx +69 -0
  72. package/src/components/editor/output/MarimoErrorOutput.tsx +21 -5
  73. package/src/components/editor/output/MarimoTracebackOutput.tsx +35 -20
  74. package/src/components/editor/output/__tests__/ansi-reduce.test.ts +18 -0
  75. package/src/components/editor/output/ansi-reduce.ts +6 -0
  76. package/src/core/attachments/__tests__/toast.test.tsx +58 -0
  77. package/src/core/attachments/state.ts +21 -0
  78. package/src/core/attachments/toast.ts +15 -0
  79. package/src/core/cells/__tests__/cells.test.ts +52 -0
  80. package/src/core/cells/__tests__/error-report.test.ts +139 -0
  81. package/src/core/cells/__tests__/session.test.ts +50 -1
  82. package/src/core/cells/error-report.ts +63 -0
  83. package/src/core/cells/session.ts +28 -1
  84. package/src/core/config/pair.ts +5 -3
  85. package/src/core/islands/__tests__/bootstrap.test.ts +26 -0
  86. package/src/core/islands/bootstrap.ts +1 -0
  87. package/src/core/websocket/__tests__/useMarimoKernelConnection.hook.test.tsx +193 -0
  88. package/src/core/websocket/useMarimoKernelConnection.tsx +9 -0
  89. package/src/mount.tsx +3 -3
  90. package/dist/error-entries-CzoYkIQZ.js +0 -56
@@ -17,18 +17,16 @@ import { Events } from "@/utils/events";
17
17
  import { Tooltip } from "@/components/ui/tooltip";
18
18
  import { asRemoteURL, useRuntimeManager } from "@/core/runtime/config";
19
19
  import { API } from "@/core/network/api";
20
- import { pairPreviewAtom } from "@/core/config/pair";
20
+ import { pairConfigAtom } from "@/core/config/pair";
21
21
  import { stableSessionIdAtom } from "@/core/kernel/session";
22
22
  import {
23
23
  AGENT_LABELS,
24
24
  AGENT_TABS,
25
25
  type AgentTab,
26
26
  type ConnectionInfo,
27
- getFileFromURL,
28
27
  getRawPrompt,
29
28
  getTerminalCommand,
30
29
  maskToken,
31
- SKILL_INSTALL,
32
30
  TERMINAL_TABS,
33
31
  } from "./pair-with-agent-commands";
34
32
 
@@ -52,15 +50,64 @@ export const PairWithAgentModal: React.FC<{
52
50
  }> = ({ onClose }) => {
53
51
  const [activeTab, setActiveTab] = useState<AgentTab>("claude");
54
52
  const runtimeManager = useRuntimeManager();
55
- const preview = useAtomValue(pairPreviewAtom);
53
+ const config = useAtomValue(pairConfigAtom);
54
+ const hostEnabled = config?.hostEnabled ?? false;
56
55
  const stableSessionId = useAtomValue(stableSessionIdAtom);
57
- const commandStep = preview ? 1 : 2;
56
+ const [lookup, setLookup] = useState<
57
+ {
58
+ sessionId: string;
59
+ } & (
60
+ | { status: "ready"; notebookId: string | null }
61
+ | { status: "loading" | "error" }
62
+ )
63
+ >();
64
+ useEffect(() => {
65
+ if (!hostEnabled || !stableSessionId) {
66
+ setLookup(undefined);
67
+ return;
68
+ }
69
+ const sessionId = stableSessionId;
70
+ setLookup({ sessionId, status: "loading" });
71
+ let cancelled = false;
72
+ API.get<Record<string, { session_id: string; notebook_id: string | null }>>(
73
+ "/sessions",
74
+ )
75
+ .then((sessions) => {
76
+ if (!cancelled) {
77
+ const session = Object.values(sessions).find(
78
+ (session) => session.session_id === sessionId,
79
+ );
80
+ setLookup(
81
+ session
82
+ ? { sessionId, status: "ready", notebookId: session.notebook_id }
83
+ : { sessionId, status: "error" },
84
+ );
85
+ }
86
+ })
87
+ .catch(() => {
88
+ if (!cancelled) {
89
+ setLookup({ sessionId, status: "error" });
90
+ }
91
+ });
92
+ return () => {
93
+ cancelled = true;
94
+ };
95
+ }, [hostEnabled, stableSessionId]);
96
+ const currentLookup =
97
+ lookup?.sessionId === stableSessionId ? lookup : undefined;
98
+ const verified = !hostEnabled || currentLookup?.status === "ready";
99
+ const notebookId =
100
+ hostEnabled && currentLookup?.status === "ready"
101
+ ? currentLookup.notebookId
102
+ : undefined;
103
+ const targetReady = Boolean(stableSessionId) && verified;
58
104
  const authToken = useAuthToken();
59
105
  const hasToken = Boolean(authToken);
60
106
  const connection: ConnectionInfo = {
61
107
  url: runtimeManager.httpURL.toString(),
62
- file: preview ? undefined : getFileFromURL(window.location.href),
63
- session: preview ? (stableSessionId ?? undefined) : undefined,
108
+ notebook: notebookId ?? undefined,
109
+ session:
110
+ targetReady && !notebookId ? (stableSessionId ?? undefined) : undefined,
64
111
  };
65
112
 
66
113
  return (
@@ -83,6 +130,13 @@ export const PairWithAgentModal: React.FC<{
83
130
  </DialogHeader>
84
131
 
85
132
  <div className="flex flex-col gap-4 py-2">
133
+ {!targetReady && (
134
+ <output className="text-sm text-muted-foreground">
135
+ {currentLookup?.status === "error"
136
+ ? "Could not verify this notebook. Close this dialog and try again."
137
+ : "Waiting for the notebook connection…"}
138
+ </output>
139
+ )}
86
140
  <Tabs
87
141
  value={activeTab}
88
142
  onValueChange={(v) => setActiveTab(v as AgentTab)}
@@ -101,30 +155,27 @@ export const PairWithAgentModal: React.FC<{
101
155
  value={tab}
102
156
  className="mt-4 flex flex-col gap-4"
103
157
  >
104
- {!preview && (
105
- <Step
106
- index={1}
107
- title="Install the skill"
108
- hint="Run once per machine."
109
- >
110
- <CommandBlock command={SKILL_INSTALL} />
111
- </Step>
112
- )}
113
- <Step index={commandStep} title="Run in your terminal">
158
+ <Step
159
+ index={1}
160
+ title="Run in your terminal"
161
+ hint={
162
+ hostEnabled && tab === "opencode"
163
+ ? "Agent connections require OpenCode 2.x."
164
+ : undefined
165
+ }
166
+ >
114
167
  <CommandBlock
115
168
  command={getTerminalCommand(
116
169
  tab,
117
170
  connection,
118
171
  hasToken,
119
- preview,
172
+ config,
120
173
  )}
174
+ disabled={!targetReady}
121
175
  />
122
176
  </Step>
123
177
  {hasToken && authToken && (
124
- <Step
125
- index={commandStep + 1}
126
- title="Paste when prompted for a token"
127
- >
178
+ <Step index={2} title="Paste when prompted for a token">
128
179
  <CommandBlock
129
180
  command={authToken}
130
181
  display={maskToken(authToken)}
@@ -136,17 +187,8 @@ export const PairWithAgentModal: React.FC<{
136
187
  ))}
137
188
 
138
189
  <TabsContent value="prompt" className="mt-4 flex flex-col gap-4">
139
- {!preview && (
140
- <Step
141
- index={1}
142
- title="Make sure the marimo-pair skill is available to your agent"
143
- hint="Skip if your agent already has it."
144
- >
145
- <CommandBlock command={SKILL_INSTALL} />
146
- </Step>
147
- )}
148
190
  <Step
149
- index={commandStep}
191
+ index={1}
150
192
  title="Copy this prompt into your agent"
151
193
  hint={
152
194
  hasToken
@@ -155,13 +197,14 @@ export const PairWithAgentModal: React.FC<{
155
197
  }
156
198
  >
157
199
  <CommandBlock
158
- command={getRawPrompt(connection, authToken, preview)}
200
+ command={getRawPrompt(connection, authToken, config)}
159
201
  display={getRawPrompt(
160
202
  connection,
161
203
  authToken ? maskToken(authToken) : null,
162
- preview,
204
+ config,
163
205
  )}
164
206
  multiline={true}
207
+ disabled={!targetReady}
165
208
  />
166
209
  </Step>
167
210
  </TabsContent>
@@ -199,11 +242,21 @@ const CommandBlock: React.FC<{
199
242
  display?: string;
200
243
  multiline?: boolean;
201
244
  copyLabel?: string;
202
- }> = ({ command, display, multiline = false, copyLabel = "Copy command" }) => {
245
+ disabled?: boolean;
246
+ }> = ({
247
+ command,
248
+ display,
249
+ multiline = false,
250
+ copyLabel = "Copy command",
251
+ disabled = false,
252
+ }) => {
203
253
  const [copied, setCopied] = useState(false);
204
254
 
205
255
  const copy = Events.stopPropagation(async (e) => {
206
256
  e.preventDefault();
257
+ if (disabled) {
258
+ return;
259
+ }
207
260
  await copyToClipboard(command);
208
261
  setCopied(true);
209
262
  setTimeout(() => setCopied(false), 2000);
@@ -218,6 +271,7 @@ const CommandBlock: React.FC<{
218
271
  <Tooltip content="Copied!" open={copied}>
219
272
  <Button
220
273
  aria-label="Copy prompt"
274
+ disabled={disabled}
221
275
  onClick={copy}
222
276
  size="xs"
223
277
  variant="ghost"
@@ -240,7 +294,13 @@ const CommandBlock: React.FC<{
240
294
  {display ?? command}
241
295
  </code>
242
296
  <Tooltip content="Copied!" open={copied}>
243
- <Button aria-label={copyLabel} onClick={copy} size="xs" variant="ghost">
297
+ <Button
298
+ aria-label={copyLabel}
299
+ onClick={copy}
300
+ size="xs"
301
+ variant="ghost"
302
+ disabled={disabled}
303
+ >
244
304
  {copied ? (
245
305
  <CheckIcon size={14} strokeWidth={1.5} />
246
306
  ) : (
@@ -25,6 +25,7 @@ const ErrorsPanel: React.FC = () => {
25
25
  <MarimoErrorOutput
26
26
  key={error.cellId}
27
27
  errors={error.output.data}
28
+ code={error.output.code}
28
29
  cellId={error.cellId}
29
30
  />
30
31
  </div>
@@ -0,0 +1,372 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ // @vitest-environment jsdom
3
+
4
+ import {
5
+ act,
6
+ fireEvent,
7
+ render,
8
+ screen,
9
+ waitFor,
10
+ } from "@testing-library/react";
11
+ import { createStore, Provider } from "jotai";
12
+ import { userEvent } from "storybook/test";
13
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
14
+ import { PAIR_CONFIG } from "@/__tests__/fixtures/pair-config";
15
+ import { TooltipProvider } from "@/components/ui/tooltip";
16
+ import { toast } from "@/components/ui/use-toast";
17
+ import { type Attachment, attachmentsAtom } from "@/core/attachments/state";
18
+ import { pairConfigAtom } from "@/core/config/pair";
19
+ import {
20
+ type StableSessionId,
21
+ stableSessionIdAtom,
22
+ } from "@/core/kernel/session";
23
+ import { API } from "@/core/network/api";
24
+ import { connectionAtom } from "@/core/network/connection";
25
+ import { WebSocketClosedReason, ConnectionState } from "@/core/websocket/types";
26
+ import { Deferred } from "@/utils/Deferred";
27
+ import { AttachmentStatus } from "../footer-items/attachment-status";
28
+
29
+ vi.mock("@/components/ui/use-toast", () => ({ toast: vi.fn() }));
30
+ vi.mock("@/core/network/api", () => ({ API: { post: vi.fn() } }));
31
+
32
+ const AGENT: Attachment = {
33
+ id: "a1",
34
+ kind: "agent",
35
+ name: "Pi",
36
+ since: new Date().toISOString(),
37
+ notebook_id: "nb-one",
38
+ };
39
+
40
+ function renderStatus(attachments: Attachment[] = [AGENT], preview = true) {
41
+ const store = createStore();
42
+ store.set(connectionAtom, { state: ConnectionState.OPEN });
43
+ store.set(pairConfigAtom, { ...PAIR_CONFIG, hostEnabled: preview });
44
+ store.set(attachmentsAtom, attachments);
45
+ store.set(stableSessionIdAtom, "stable/test" as StableSessionId);
46
+ return {
47
+ store,
48
+ ...render(
49
+ <Provider store={store}>
50
+ <TooltipProvider>
51
+ <AttachmentStatus />
52
+ </TooltipProvider>
53
+ </Provider>,
54
+ ),
55
+ };
56
+ }
57
+
58
+ describe("AttachmentStatus", () => {
59
+ beforeEach(() => {
60
+ vi.clearAllMocks();
61
+ vi.mocked(API.post).mockResolvedValue("");
62
+ vi.mocked(toast).mockReturnValue({
63
+ id: "notice",
64
+ dismiss: vi.fn(),
65
+ update: vi.fn(),
66
+ upsert: vi.fn(),
67
+ });
68
+ });
69
+
70
+ afterEach(() => {
71
+ vi.useRealTimers();
72
+ });
73
+
74
+ it("renders nothing when host is disabled", () => {
75
+ expect(renderStatus([AGENT], false).container).toBeEmptyDOMElement();
76
+ });
77
+
78
+ it("renders nothing without agents", () => {
79
+ expect(
80
+ renderStatus([{ ...AGENT, kind: "client" }]).container,
81
+ ).toBeEmptyDOMElement();
82
+ });
83
+
84
+ it("lists each agent by its self-described name", () => {
85
+ renderStatus([
86
+ AGENT,
87
+ { ...AGENT, id: "a2", name: "Claude Code" },
88
+ { ...AGENT, id: "c1", kind: "client", name: "Browser" },
89
+ ]);
90
+ expect(screen.getAllByTestId("footer-attachment-status")).toHaveLength(1);
91
+ expect(screen.getByLabelText("2 agents connected")).toBeInTheDocument();
92
+ expect(screen.getByRole("heading", { name: "Pi" })).toBeInTheDocument();
93
+ expect(
94
+ screen.getByRole("heading", { name: "Claude Code" }),
95
+ ).toBeInTheDocument();
96
+ expect(screen.getByText("a1")).toBeInTheDocument();
97
+ expect(screen.getByText("a2")).toBeInTheDocument();
98
+ expect(screen.queryByText("Browser")).not.toBeInTheDocument();
99
+ expect(screen.getAllByText(/^Connected /)).toHaveLength(2);
100
+ });
101
+
102
+ it("uses Agent when no name was supplied", () => {
103
+ renderStatus([{ ...AGENT, name: null }]);
104
+ expect(screen.getByLabelText("Agent connected")).toBeInTheDocument();
105
+ });
106
+
107
+ it.each(["{Enter}", " "])("opens from the keyboard with %j", async (key) => {
108
+ vi.useFakeTimers();
109
+ renderStatus();
110
+ act(() => vi.advanceTimersByTime(3000));
111
+ vi.useRealTimers();
112
+ const user = userEvent.setup();
113
+ const button = screen.getByRole("button", { name: "Pi connected" });
114
+ expect(button.tagName).toBe("BUTTON");
115
+ expect(button).toHaveAttribute("type", "button");
116
+ await user.tab();
117
+ expect(button).toHaveFocus();
118
+ await user.keyboard(key);
119
+ expect(button).toHaveAttribute("aria-expanded", "true");
120
+ expect(screen.getByText(/^Connected /)).toBeInTheDocument();
121
+ await user.keyboard("{Escape}");
122
+ expect(button).toHaveAttribute("aria-expanded", "false");
123
+ expect(button).toHaveFocus();
124
+ });
125
+
126
+ it("opens for three seconds on connection without moving editor focus", () => {
127
+ vi.useFakeTimers();
128
+ const { store } = renderStatus([]);
129
+ const editor = document.createElement("textarea");
130
+ document.body.append(editor);
131
+ editor.focus();
132
+ act(() => store.set(attachmentsAtom, [AGENT]));
133
+ const trigger = screen.getByRole("button", { name: "Pi connected" });
134
+ expect(trigger).toHaveAttribute("aria-expanded", "true");
135
+ expect(editor).toHaveFocus();
136
+ act(() => vi.advanceTimersByTime(3000));
137
+ expect(trigger).toHaveAttribute("aria-expanded", "false");
138
+ expect(editor).toHaveFocus();
139
+ editor.remove();
140
+ });
141
+
142
+ it("reopens on a new agent but not a metadata update or disconnection", () => {
143
+ vi.useFakeTimers();
144
+ const { store } = renderStatus();
145
+ act(() => vi.advanceTimersByTime(3000));
146
+ act(() => store.set(attachmentsAtom, [{ ...AGENT, name: "New name" }]));
147
+ expect(screen.getByLabelText("New name connected")).toHaveAttribute(
148
+ "aria-expanded",
149
+ "false",
150
+ );
151
+ act(() => store.set(attachmentsAtom, [AGENT, { ...AGENT, id: "a2" }]));
152
+ expect(screen.getByLabelText("2 agents connected")).toHaveAttribute(
153
+ "aria-expanded",
154
+ "true",
155
+ );
156
+ act(() => vi.advanceTimersByTime(3000));
157
+ act(() => store.set(attachmentsAtom, [AGENT]));
158
+ expect(screen.getByLabelText("Pi connected")).toHaveAttribute(
159
+ "aria-expanded",
160
+ "false",
161
+ );
162
+ });
163
+
164
+ it("keeps manually opened details open past the automatic timeout", () => {
165
+ vi.useFakeTimers();
166
+ renderStatus();
167
+ const trigger = screen.getByLabelText("Pi connected");
168
+ fireEvent.click(trigger);
169
+ fireEvent.click(trigger);
170
+ act(() => vi.advanceTimersByTime(5000));
171
+ expect(trigger).toHaveAttribute("aria-expanded", "true");
172
+ });
173
+
174
+ it("keeps manual confirmation open when another agent connects", () => {
175
+ vi.useFakeTimers();
176
+ const { store } = renderStatus();
177
+ const trigger = screen.getByLabelText("Pi connected");
178
+ fireEvent.click(trigger);
179
+ fireEvent.click(trigger);
180
+ fireEvent.click(screen.getByRole("button", { name: "Disconnect Pi" }));
181
+ act(() =>
182
+ store.set(attachmentsAtom, [
183
+ AGENT,
184
+ { ...AGENT, id: "a2", name: "Claude" },
185
+ ]),
186
+ );
187
+ act(() => vi.advanceTimersByTime(5000));
188
+ expect(
189
+ screen.getByRole("button", { name: "Confirm disconnect Pi" }),
190
+ ).toBeInTheDocument();
191
+ expect(API.post).not.toHaveBeenCalled();
192
+ });
193
+
194
+ it("keeps focused details open when another agent connects", () => {
195
+ vi.useFakeTimers();
196
+ const { store } = renderStatus();
197
+ act(() => screen.getByRole("button", { name: "Disconnect Pi" }).focus());
198
+ act(() => store.set(attachmentsAtom, [AGENT, { ...AGENT, id: "a2" }]));
199
+ act(() => vi.advanceTimersByTime(5000));
200
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
201
+ });
202
+
203
+ it("keeps hovered details open when another agent connects", () => {
204
+ vi.useFakeTimers();
205
+ const { store } = renderStatus();
206
+ fireEvent.pointerEnter(screen.getByRole("dialog"));
207
+ act(() => store.set(attachmentsAtom, [AGENT, { ...AGENT, id: "a2" }]));
208
+ act(() => vi.advanceTimersByTime(5000));
209
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
210
+ });
211
+
212
+ it("pauses the automatic close while the pointer is over the details", () => {
213
+ vi.useFakeTimers();
214
+ renderStatus();
215
+ const details = screen.getByRole("dialog");
216
+ fireEvent.pointerEnter(details);
217
+ act(() => vi.advanceTimersByTime(5000));
218
+ expect(screen.getByLabelText("Pi connected")).toHaveAttribute(
219
+ "aria-expanded",
220
+ "true",
221
+ );
222
+ fireEvent.pointerLeave(details);
223
+ act(() => vi.advanceTimersByTime(3000));
224
+ expect(screen.getByLabelText("Pi connected")).toHaveAttribute(
225
+ "aria-expanded",
226
+ "false",
227
+ );
228
+ });
229
+
230
+ it("groups identities while keeping separate attachment rows", () => {
231
+ renderStatus([
232
+ { ...AGENT, identity: "same" },
233
+ { ...AGENT, id: "a2", identity: "same" },
234
+ ]);
235
+ expect(screen.getByLabelText("Pi connected")).toBeInTheDocument();
236
+ expect(
237
+ screen.getAllByRole("button", { name: "Disconnect Pi" }),
238
+ ).toHaveLength(2);
239
+ for (const text of screen.getAllByText(/^Connected /)) {
240
+ expect(text).not.toHaveTextContent("Invalid Date");
241
+ }
242
+ });
243
+
244
+ it("keeps the popover closed across a same-identity reconnect gap", () => {
245
+ vi.useFakeTimers();
246
+ const { store } = renderStatus([{ ...AGENT, identity: "same" }]);
247
+ act(() => vi.advanceTimersByTime(3000));
248
+ act(() => store.set(attachmentsAtom, []));
249
+ act(() =>
250
+ store.set(attachmentsAtom, [{ ...AGENT, id: "a2", identity: "same" }]),
251
+ );
252
+ expect(screen.getByLabelText("Pi connected")).toHaveAttribute(
253
+ "aria-expanded",
254
+ "false",
255
+ );
256
+ });
257
+
258
+ it("disconnects only the selected attachment and waits for the server update", async () => {
259
+ const { store } = renderStatus([
260
+ AGENT,
261
+ { ...AGENT, id: "a2", name: "Codex" },
262
+ ]);
263
+ fireEvent.click(screen.getByRole("button", { name: "Disconnect Pi" }));
264
+ expect(API.post).not.toHaveBeenCalled();
265
+ fireEvent.click(
266
+ screen.getByRole("button", { name: "Confirm disconnect Pi" }),
267
+ );
268
+ await waitFor(() =>
269
+ expect(API.post).toHaveBeenCalledWith(
270
+ "/pair/notebooks/stable%2Ftest/attachments/detach",
271
+ { attachment_id: "a1" },
272
+ ),
273
+ );
274
+ expect(screen.getByLabelText("2 agents connected")).toBeInTheDocument();
275
+ act(() =>
276
+ store.set(attachmentsAtom, [{ ...AGENT, id: "a2", name: "Codex" }]),
277
+ );
278
+ expect(screen.getByLabelText("Codex connected")).toBeInTheDocument();
279
+ expect(
280
+ screen.queryByRole("button", { name: "Disconnect Pi" }),
281
+ ).not.toBeInTheDocument();
282
+ });
283
+
284
+ it("disables duplicate disconnects while pending", async () => {
285
+ const deferred = new Deferred<string>();
286
+ vi.mocked(API.post).mockReturnValue(deferred.promise);
287
+ renderStatus();
288
+ const button = screen.getByRole("button", { name: "Disconnect Pi" });
289
+ fireEvent.click(button);
290
+ fireEvent.click(
291
+ screen.getByRole("button", { name: "Confirm disconnect Pi" }),
292
+ );
293
+ expect(button).toBeDisabled();
294
+ expect(
295
+ screen.getByRole("button", { name: "Cancel disconnect Pi" }),
296
+ ).toBeDisabled();
297
+ fireEvent.click(button);
298
+ expect(API.post).toHaveBeenCalledOnce();
299
+ await act(async () => deferred.resolve(""));
300
+ });
301
+
302
+ it("shows disconnect failures and retains the attachment", async () => {
303
+ vi.mocked(API.post).mockRejectedValue(new Error("offline"));
304
+ renderStatus();
305
+ fireEvent.click(screen.getByRole("button", { name: "Disconnect Pi" }));
306
+ fireEvent.click(
307
+ screen.getByRole("button", { name: "Confirm disconnect Pi" }),
308
+ );
309
+ await waitFor(() =>
310
+ expect(toast).toHaveBeenCalledWith({
311
+ variant: "danger",
312
+ title: "Could not disconnect Pi",
313
+ duration: 5000,
314
+ }),
315
+ );
316
+ expect(screen.getByLabelText("Pi connected")).toBeInTheDocument();
317
+ expect(screen.getByRole("button", { name: "Disconnect Pi" })).toBeEnabled();
318
+ });
319
+
320
+ it("cancels confirmation without disconnecting and restores focus", () => {
321
+ renderStatus();
322
+ fireEvent.click(screen.getByRole("button", { name: "Disconnect Pi" }));
323
+ expect(screen.getByText("Disconnect?")).toBeVisible();
324
+ expect(
325
+ screen.getByRole("button", { name: "Confirm disconnect Pi" }),
326
+ ).toBeEnabled();
327
+ fireEvent.click(
328
+ screen.getByRole("button", { name: "Cancel disconnect Pi" }),
329
+ );
330
+ expect(API.post).not.toHaveBeenCalled();
331
+ expect(screen.getByRole("button", { name: "Disconnect Pi" })).toHaveFocus();
332
+ expect(
333
+ screen.queryByRole("button", { name: "Confirm disconnect Pi" }),
334
+ ).not.toBeInTheDocument();
335
+ });
336
+
337
+ it("disables disconnect without a stable session ID", () => {
338
+ const { store } = renderStatus();
339
+ act(() => store.set(stableSessionIdAtom, null));
340
+ expect(
341
+ screen.getByRole("button", { name: "Disconnect Pi" }),
342
+ ).toBeDisabled();
343
+ });
344
+
345
+ it.each([
346
+ { state: ConnectionState.CONNECTING },
347
+ { state: ConnectionState.CLOSING },
348
+ { state: ConnectionState.NOT_STARTED },
349
+ {
350
+ state: ConnectionState.CLOSED,
351
+ code: WebSocketClosedReason.KERNEL_DISCONNECTED,
352
+ reason: "offline",
353
+ },
354
+ ])("hides connected claims while the browser is %j", (connection) => {
355
+ const { store, container } = renderStatus();
356
+ act(() => store.set(connectionAtom, connection));
357
+ expect(container).toBeEmptyDOMElement();
358
+ });
359
+
360
+ it("removes detached agents when the list is replaced", () => {
361
+ const { store, container } = renderStatus([
362
+ AGENT,
363
+ { ...AGENT, id: "a2", name: "Claude Code" },
364
+ ]);
365
+ act(() => store.set(attachmentsAtom, [AGENT]));
366
+ expect(
367
+ screen.queryByRole("heading", { name: "Claude Code" }),
368
+ ).not.toBeInTheDocument();
369
+ act(() => store.set(attachmentsAtom, []));
370
+ expect(container).toBeEmptyDOMElement();
371
+ });
372
+ });
@@ -1,6 +1,7 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
3
  import type React from "react";
4
+ import { Slot as SlotPrimitive } from "radix-ui";
4
5
  import { forwardRef } from "react";
5
6
  import { Tooltip } from "@/components/ui/tooltip";
6
7
  import { cn } from "@/utils/cn";
@@ -8,36 +9,43 @@ import { cn } from "@/utils/cn";
8
9
  type FooterItemProps = {
9
10
  selected: boolean;
10
11
  tooltip: React.ReactNode;
12
+ asChild?: boolean;
11
13
  } & React.HTMLAttributes<HTMLDivElement>;
12
14
 
13
15
  export const FooterItem: React.FC<FooterItemProps> = forwardRef<
14
16
  HTMLDivElement,
15
17
  FooterItemProps
16
- >(({ children, tooltip, selected, className, ...rest }, ref) => {
17
- const content = (
18
- <div
19
- ref={ref}
20
- className={cn(
21
- "h-full flex items-center p-2 text-sm shadow-inset font-mono cursor-pointer rounded",
22
- !selected && "hover:bg-(--sage-3)",
23
- selected && "bg-(--sage-4)",
24
- className,
25
- )}
26
- {...rest}
27
- >
28
- {children}
29
- </div>
30
- );
31
-
32
- if (tooltip) {
33
- return (
34
- <Tooltip content={tooltip} side="top" delayDuration={200}>
35
- {content}
36
- </Tooltip>
18
+ >(
19
+ (
20
+ { children, tooltip, selected, className, asChild = false, ...rest },
21
+ ref,
22
+ ) => {
23
+ const Component = asChild ? SlotPrimitive.Slot : "div";
24
+ const content = (
25
+ <Component
26
+ ref={ref}
27
+ className={cn(
28
+ "h-full flex items-center p-2 text-sm shadow-inset font-mono cursor-pointer rounded",
29
+ !selected && "hover:bg-(--sage-3)",
30
+ selected && "bg-(--sage-4)",
31
+ className,
32
+ )}
33
+ {...rest}
34
+ >
35
+ {children}
36
+ </Component>
37
37
  );
38
- }
39
38
 
40
- return content;
41
- });
39
+ if (tooltip) {
40
+ return (
41
+ <Tooltip content={tooltip} side="top" delayDuration={200}>
42
+ {content}
43
+ </Tooltip>
44
+ );
45
+ }
46
+
47
+ return content;
48
+ },
49
+ );
42
50
 
43
51
  FooterItem.displayName = "FooterItem";