@marimo-team/islands 0.25.3-dev5 → 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 (32) hide show
  1. package/dist/{ErrorBoundary-BiZWOrv4.js → ErrorBoundary-Ca-Sa492.js} +1 -1
  2. package/dist/{code-visibility-CW5_8ZOI.js → code-visibility-NGRjhbtA.js} +1 -1
  3. package/dist/{common-mzWw4FdI.js → common-B7_ZSg0P.js} +4755 -4744
  4. package/dist/{data-grid-overlay-editor-CJmehSdc.js → data-grid-overlay-editor-B_cmrkBm.js} +1 -1
  5. package/dist/{feedback-button-BcT1FBud.js → feedback-button-CTF2Nj9s.js} +2 -2
  6. package/dist/{glide-data-editor-XckatD0j.js → glide-data-editor-BQHF9puC.js} +3 -3
  7. package/dist/{globals-jRrLCkrP.js → globals-Cj8iqMEp.js} +1 -1
  8. package/dist/main.js +6 -6
  9. package/dist/{number-overlay-editor-pZx8dUDV.js → number-overlay-editor-DBcnm0SY.js} +1 -1
  10. package/dist/{reveal-component-79ez8-D5.js → reveal-component-DTBSuGkC.js} +2 -2
  11. package/dist/style.css +1 -1
  12. package/package.json +1 -1
  13. package/src/__tests__/fixtures/{pair-preview.ts → pair-config.ts} +3 -2
  14. package/src/__tests__/mount.test.ts +8 -8
  15. package/src/components/editor/Output.tsx +5 -1
  16. package/src/components/editor/actions/__tests__/pair-with-agent-commands.test.ts +80 -252
  17. package/src/components/editor/actions/__tests__/pair-with-agent-modal.test.tsx +186 -34
  18. package/src/components/editor/actions/pair-with-agent-commands.ts +42 -51
  19. package/src/components/editor/actions/pair-with-agent-modal.tsx +96 -36
  20. package/src/components/editor/chrome/wrapper/__tests__/attachment-status.test.tsx +267 -62
  21. package/src/components/editor/chrome/wrapper/footer-items/attachment-status.tsx +196 -41
  22. package/src/components/editor/errors/__tests__/send-error-report-button.test.tsx +127 -13
  23. package/src/components/editor/errors/send-error-report-button.tsx +10 -7
  24. package/src/components/editor/output/MarimoTracebackOutput.tsx +27 -21
  25. package/src/core/attachments/__tests__/toast.test.tsx +58 -0
  26. package/src/core/attachments/toast.ts +15 -0
  27. package/src/core/cells/__tests__/error-report.test.ts +53 -3
  28. package/src/core/cells/__tests__/session.test.ts +18 -3
  29. package/src/core/cells/error-report.ts +14 -6
  30. package/src/core/cells/session.ts +19 -1
  31. package/src/core/config/pair.ts +5 -3
  32. package/src/mount.tsx +3 -3
