@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
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
3
|
import { useAtomValue } from "jotai";
|
|
4
|
-
import {
|
|
5
|
-
|
|
4
|
+
import {
|
|
5
|
+
BotIcon,
|
|
6
|
+
CheckIcon,
|
|
7
|
+
Loader2Icon,
|
|
8
|
+
UnplugIcon,
|
|
9
|
+
XIcon,
|
|
10
|
+
} from "lucide-react";
|
|
11
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
12
|
+
import { Button } from "@/components/ui/button";
|
|
6
13
|
import {
|
|
7
14
|
Popover,
|
|
8
15
|
PopoverContent,
|
|
@@ -13,43 +20,86 @@ import {
|
|
|
13
20
|
agentAttachmentsAtom,
|
|
14
21
|
attachmentLabel,
|
|
15
22
|
} from "@/core/attachments/state";
|
|
16
|
-
import {
|
|
23
|
+
import { agentToast } from "@/core/attachments/toast";
|
|
24
|
+
import { pairConfigAtom } from "@/core/config/pair";
|
|
25
|
+
import { stableSessionIdAtom } from "@/core/kernel/session";
|
|
26
|
+
import { API } from "@/core/network/api";
|
|
17
27
|
import { useTimeAgo } from "@/hooks/useFormatting";
|
|
18
|
-
import { Objects } from "@/utils/objects";
|
|
19
28
|
import { FooterItem } from "../footer-item";
|
|
20
29
|
|
|
21
30
|
export function AttachmentStatus() {
|
|
22
|
-
const
|
|
31
|
+
const hostEnabled = useAtomValue(pairConfigAtom)?.hostEnabled;
|
|
23
32
|
const agents = useAtomValue(agentAttachmentsAtom);
|
|
24
|
-
|
|
33
|
+
const [open, setOpen] = useState(false);
|
|
34
|
+
const previous = useRef(new Set<string>());
|
|
35
|
+
const automatic = useRef(false);
|
|
36
|
+
const pointerInside = useRef(false);
|
|
37
|
+
const timeout = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
38
|
+
|
|
39
|
+
const stopTimer = useCallback(() => clearTimeout(timeout.current), []);
|
|
40
|
+
const startTimer = useCallback(() => {
|
|
41
|
+
stopTimer();
|
|
42
|
+
timeout.current = setTimeout(() => setOpen(false), 3000);
|
|
43
|
+
}, [stopTimer]);
|
|
44
|
+
const preserveEditorFocus = (event: Event) => {
|
|
45
|
+
if (automatic.current) {
|
|
46
|
+
event.preventDefault();
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (!open) {
|
|
52
|
+
pointerInside.current = false;
|
|
53
|
+
}
|
|
54
|
+
const current = new Set(
|
|
55
|
+
hostEnabled ? agents.map((agent) => agent.identity ?? agent.id) : [],
|
|
56
|
+
);
|
|
57
|
+
const connected = [...current].some((id) => !previous.current.has(id));
|
|
58
|
+
for (const id of current) {
|
|
59
|
+
previous.current.add(id);
|
|
60
|
+
}
|
|
61
|
+
if (current.size === 0) {
|
|
62
|
+
stopTimer();
|
|
63
|
+
setOpen(false);
|
|
64
|
+
} else if (connected) {
|
|
65
|
+
if (open && !automatic.current) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
automatic.current = true;
|
|
69
|
+
setOpen(true);
|
|
70
|
+
if (!pointerInside.current) {
|
|
71
|
+
startTimer();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}, [agents, hostEnabled, open, startTimer, stopTimer]);
|
|
75
|
+
|
|
76
|
+
useEffect(() => stopTimer, [stopTimer]);
|
|
77
|
+
|
|
78
|
+
if (!hostEnabled || agents.length === 0) {
|
|
25
79
|
return null;
|
|
26
80
|
}
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
: `attachment:${attachment.id}`,
|
|
33
|
-
(attachment) => attachment,
|
|
34
|
-
);
|
|
35
|
-
return Object.entries(groups).map(([identity, attachments]) => (
|
|
36
|
-
<AttachmentStatusItem key={identity} attachments={attachments} />
|
|
37
|
-
));
|
|
38
|
-
}
|
|
81
|
+
const identities = new Set(agents.map((agent) => agent.identity ?? agent.id));
|
|
82
|
+
const label =
|
|
83
|
+
identities.size === 1
|
|
84
|
+
? `${attachmentLabel(agents[0])} connected`
|
|
85
|
+
: `${identities.size} agents connected`;
|
|
39
86
|
|
|
40
|
-
function AttachmentStatusItem({ attachments }: { attachments: Attachment[] }) {
|
|
41
|
-
const [open, setOpen] = useState(false);
|
|
42
|
-
const timeAgo = useTimeAgo();
|
|
43
|
-
const label = attachmentLabel(attachments[0]);
|
|
44
87
|
return (
|
|
45
|
-
<Popover
|
|
88
|
+
<Popover
|
|
89
|
+
open={open}
|
|
90
|
+
onOpenChange={(nextOpen) => {
|
|
91
|
+
automatic.current = false;
|
|
92
|
+
stopTimer();
|
|
93
|
+
setOpen(nextOpen);
|
|
94
|
+
}}
|
|
95
|
+
>
|
|
46
96
|
<PopoverTrigger asChild={true}>
|
|
47
97
|
<FooterItem
|
|
48
98
|
asChild={true}
|
|
49
|
-
tooltip={
|
|
99
|
+
tooltip={label}
|
|
50
100
|
selected={open}
|
|
51
101
|
data-testid="footer-attachment-status"
|
|
52
|
-
aria-label={
|
|
102
|
+
aria-label={label}
|
|
53
103
|
>
|
|
54
104
|
<button type="button">
|
|
55
105
|
<span className="flex items-center gap-1.5">
|
|
@@ -59,24 +109,129 @@ function AttachmentStatusItem({ attachments }: { attachments: Attachment[] }) {
|
|
|
59
109
|
</button>
|
|
60
110
|
</FooterItem>
|
|
61
111
|
</PopoverTrigger>
|
|
62
|
-
<PopoverContent
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
112
|
+
<PopoverContent
|
|
113
|
+
className="w-80 p-0 text-sm"
|
|
114
|
+
side="top"
|
|
115
|
+
align="start"
|
|
116
|
+
onOpenAutoFocus={preserveEditorFocus}
|
|
117
|
+
onCloseAutoFocus={preserveEditorFocus}
|
|
118
|
+
onPointerEnter={() => {
|
|
119
|
+
pointerInside.current = true;
|
|
120
|
+
stopTimer();
|
|
121
|
+
}}
|
|
122
|
+
onPointerLeave={() => {
|
|
123
|
+
pointerInside.current = false;
|
|
124
|
+
if (automatic.current) {
|
|
125
|
+
startTimer();
|
|
126
|
+
}
|
|
127
|
+
}}
|
|
128
|
+
onFocusCapture={() => {
|
|
129
|
+
automatic.current = false;
|
|
130
|
+
stopTimer();
|
|
131
|
+
}}
|
|
132
|
+
>
|
|
133
|
+
<h4 className="font-medium px-4 py-3 border-b">{label}</h4>
|
|
134
|
+
<div className="max-h-72 overflow-auto divide-y">
|
|
135
|
+
{agents.map((attachment) => (
|
|
136
|
+
<AttachmentStatusItem key={attachment.id} attachment={attachment} />
|
|
77
137
|
))}
|
|
78
|
-
</
|
|
138
|
+
</div>
|
|
139
|
+
<p className="px-4 py-3 border-t text-xs text-muted-foreground">
|
|
140
|
+
Disconnect removes the notebook connection. The agent session keeps
|
|
141
|
+
running.
|
|
142
|
+
</p>
|
|
79
143
|
</PopoverContent>
|
|
80
144
|
</Popover>
|
|
81
145
|
);
|
|
82
146
|
}
|
|
147
|
+
|
|
148
|
+
function AttachmentStatusItem({ attachment }: { attachment: Attachment }) {
|
|
149
|
+
const stableSessionId = useAtomValue(stableSessionIdAtom);
|
|
150
|
+
const [state, setState] = useState<"idle" | "confirming" | "disconnecting">(
|
|
151
|
+
"idle",
|
|
152
|
+
);
|
|
153
|
+
const confirming = state !== "idle";
|
|
154
|
+
const disconnecting = state === "disconnecting";
|
|
155
|
+
const disconnectButton = useRef<HTMLButtonElement>(null);
|
|
156
|
+
const timeAgo = useTimeAgo();
|
|
157
|
+
const label = attachmentLabel(attachment);
|
|
158
|
+
const disconnectLabel = disconnecting
|
|
159
|
+
? `Disconnecting ${label}`
|
|
160
|
+
: confirming
|
|
161
|
+
? `Confirm disconnect ${label}`
|
|
162
|
+
: `Disconnect ${label}`;
|
|
163
|
+
|
|
164
|
+
const disconnect = async () => {
|
|
165
|
+
if (disconnecting || !stableSessionId) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
setState("disconnecting");
|
|
169
|
+
try {
|
|
170
|
+
await API.post(
|
|
171
|
+
`/pair/notebooks/${encodeURIComponent(stableSessionId)}/attachments/detach`,
|
|
172
|
+
{ attachment_id: attachment.id },
|
|
173
|
+
);
|
|
174
|
+
} catch {
|
|
175
|
+
agentToast({ variant: "danger", title: `Could not disconnect ${label}` });
|
|
176
|
+
} finally {
|
|
177
|
+
setState("idle");
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<div className="flex items-start gap-3 px-4 py-3">
|
|
183
|
+
<div className="min-w-0 flex-1">
|
|
184
|
+
<h5 className="font-medium">{label}</h5>
|
|
185
|
+
<p className="text-xs text-muted-foreground mt-1">
|
|
186
|
+
Connected {timeAgo(attachment.since)}
|
|
187
|
+
</p>
|
|
188
|
+
<p
|
|
189
|
+
className="text-xs font-mono text-muted-foreground truncate mt-1"
|
|
190
|
+
title={attachment.id}
|
|
191
|
+
>
|
|
192
|
+
{attachment.id}
|
|
193
|
+
</p>
|
|
194
|
+
</div>
|
|
195
|
+
<div className="flex shrink-0 items-center gap-1">
|
|
196
|
+
{confirming && (
|
|
197
|
+
<span className="text-xs text-muted-foreground">
|
|
198
|
+
{disconnecting ? "Disconnecting…" : "Disconnect?"}
|
|
199
|
+
</span>
|
|
200
|
+
)}
|
|
201
|
+
<Button
|
|
202
|
+
ref={disconnectButton}
|
|
203
|
+
size="icon"
|
|
204
|
+
variant="outlineDestructive"
|
|
205
|
+
disabled={disconnecting || !stableSessionId}
|
|
206
|
+
onClick={confirming ? disconnect : () => setState("confirming")}
|
|
207
|
+
aria-label={disconnectLabel}
|
|
208
|
+
title={disconnectLabel}
|
|
209
|
+
aria-busy={disconnecting}
|
|
210
|
+
>
|
|
211
|
+
{disconnecting ? (
|
|
212
|
+
<Loader2Icon className="size-3.5 animate-spin" aria-hidden={true} />
|
|
213
|
+
) : confirming ? (
|
|
214
|
+
<CheckIcon className="size-3.5" aria-hidden={true} />
|
|
215
|
+
) : (
|
|
216
|
+
<UnplugIcon className="size-3.5" aria-hidden={true} />
|
|
217
|
+
)}
|
|
218
|
+
</Button>
|
|
219
|
+
{confirming && (
|
|
220
|
+
<Button
|
|
221
|
+
size="icon"
|
|
222
|
+
variant="outline"
|
|
223
|
+
disabled={disconnecting}
|
|
224
|
+
onClick={() => {
|
|
225
|
+
setState("idle");
|
|
226
|
+
disconnectButton.current?.focus();
|
|
227
|
+
}}
|
|
228
|
+
aria-label={`Cancel disconnect ${label}`}
|
|
229
|
+
title={`Cancel disconnect ${label}`}
|
|
230
|
+
>
|
|
231
|
+
<XIcon className="size-3.5" aria-hidden={true} />
|
|
232
|
+
</Button>
|
|
233
|
+
)}
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
@@ -13,16 +13,18 @@ import type React from "react";
|
|
|
13
13
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
14
14
|
import { MockNotebook } from "@/__mocks__/notebook";
|
|
15
15
|
import { cellId } from "@/__tests__/branded";
|
|
16
|
-
import {
|
|
16
|
+
import { PAIR_CONFIG } from "@/__tests__/fixtures/pair-config";
|
|
17
17
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
18
18
|
import { toast } from "@/components/ui/use-toast";
|
|
19
19
|
import { type Attachment, attachmentsAtom } from "@/core/attachments/state";
|
|
20
20
|
import { exportedForTesting, notebookAtom } from "@/core/cells/cells";
|
|
21
|
-
import
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
import {
|
|
22
|
+
type CellOutputMessage,
|
|
23
|
+
type ErrorMessageOutput,
|
|
24
|
+
ERROR_MESSAGE_INSTRUCTIONS,
|
|
24
25
|
} from "@/core/cells/error-report";
|
|
25
|
-
import {
|
|
26
|
+
import { pairConfigAtom } from "@/core/config/pair";
|
|
27
|
+
import { notebookStateFromSession } from "@/core/cells/session";
|
|
26
28
|
import type { MarimoError, OutputMessage } from "@/core/kernel/messages";
|
|
27
29
|
import {
|
|
28
30
|
type StableSessionId,
|
|
@@ -57,11 +59,15 @@ const AGENT: Attachment = {
|
|
|
57
59
|
const ROUTE = "/pair/notebooks/stable%2Ftest/messages";
|
|
58
60
|
const MESSAGE = "df is not defined";
|
|
59
61
|
const CODE = "df.head()";
|
|
60
|
-
const REPORT:
|
|
62
|
+
const REPORT: CellOutputMessage<ErrorMessageOutput> = {
|
|
61
63
|
type: "cell-output",
|
|
62
64
|
cell_id: CELL_ID,
|
|
63
65
|
code: CODE,
|
|
64
|
-
output: {
|
|
66
|
+
output: {
|
|
67
|
+
type: "error",
|
|
68
|
+
instructions: ERROR_MESSAGE_INSTRUCTIONS,
|
|
69
|
+
message: MESSAGE,
|
|
70
|
+
},
|
|
65
71
|
};
|
|
66
72
|
const ERRORS: MarimoError[] = [
|
|
67
73
|
{
|
|
@@ -86,7 +92,7 @@ function renderButton({
|
|
|
86
92
|
} = {}) {
|
|
87
93
|
const store = createStore();
|
|
88
94
|
store.set(connectionAtom, { state: ConnectionState.OPEN });
|
|
89
|
-
store.set(
|
|
95
|
+
store.set(pairConfigAtom, { ...PAIR_CONFIG, hostEnabled: preview });
|
|
90
96
|
store.set(attachmentsAtom, attachments);
|
|
91
97
|
store.set(stableSessionIdAtom, stableId);
|
|
92
98
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
@@ -105,9 +111,15 @@ describe("SendErrorReportButton", () => {
|
|
|
105
111
|
beforeEach(() => {
|
|
106
112
|
vi.clearAllMocks();
|
|
107
113
|
vi.mocked(API.post).mockResolvedValue("");
|
|
114
|
+
vi.mocked(toast).mockReturnValue({
|
|
115
|
+
id: "notice",
|
|
116
|
+
dismiss: vi.fn(),
|
|
117
|
+
update: vi.fn(),
|
|
118
|
+
upsert: vi.fn(),
|
|
119
|
+
});
|
|
108
120
|
});
|
|
109
121
|
|
|
110
|
-
it("hides
|
|
122
|
+
it("hides when host is disabled without building content", () => {
|
|
111
123
|
const getContent = vi.fn(() => REPORT);
|
|
112
124
|
expect(
|
|
113
125
|
renderButton({
|
|
@@ -138,7 +150,10 @@ describe("SendErrorReportButton", () => {
|
|
|
138
150
|
expect(getContent).not.toHaveBeenCalled();
|
|
139
151
|
send();
|
|
140
152
|
await waitFor(() =>
|
|
141
|
-
expect(toast).toHaveBeenCalledWith({
|
|
153
|
+
expect(toast).toHaveBeenCalledWith({
|
|
154
|
+
title: "Sent to Pi, Claude Code",
|
|
155
|
+
duration: 3000,
|
|
156
|
+
}),
|
|
142
157
|
);
|
|
143
158
|
expect(API.post).toHaveBeenCalledWith(ROUTE, REPORT);
|
|
144
159
|
expect(getContent).toHaveBeenCalledOnce();
|
|
@@ -190,12 +205,20 @@ describe("SendErrorReportButton", () => {
|
|
|
190
205
|
renderButton();
|
|
191
206
|
send();
|
|
192
207
|
await waitFor(() =>
|
|
193
|
-
expect(toast).toHaveBeenCalledWith({
|
|
208
|
+
expect(toast).toHaveBeenCalledWith({
|
|
209
|
+
variant: "danger",
|
|
210
|
+
title,
|
|
211
|
+
description: error.message,
|
|
212
|
+
duration: 5000,
|
|
213
|
+
}),
|
|
194
214
|
);
|
|
195
215
|
expect(screen.getByRole("button")).toBeEnabled();
|
|
196
216
|
send();
|
|
197
217
|
await waitFor(() =>
|
|
198
|
-
expect(toast).toHaveBeenCalledWith({
|
|
218
|
+
expect(toast).toHaveBeenCalledWith({
|
|
219
|
+
title: "Sent to Pi",
|
|
220
|
+
duration: 3000,
|
|
221
|
+
}),
|
|
199
222
|
);
|
|
200
223
|
expect(API.post).toHaveBeenCalledTimes(2);
|
|
201
224
|
expect(API.post).toHaveBeenLastCalledWith(ROUTE, REPORT);
|
|
@@ -222,6 +245,7 @@ describe("SendErrorReportButton", () => {
|
|
|
222
245
|
...REPORT,
|
|
223
246
|
output: {
|
|
224
247
|
type: "error",
|
|
248
|
+
instructions: ERROR_MESSAGE_INSTRUCTIONS,
|
|
225
249
|
message: MESSAGE,
|
|
226
250
|
traceback: "Traceback\nNameError: df is not defined",
|
|
227
251
|
},
|
|
@@ -264,6 +288,7 @@ describe("SendErrorReportButton", () => {
|
|
|
264
288
|
...REPORT,
|
|
265
289
|
output: {
|
|
266
290
|
type: "error",
|
|
291
|
+
instructions: ERROR_MESSAGE_INSTRUCTIONS,
|
|
267
292
|
message: "ValueError: bad <value>",
|
|
268
293
|
traceback: "Traceback\nValueError: bad <value>",
|
|
269
294
|
},
|
|
@@ -271,6 +296,90 @@ describe("SendErrorReportButton", () => {
|
|
|
271
296
|
);
|
|
272
297
|
});
|
|
273
298
|
|
|
299
|
+
it("sends plain code-mode traceback text without parsing it as HTML", async () => {
|
|
300
|
+
const traceback =
|
|
301
|
+
' File "<cell>", line 1\nValueError: bad <value> & <other>';
|
|
302
|
+
renderButton({
|
|
303
|
+
content: (
|
|
304
|
+
<OutputRenderer
|
|
305
|
+
cellId={CELL_ID}
|
|
306
|
+
message={{
|
|
307
|
+
channel: "stderr",
|
|
308
|
+
mimetype: "application/vnd.marimo+traceback",
|
|
309
|
+
traceback_format: "text",
|
|
310
|
+
code: CODE,
|
|
311
|
+
data: traceback,
|
|
312
|
+
}}
|
|
313
|
+
/>
|
|
314
|
+
),
|
|
315
|
+
});
|
|
316
|
+
expect(screen.getByText(/ValueError: bad <value>/)).toHaveTextContent(
|
|
317
|
+
"<cell>",
|
|
318
|
+
);
|
|
319
|
+
send();
|
|
320
|
+
await waitFor(() =>
|
|
321
|
+
expect(API.post).toHaveBeenCalledWith(ROUTE, {
|
|
322
|
+
...REPORT,
|
|
323
|
+
output: {
|
|
324
|
+
type: "error",
|
|
325
|
+
instructions: ERROR_MESSAGE_INSTRUCTIONS,
|
|
326
|
+
message: "ValueError: bad <value> & <other>",
|
|
327
|
+
traceback,
|
|
328
|
+
},
|
|
329
|
+
}),
|
|
330
|
+
);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("sends the restored exception type and complete traceback", async () => {
|
|
334
|
+
const state = notebookStateFromSession(
|
|
335
|
+
{
|
|
336
|
+
version: "1",
|
|
337
|
+
metadata: { marimo_version: "1", script_metadata_hash: null },
|
|
338
|
+
cells: [
|
|
339
|
+
{
|
|
340
|
+
id: CELL_ID,
|
|
341
|
+
code_hash: null,
|
|
342
|
+
console: [],
|
|
343
|
+
outputs: [
|
|
344
|
+
{
|
|
345
|
+
type: "error",
|
|
346
|
+
ename: "ValueError",
|
|
347
|
+
evalue: "cached failure",
|
|
348
|
+
traceback: [
|
|
349
|
+
"<pre>Traceback",
|
|
350
|
+
" failed()\n",
|
|
351
|
+
"ValueError: cached failure</pre>",
|
|
352
|
+
],
|
|
353
|
+
code: CODE,
|
|
354
|
+
},
|
|
355
|
+
],
|
|
356
|
+
},
|
|
357
|
+
],
|
|
358
|
+
},
|
|
359
|
+
null,
|
|
360
|
+
);
|
|
361
|
+
const message = state?.cellRuntime[CELL_ID].output;
|
|
362
|
+
expect(message).toBeDefined();
|
|
363
|
+
renderButton({
|
|
364
|
+
content: message && <OutputRenderer cellId={CELL_ID} message={message} />,
|
|
365
|
+
});
|
|
366
|
+
send();
|
|
367
|
+
await waitFor(() =>
|
|
368
|
+
expect(API.post).toHaveBeenCalledWith(ROUTE, {
|
|
369
|
+
...REPORT,
|
|
370
|
+
output: {
|
|
371
|
+
type: "error",
|
|
372
|
+
instructions: ERROR_MESSAGE_INSTRUCTIONS,
|
|
373
|
+
message: "cached failure",
|
|
374
|
+
traceback: "Traceback\n failed()\nValueError: cached failure",
|
|
375
|
+
},
|
|
376
|
+
}),
|
|
377
|
+
);
|
|
378
|
+
expect(message?.data).toEqual([
|
|
379
|
+
expect.objectContaining({ exception_type: "ValueError" }),
|
|
380
|
+
]);
|
|
381
|
+
});
|
|
382
|
+
|
|
274
383
|
it("sends updated content after the error changes", async () => {
|
|
275
384
|
const { rerender } = renderButton();
|
|
276
385
|
const updated = {
|
|
@@ -385,7 +494,11 @@ describe("SendErrorReportButton", () => {
|
|
|
385
494
|
type: "cell-output",
|
|
386
495
|
cell_id: CELL_ID,
|
|
387
496
|
code: "x + 1",
|
|
388
|
-
output: {
|
|
497
|
+
output: {
|
|
498
|
+
type: "error",
|
|
499
|
+
instructions: ERROR_MESSAGE_INSTRUCTIONS,
|
|
500
|
+
message: "Ancestor failed",
|
|
501
|
+
},
|
|
389
502
|
}),
|
|
390
503
|
);
|
|
391
504
|
});
|
|
@@ -408,6 +521,7 @@ describe("SendErrorReportButton", () => {
|
|
|
408
521
|
code: "slow()",
|
|
409
522
|
output: {
|
|
410
523
|
type: "error",
|
|
524
|
+
instructions: ERROR_MESSAGE_INSTRUCTIONS,
|
|
411
525
|
message: "This cell was interrupted and needs to be re-run",
|
|
412
526
|
},
|
|
413
527
|
}),
|
|
@@ -4,27 +4,27 @@ import { useAtomValue } from "jotai";
|
|
|
4
4
|
import { SendIcon } from "lucide-react";
|
|
5
5
|
import { useState } from "react";
|
|
6
6
|
import { Button } from "@/components/ui/button";
|
|
7
|
-
import { toast } from "@/components/ui/use-toast";
|
|
8
7
|
import {
|
|
9
8
|
agentAttachmentsAtom,
|
|
10
9
|
attachmentLabel,
|
|
11
10
|
} from "@/core/attachments/state";
|
|
12
|
-
import {
|
|
11
|
+
import { agentToast } from "@/core/attachments/toast";
|
|
12
|
+
import { pairConfigAtom } from "@/core/config/pair";
|
|
13
13
|
import { stableSessionIdAtom } from "@/core/kernel/session";
|
|
14
14
|
import { API } from "@/core/network/api";
|
|
15
|
-
import { HTTPError } from "@/utils/errors";
|
|
15
|
+
import { HTTPError, prettyError } from "@/utils/errors";
|
|
16
16
|
|
|
17
17
|
export function SendErrorReportButton({
|
|
18
18
|
getContent,
|
|
19
19
|
}: {
|
|
20
20
|
getContent: () => unknown;
|
|
21
21
|
}) {
|
|
22
|
-
const
|
|
22
|
+
const hostEnabled = useAtomValue(pairConfigAtom)?.hostEnabled;
|
|
23
23
|
const agents = useAtomValue(agentAttachmentsAtom);
|
|
24
24
|
const stableSessionId = useAtomValue(stableSessionIdAtom);
|
|
25
25
|
const [sending, setSending] = useState(false);
|
|
26
26
|
|
|
27
|
-
if (!
|
|
27
|
+
if (!hostEnabled) {
|
|
28
28
|
return null;
|
|
29
29
|
}
|
|
30
30
|
|
|
@@ -38,14 +38,17 @@ export function SendErrorReportButton({
|
|
|
38
38
|
`/pair/notebooks/${encodeURIComponent(stableSessionId)}/messages`,
|
|
39
39
|
getContent(),
|
|
40
40
|
);
|
|
41
|
-
|
|
41
|
+
agentToast({
|
|
42
|
+
title: `Sent to ${agents.map(attachmentLabel).join(", ")}`,
|
|
43
|
+
});
|
|
42
44
|
} catch (error) {
|
|
43
|
-
|
|
45
|
+
agentToast({
|
|
44
46
|
variant: "danger",
|
|
45
47
|
title:
|
|
46
48
|
error instanceof HTTPError && error.status === 409
|
|
47
49
|
? "No agent attached"
|
|
48
50
|
: "Could not send error",
|
|
51
|
+
description: prettyError(error),
|
|
49
52
|
});
|
|
50
53
|
} finally {
|
|
51
54
|
setSending(false);
|
|
@@ -35,6 +35,7 @@ import { isWasm } from "@/core/wasm/utils";
|
|
|
35
35
|
import { renderHTML } from "@/plugins/core/RenderHTML";
|
|
36
36
|
import { sanitizeHtml } from "@/plugins/core/sanitize-html";
|
|
37
37
|
import { copyToClipboard } from "@/utils/copy";
|
|
38
|
+
import { parseHtmlContent } from "@/utils/dom";
|
|
38
39
|
import {
|
|
39
40
|
containsMangledLocal,
|
|
40
41
|
splitMangledLocals,
|
|
@@ -54,29 +55,38 @@ import type { OnRefactorWithAI } from "../Output";
|
|
|
54
55
|
interface Props {
|
|
55
56
|
cellId: CellId | undefined;
|
|
56
57
|
traceback: string;
|
|
58
|
+
tracebackFormat?: "html" | "text";
|
|
57
59
|
code?: string;
|
|
58
60
|
onRefactorWithAI?: OnRefactorWithAI;
|
|
59
61
|
}
|
|
60
62
|
|
|
61
|
-
/**
|
|
62
|
-
* List of errors due to violations of Marimo semantics.
|
|
63
|
-
*/
|
|
63
|
+
/** Render a traceback and actions for its captured execution. */
|
|
64
64
|
export const MarimoTracebackOutput = ({
|
|
65
65
|
onRefactorWithAI,
|
|
66
66
|
traceback,
|
|
67
|
+
tracebackFormat = "html",
|
|
67
68
|
cellId,
|
|
68
69
|
code,
|
|
69
70
|
}: Props): JSX.Element => {
|
|
70
|
-
const htmlTraceback =
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
71
|
+
const htmlTraceback =
|
|
72
|
+
tracebackFormat === "text" ? (
|
|
73
|
+
<pre>{traceback}</pre>
|
|
74
|
+
) : (
|
|
75
|
+
renderHTML({
|
|
76
|
+
html: traceback,
|
|
77
|
+
additionalReplacements: [
|
|
78
|
+
replaceTracebackFilenames,
|
|
79
|
+
replaceTracebackPrefix,
|
|
80
|
+
replaceMangledLocal,
|
|
81
|
+
],
|
|
82
|
+
})
|
|
83
|
+
);
|
|
78
84
|
|
|
79
|
-
const
|
|
85
|
+
const textTraceback =
|
|
86
|
+
tracebackFormat === "html"
|
|
87
|
+
? parseHtmlContent(sanitizeHtml(traceback))
|
|
88
|
+
: traceback;
|
|
89
|
+
const lastTracebackLine = lastLine(textTraceback);
|
|
80
90
|
const aiFeaturesEnabled = useAtomValue(aiFeaturesEnabledAtom);
|
|
81
91
|
const openAiAssistant = useOpenAiAssistant();
|
|
82
92
|
|
|
@@ -137,7 +147,9 @@ export const MarimoTracebackOutput = ({
|
|
|
137
147
|
<div className="flex gap-2">
|
|
138
148
|
{cellId && lastTracebackLine && (
|
|
139
149
|
<SendErrorReportButton
|
|
140
|
-
getContent={() =>
|
|
150
|
+
getContent={() =>
|
|
151
|
+
buildErrorReport(cellId, { code, traceback, tracebackFormat })
|
|
152
|
+
}
|
|
141
153
|
/>
|
|
142
154
|
)}
|
|
143
155
|
{showAIFix && (
|
|
@@ -195,11 +207,7 @@ export const MarimoTracebackOutput = ({
|
|
|
195
207
|
</DropdownMenuItem>
|
|
196
208
|
<DropdownMenuItem
|
|
197
209
|
onClick={() => {
|
|
198
|
-
|
|
199
|
-
const div = document.createElement("div");
|
|
200
|
-
div.innerHTML = sanitizeHtml(traceback);
|
|
201
|
-
const textContent = div.textContent || "";
|
|
202
|
-
copyToClipboard(textContent);
|
|
210
|
+
copyToClipboard(textTraceback);
|
|
203
211
|
}}
|
|
204
212
|
>
|
|
205
213
|
<CopyIcon className="h-4 w-4 mr-2" />
|
|
@@ -214,9 +222,7 @@ export const MarimoTracebackOutput = ({
|
|
|
214
222
|
};
|
|
215
223
|
|
|
216
224
|
function lastLine(text: string): string {
|
|
217
|
-
const
|
|
218
|
-
el.innerHTML = sanitizeHtml(text);
|
|
219
|
-
const lines = el.textContent?.split("\n").filter(Boolean);
|
|
225
|
+
const lines = text.split("\n").filter(Boolean);
|
|
220
226
|
return lines?.at(-1) || "";
|
|
221
227
|
}
|
|
222
228
|
|