@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.
- package/dist/{ConnectedDataExplorerComponent-CNSxdKyJ.js → ConnectedDataExplorerComponent-CNMliVzw.js} +1 -1
- package/dist/{ErrorBoundary-CuU5CuVr.js → ErrorBoundary-Ca-Sa492.js} +1 -1
- package/dist/{abnfDiagram-VCTEODGH-DQDjNXt3.js → abnfDiagram-VCTEODGH-D-Ez91gV.js} +2 -2
- package/dist/{any-language-editor-B9REGlD9.js → any-language-editor-DZ_YTVus.js} +1 -1
- package/dist/{architectureDiagram-5GKGNRK7-Nmls7Wxn.js → architectureDiagram-5GKGNRK7-CTpcNtO8.js} +1 -1
- package/dist/{chat-ui-BDpymeoe.js → chat-ui-0lKdOoQ3.js} +436 -437
- package/dist/{chunk-IMKFNOWR-CoIFEo-1.js → chunk-IMKFNOWR-DGxi-zeo.js} +1 -1
- package/dist/{chunk-SHT3W25Y-lHiGx3ZC.js → chunk-SHT3W25Y-CV9J3rAB.js} +1 -1
- package/dist/{chunk-SVP7TREG-BvsHIrY_.js → chunk-SVP7TREG-BL-9Fqgg.js} +1 -1
- package/dist/{chunk-TICWLB2K-BC8YIm_n.js → chunk-TICWLB2K-Cu_XMjDM.js} +1 -1
- package/dist/{classDiagram-ZZMXUADV-Bt98lkkv.js → classDiagram-ZZMXUADV-BXXV13Hq.js} +2 -2
- package/dist/{classDiagram-v2-VYDZK3BY-D9bfbcC3.js → classDiagram-v2-VYDZK3BY-B7FXJ1Cp.js} +2 -2
- package/dist/{code-visibility-Lu0dkrFO.js → code-visibility-NGRjhbtA.js} +14 -14
- package/dist/{common-DiWGWDIM.js → common-B7_ZSg0P.js} +6426 -6256
- package/dist/{cynefinDiagram-5FMLGOSQ-CgWra7wt.js → cynefinDiagram-5FMLGOSQ-CH96rwba.js} +1 -1
- package/dist/{dagre-GXQ25YYZ-CFZ8-35c.js → dagre-GXQ25YYZ-BD3PmpFY.js} +1 -1
- package/dist/{data-grid-overlay-editor-ga7ytgbj.js → data-grid-overlay-editor-B_cmrkBm.js} +1 -1
- package/dist/{diagram-S7CK7UJ4-lCNQrW7d.js → diagram-S7CK7UJ4-BsSJJldB.js} +1 -1
- package/dist/{diagram-UQ7AKVKN-CQPQ6pP_.js → diagram-UQ7AKVKN-muQtNZc4.js} +1 -1
- package/dist/{diagram-VX7I27RA-Cf3b0llz.js → diagram-VX7I27RA-CdhuZAqR.js} +1 -1
- package/dist/{diagram-Z3DM3KII-Bt4QLrpn.js → diagram-Z3DM3KII-BD8mXjOj.js} +1 -1
- package/dist/{ebnfDiagram-PWID7BFC-D3NAyPFY.js → ebnfDiagram-PWID7BFC-BIN1sweE.js} +2 -2
- package/dist/{erDiagram-RLTQ6QDP-Tlu09_Nm.js → erDiagram-RLTQ6QDP-Ctp5cfoN.js} +1 -1
- package/dist/{error-banner-Da600cwz.js → error-banner-D-WCIiMt.js} +10 -5
- package/dist/{cells-C8iCYvLU.js → error-entries-DSvFWT_M.js} +2262 -2206
- package/dist/{feedback-button-DQA-pzxi.js → feedback-button-CTF2Nj9s.js} +115 -116
- package/dist/{flowDiagram-HODETNUW-CAYbpJmV.js → flowDiagram-HODETNUW-3EfLrlFb.js} +2 -2
- package/dist/{formats-BGh2thRc.js → formats-CeOmU1MU.js} +1 -1
- package/dist/{glide-data-editor-Bv75MH6g.js → glide-data-editor-BQHF9puC.js} +3 -3
- package/dist/{globals-EDQGFIvi.js → globals-Cj8iqMEp.js} +1 -1
- package/dist/{html-to-image-CZY1pIZH.js → html-to-image-B8s5SgGm.js} +23 -23
- package/dist/{infoDiagram-27XIBGKW-XrDxhr0x.js → infoDiagram-27XIBGKW-DipQoHxl.js} +1 -1
- package/dist/{ishikawaDiagram-5VMMS53U-CD1muzov.js → ishikawaDiagram-5VMMS53U-DgFMjSGz.js} +1 -1
- package/dist/{kanban-definition-UXKFOSKX-Dg9W142_.js → kanban-definition-UXKFOSKX-w1ACVjgl.js} +1 -1
- package/dist/main.js +164 -163
- package/dist/{mermaid-CiAXjLpA.js → mermaid-CHo49Yv-.js} +33 -33
- package/dist/{mindmap-definition-YA3MSWOX-DgCidgYJ.js → mindmap-definition-YA3MSWOX-CrT0KSfX.js} +1 -1
- package/dist/{number-overlay-editor-BCP3MxhC.js → number-overlay-editor-DBcnm0SY.js} +1 -1
- package/dist/{pegDiagram-XKGWAZYB-coOYYKB6.js → pegDiagram-XKGWAZYB-BqvywNJA.js} +2 -2
- package/dist/{pieDiagram-E7YTZNPT-BnXTc3bj.js → pieDiagram-E7YTZNPT-BfxJ_XCc.js} +1 -1
- package/dist/{process-output-e4jj9Ijt.js → process-output-CKRXyszb.js} +3 -3
- package/dist/{railroadDiagram-O6MQD6OU-B28-u83n.js → railroadDiagram-O6MQD6OU-8vFLVTPu.js} +2 -2
- package/dist/{requirementDiagram-BXWQKSXE-CdYCTMu0.js → requirementDiagram-BXWQKSXE-a_MxGvxe.js} +1 -1
- package/dist/{reveal-component-DO5BJB9l.js → reveal-component-DTBSuGkC.js} +4 -4
- package/dist/{sequenceDiagram-WJ2MYXX4-CM3aRAe_.js → sequenceDiagram-WJ2MYXX4-CXFMnxGh.js} +1 -1
- package/dist/{stateDiagram-D77RDMKH-DOZUMVj8.js → stateDiagram-D77RDMKH-DQeA_CwI.js} +2 -2
- package/dist/{stateDiagram-v2-MP3YSRHH-CISYxma6.js → stateDiagram-v2-MP3YSRHH-BxxQucm1.js} +2 -2
- package/dist/style.css +1 -1
- package/dist/{swimlanes-42K2YHIH-BkSppxp8.js → swimlanes-42K2YHIH-Do6L-cYg.js} +1 -1
- package/dist/{swimlanesDiagram-VR7AAH4N-c8G5pGxC.js → swimlanesDiagram-VR7AAH4N-DUzPvSVU.js} +2 -2
- package/dist/{timeline-definition-24CTP7MA-CaMQayqK.js → timeline-definition-24CTP7MA-D3c46WM5.js} +1 -1
- package/dist/{toDate-BNJf01u1.js → toDate-vIXljnsr.js} +39 -38
- package/dist/{vega-component-DG29TAB7.js → vega-component-DTG6zxaY.js} +3 -3
- package/dist/{vennDiagram-4TSXK5OY-CF8pfaK3.js → vennDiagram-4TSXK5OY-BSZD8JcZ.js} +1 -1
- package/dist/{wardleyDiagram-VM6X3IG4-dGPu39d-.js → wardleyDiagram-VM6X3IG4-48cUWkfA.js} +1 -1
- package/dist/{xychartDiagram-S5SC5T6Z-CP6-9q56.js → xychartDiagram-S5SC5T6Z-CswYgYeu.js} +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 +9 -2
- 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/panels/error-panel.tsx +1 -0
- package/src/components/editor/chrome/wrapper/__tests__/attachment-status.test.tsx +372 -0
- package/src/components/editor/chrome/wrapper/footer-item.tsx +32 -24
- package/src/components/editor/chrome/wrapper/footer-items/attachment-status.tsx +237 -0
- package/src/components/editor/chrome/wrapper/footer.tsx +2 -0
- package/src/components/editor/errors/__tests__/send-error-report-button.test.tsx +548 -0
- package/src/components/editor/errors/send-error-report-button.tsx +69 -0
- package/src/components/editor/output/MarimoErrorOutput.tsx +21 -5
- package/src/components/editor/output/MarimoTracebackOutput.tsx +35 -20
- package/src/components/editor/output/__tests__/ansi-reduce.test.ts +18 -0
- package/src/components/editor/output/ansi-reduce.ts +6 -0
- package/src/core/attachments/__tests__/toast.test.tsx +58 -0
- package/src/core/attachments/state.ts +21 -0
- package/src/core/attachments/toast.ts +15 -0
- package/src/core/cells/__tests__/cells.test.ts +52 -0
- package/src/core/cells/__tests__/error-report.test.ts +139 -0
- package/src/core/cells/__tests__/session.test.ts +50 -1
- package/src/core/cells/error-report.ts +63 -0
- package/src/core/cells/session.ts +28 -1
- package/src/core/config/pair.ts +5 -3
- package/src/core/islands/__tests__/bootstrap.test.ts +26 -0
- package/src/core/islands/bootstrap.ts +1 -0
- package/src/core/websocket/__tests__/useMarimoKernelConnection.hook.test.tsx +193 -0
- package/src/core/websocket/useMarimoKernelConnection.tsx +9 -0
- package/src/mount.tsx +3 -3
- 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 {
|
|
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
|
) : (
|
|
@@ -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
|
-
>(
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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";
|