@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.
- package/dist/{ErrorBoundary-BiZWOrv4.js → ErrorBoundary-Ca-Sa492.js} +1 -1
- package/dist/{code-visibility-CW5_8ZOI.js → code-visibility-NGRjhbtA.js} +1 -1
- package/dist/{common-mzWw4FdI.js → common-B7_ZSg0P.js} +4755 -4744
- package/dist/{data-grid-overlay-editor-CJmehSdc.js → data-grid-overlay-editor-B_cmrkBm.js} +1 -1
- package/dist/{feedback-button-BcT1FBud.js → feedback-button-CTF2Nj9s.js} +2 -2
- package/dist/{glide-data-editor-XckatD0j.js → glide-data-editor-BQHF9puC.js} +3 -3
- package/dist/{globals-jRrLCkrP.js → globals-Cj8iqMEp.js} +1 -1
- package/dist/main.js +6 -6
- package/dist/{number-overlay-editor-pZx8dUDV.js → number-overlay-editor-DBcnm0SY.js} +1 -1
- package/dist/{reveal-component-79ez8-D5.js → reveal-component-DTBSuGkC.js} +2 -2
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/__tests__/fixtures/{pair-preview.ts → pair-config.ts} +3 -2
- package/src/__tests__/mount.test.ts +8 -8
- package/src/components/editor/Output.tsx +5 -1
- package/src/components/editor/actions/__tests__/pair-with-agent-commands.test.ts +80 -252
- package/src/components/editor/actions/__tests__/pair-with-agent-modal.test.tsx +186 -34
- package/src/components/editor/actions/pair-with-agent-commands.ts +42 -51
- package/src/components/editor/actions/pair-with-agent-modal.tsx +96 -36
- package/src/components/editor/chrome/wrapper/__tests__/attachment-status.test.tsx +267 -62
- package/src/components/editor/chrome/wrapper/footer-items/attachment-status.tsx +196 -41
- package/src/components/editor/errors/__tests__/send-error-report-button.test.tsx +127 -13
- package/src/components/editor/errors/send-error-report-button.tsx +10 -7
- package/src/components/editor/output/MarimoTracebackOutput.tsx +27 -21
- package/src/core/attachments/__tests__/toast.test.tsx +58 -0
- package/src/core/attachments/toast.ts +15 -0
- package/src/core/cells/__tests__/error-report.test.ts +53 -3
- package/src/core/cells/__tests__/session.test.ts +18 -3
- package/src/core/cells/error-report.ts +14 -6
- package/src/core/cells/session.ts +19 -1
- package/src/core/config/pair.ts +5 -3
- 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 {
|
|
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
|
|
53
|
+
const config = useAtomValue(pairConfigAtom);
|
|
54
|
+
const hostEnabled = config?.hostEnabled ?? false;
|
|
56
55
|
const stableSessionId = useAtomValue(stableSessionIdAtom);
|
|
57
|
-
const
|
|
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
|
-
|
|
63
|
-
session:
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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={
|
|
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,
|
|
200
|
+
command={getRawPrompt(connection, authToken, config)}
|
|
159
201
|
display={getRawPrompt(
|
|
160
202
|
connection,
|
|
161
203
|
authToken ? maskToken(authToken) : null,
|
|
162
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
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",
|
|
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(
|
|
60
|
-
expect(screen.getByLabelText("
|
|
61
|
-
expect(screen.
|
|
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
|
-
|
|
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
|
|
104
|
+
expect(screen.getByLabelText("Agent connected")).toBeInTheDocument();
|
|
70
105
|
});
|
|
71
106
|
|
|
72
|
-
it("
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
expect(
|
|
80
|
-
expect(
|
|
81
|
-
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
94
|
-
).
|
|
95
|
-
expect(
|
|
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("
|
|
194
|
+
it("keeps focused details open when another agent connects", () => {
|
|
195
|
+
vi.useFakeTimers();
|
|
100
196
|
const { store } = renderStatus();
|
|
101
|
-
act(() =>
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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("
|
|
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:
|
|
110
|
-
{ ...AGENT, id: "a2", identity:
|
|
111
|
-
{ ...AGENT, id: "a3", identity: "a1" },
|
|
232
|
+
{ ...AGENT, identity: "same" },
|
|
233
|
+
{ ...AGENT, id: "a2", identity: "same" },
|
|
112
234
|
]);
|
|
113
|
-
expect(screen.
|
|
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("
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
|
125
|
-
const
|
|
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
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
expect(
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
expect(
|
|
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",
|
|
363
|
+
{ ...AGENT, id: "a2", name: "Claude Code" },
|
|
159
364
|
]);
|
|
160
365
|
act(() => store.set(attachmentsAtom, [AGENT]));
|
|
161
366
|
expect(
|
|
162
|
-
screen.
|
|
367
|
+
screen.queryByRole("heading", { name: "Claude Code" }),
|
|
163
368
|
).not.toBeInTheDocument();
|
|
164
369
|
act(() => store.set(attachmentsAtom, []));
|
|
165
370
|
expect(container).toBeEmptyDOMElement();
|