@@ -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
  ) : (
@@ -1,32 +1,48 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
  // @vitest-environment jsdom
3
3
 
4
- import { act, fireEvent, render, screen } from "@testing-library/react";
4
+ import {
5
+ act,
6
+ fireEvent,
7
+ render,
8
+ screen,
9
+ waitFor,
10
+ } from "@testing-library/react";
5
11
  import { createStore, Provider } from "jotai";
6
12
  import { userEvent } from "storybook/test";
7
- import { describe, expect, it } from "vitest";
8
- import { PAIR_PREVIEW } from "@/__tests__/fixtures/pair-preview";
13
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
14
+ import { PAIR_CONFIG } from "@/__tests__/fixtures/pair-config";
9
15
  import { TooltipProvider } from "@/components/ui/tooltip";
16
+ import { toast } from "@/components/ui/use-toast";
10
17
  import { type Attachment, attachmentsAtom } from "@/core/attachments/state";
11
- import { pairPreviewAtom } from "@/core/config/pair";
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";
12
24
  import { connectionAtom } from "@/core/network/connection";
13
25
  import { WebSocketClosedReason, ConnectionState } from "@/core/websocket/types";
26
+ import { Deferred } from "@/utils/Deferred";
14
27
  import { AttachmentStatus } from "../footer-items/attachment-status";
15
28
 
29
+ vi.mock("@/components/ui/use-toast", () => ({ toast: vi.fn() }));
30
+ vi.mock("@/core/network/api", () => ({ API: { post: vi.fn() } }));
31
+
16
32
  const AGENT: Attachment = {
17
33
  id: "a1",
18
34
  kind: "agent",
19
35
  name: "Pi",
20
- notebook_id: "nb-test",
21
- identity: "pi-thread",
22
36
  since: new Date().toISOString(),
37
+ notebook_id: "nb-one",
23
38
  };
24
39
 
25
40
  function renderStatus(attachments: Attachment[] = [AGENT], preview = true) {
26
41
  const store = createStore();
27
42
  store.set(connectionAtom, { state: ConnectionState.OPEN });
28
- store.set(pairPreviewAtom, preview ? PAIR_PREVIEW : undefined);
43
+ store.set(pairConfigAtom, { ...PAIR_CONFIG, hostEnabled: preview });
29
44
  store.set(attachmentsAtom, attachments);
45
+ store.set(stableSessionIdAtom, "stable/test" as StableSessionId);
30
46
  return {
31
47
  store,
32
48
  ...render(
@@ -40,7 +56,22 @@ function renderStatus(attachments: Attachment[] = [AGENT], preview = true) {
40
56
  }
41
57
 
42
58
  describe("AttachmentStatus", () => {
43
- it("renders nothing outside the preview", () => {
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", () => {
44
75
  expect(renderStatus([AGENT], false).container).toBeEmptyDOMElement();
45
76
  });
46
77
 
@@ -53,88 +84,262 @@ describe("AttachmentStatus", () => {
53
84
  it("lists each agent by its self-described name", () => {
54
85
  renderStatus([
55
86
  AGENT,
56
- { ...AGENT, id: "a2", identity: "claude-thread", name: "Claude Code" },
87
+ { ...AGENT, id: "a2", name: "Claude Code" },
57
88
  { ...AGENT, id: "c1", kind: "client", name: "Browser" },
58
89
  ]);
59
- expect(screen.getAllByTestId("footer-attachment-status")).toHaveLength(2);
60
- expect(screen.getByLabelText("Pi: Connected")).toBeInTheDocument();
61
- expect(screen.getByLabelText("Claude Code: Connected")).toBeInTheDocument();
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();
62
98
  expect(screen.queryByText("Browser")).not.toBeInTheDocument();
63
- fireEvent.click(screen.getByLabelText("Pi: Connected"));
64
- expect(screen.getByText(/^Connected /)).toBeInTheDocument();
99
+ expect(screen.getAllByText(/^Connected /)).toHaveLength(2);
65
100
  });
66
101
 
67
102
  it("uses Agent when no name was supplied", () => {
68
103
  renderStatus([{ ...AGENT, name: null }]);
69
- expect(screen.getByLabelText("Agent: Connected")).toBeInTheDocument();
104
+ expect(screen.getByLabelText("Agent connected")).toBeInTheDocument();
70
105
  });
71
106
 
72
- it("groups attachments by identity while retaining each connection row", () => {
73
- renderStatus([
74
- AGENT,
75
- { ...AGENT, id: "a2", since: "2026-10-14T00:00:00Z" },
76
- ]);
77
- expect(screen.getAllByTestId("footer-attachment-status")).toHaveLength(1);
78
- fireEvent.click(screen.getByLabelText("Pi: Connected"));
79
- expect(screen.getAllByText(/^Connected /)).toHaveLength(2);
80
- expect(screen.getByText("a1")).toBeInTheDocument();
81
- expect(screen.getByText("a2")).toBeInTheDocument();
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();
82
124
  });
83
125
 
84
- it("preserves an open popover when the same identity reconnects", () => {
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();
85
144
  const { store } = renderStatus();
86
- fireEvent.click(screen.getByLabelText("Pi: Connected"));
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" }));
87
181
  act(() =>
88
182
  store.set(attachmentsAtom, [
89
- { ...AGENT, id: "a2", since: "2026-10-14T00:00:00Z" },
183
+ AGENT,
184
+ { ...AGENT, id: "a2", name: "Claude" },
90
185
  ]),
91
186
  );
187
+ act(() => vi.advanceTimersByTime(5000));
92
188
  expect(
93
- screen.getByRole("button", { name: "Pi: Connected" }),
94
- ).toHaveAttribute("aria-expanded", "true");
95
- expect(screen.queryByText("a1")).not.toBeInTheDocument();
96
- expect(screen.getByText("a2")).toBeInTheDocument();
189
+ screen.getByRole("button", { name: "Confirm disconnect Pi" }),
190
+ ).toBeInTheDocument();
191
+ expect(API.post).not.toHaveBeenCalled();
97
192
  });
98
193
 
99
- it("does not open a closed popover when the same identity reconnects", () => {
194
+ it("keeps focused details open when another agent connects", () => {
195
+ vi.useFakeTimers();
100
196
  const { store } = renderStatus();
101
- act(() => store.set(attachmentsAtom, [{ ...AGENT, id: "a2" }]));
102
- expect(
103
- screen.getByRole("button", { name: "Pi: Connected" }),
104
- ).toHaveAttribute("aria-expanded", "false");
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();
105
210
  });
106
211
 
107
- it("keeps anonymous attachments separate from matching client identities", () => {
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", () => {
108
231
  renderStatus([
109
- { ...AGENT, identity: undefined },
110
- { ...AGENT, id: "a2", identity: undefined },
111
- { ...AGENT, id: "a3", identity: "a1" },
232
+ { ...AGENT, identity: "same" },
233
+ { ...AGENT, id: "a2", identity: "same" },
112
234
  ]);
113
- expect(screen.getAllByTestId("footer-attachment-status")).toHaveLength(3);
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
+ }
114
242
  });
115
243
 
116
- it("accepts client identities that match object property names", () => {
117
- renderStatus([
118
- { ...AGENT, identity: "__proto__" },
119
- { ...AGENT, id: "a2", identity: "constructor" },
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" },
120
262
  ]);
121
- expect(screen.getAllByTestId("footer-attachment-status")).toHaveLength(2);
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();
122
282
  });
123
283
 
124
- it.each(["{Enter}", " "])("opens from the keyboard with %j", async (key) => {
125
- const user = userEvent.setup();
284
+ it("disables duplicate disconnects while pending", async () => {
285
+ const deferred = new Deferred<string>();
286
+ vi.mocked(API.post).mockReturnValue(deferred.promise);
126
287
  renderStatus();
127
- const button = screen.getByRole("button", { name: "Pi: Connected" });
128
- expect(button.tagName).toBe("BUTTON");
129
- expect(button).toHaveAttribute("type", "button");
130
- await user.tab();
131
- expect(button).toHaveFocus();
132
- await user.keyboard(key);
133
- expect(button).toHaveAttribute("aria-expanded", "true");
134
- expect(screen.getByText(/^Connected /)).toBeInTheDocument();
135
- await user.keyboard("{Escape}");
136
- expect(button).toHaveAttribute("aria-expanded", "false");
137
- expect(button).toHaveFocus();
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();
138
343
  });
139
344
 
140
345
  it.each([
@@ -155,11 +360,11 @@ describe("AttachmentStatus", () => {
155
360
  it("removes detached agents when the list is replaced", () => {
156
361
  const { store, container } = renderStatus([
157
362
  AGENT,
158
- { ...AGENT, id: "a2", identity: "claude-thread", name: "Claude Code" },
363
+ { ...AGENT, id: "a2", name: "Claude Code" },
159
364
  ]);
160
365
  act(() => store.set(attachmentsAtom, [AGENT]));
161
366
  expect(
162
- screen.queryByLabelText("Claude Code: Connected"),
367
+ screen.queryByRole("heading", { name: "Claude Code" }),
163
368
  ).not.toBeInTheDocument();
164
369
  act(() => store.set(attachmentsAtom, []));
165
370
  expect(container).toBeEmptyDOMElement();