@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
@@ -1,8 +1,15 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
3
  import { useAtomValue } from "jotai";
4
- import { BotIcon } from "lucide-react";
5
- import { useState } from "react";
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 { pairPreviewAtom } from "@/core/config/pair";
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 pairPreview = useAtomValue(pairPreviewAtom);
31
+ const hostEnabled = useAtomValue(pairConfigAtom)?.hostEnabled;
23
32
  const agents = useAtomValue(agentAttachmentsAtom);
24
- if (!pairPreview) {
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 groups = Objects.groupBy(
28
- agents,
29
- (attachment) =>
30
- attachment.identity
31
- ? `identity:${attachment.identity}`
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 open={open} onOpenChange={setOpen}>
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={`${label}: connected`}
99
+ tooltip={label}
50
100
  selected={open}
51
101
  data-testid="footer-attachment-status"
52
- aria-label={`${label}: Connected`}
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 className="w-64 text-sm">
63
- <h4 className="font-medium">{label}</h4>
64
- <ul className="space-y-2">
65
- {attachments.map((attachment) => (
66
- <li key={attachment.id}>
67
- <p
68
- className="truncate text-xs text-muted-foreground"
69
- title={attachment.id}
70
- >
71
- {attachment.id}
72
- </p>
73
- <p className="text-muted-foreground">
74
- Connected {timeAgo(attachment.since)}
75
- </p>
76
- </li>
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
- </ul>
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 { PAIR_PREVIEW } from "@/__tests__/fixtures/pair-preview";
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 type {
22
- CellOutputHandoff,
23
- ErrorHandoffOutput,
21
+ import {
22
+ type CellOutputMessage,
23
+ type ErrorMessageOutput,
24
+ ERROR_MESSAGE_INSTRUCTIONS,
24
25
  } from "@/core/cells/error-report";
25
- import { pairPreviewAtom } from "@/core/config/pair";
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: CellOutputHandoff<ErrorHandoffOutput> = {
62
+ const REPORT: CellOutputMessage<ErrorMessageOutput> = {
61
63
  type: "cell-output",
62
64
  cell_id: CELL_ID,
63
65
  code: CODE,
64
- output: { type: "error", message: MESSAGE },
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(pairPreviewAtom, preview ? PAIR_PREVIEW : undefined);
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 outside the preview without building content", () => {
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({ title: "Sent to Pi, Claude Code" }),
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({ variant: "danger", title }),
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({ title: "Sent to Pi" }),
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: { type: "error", message: "Ancestor failed" },
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 { pairPreviewAtom } from "@/core/config/pair";
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 pairPreview = useAtomValue(pairPreviewAtom);
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 (!pairPreview) {
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
- toast({ title: `Sent to ${agents.map(attachmentLabel).join(", ")}` });
41
+ agentToast({
42
+ title: `Sent to ${agents.map(attachmentLabel).join(", ")}`,
43
+ });
42
44
  } catch (error) {
43
- toast({
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 = renderHTML({
71
- html: traceback,
72
- additionalReplacements: [
73
- replaceTracebackFilenames,
74
- replaceTracebackPrefix,
75
- replaceMangledLocal,
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 lastTracebackLine = lastLine(traceback);
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={() => buildErrorReport(cellId, { code, traceback })}
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
- // Strip HTML from the traceback (sanitize first to prevent XSS)
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 el = document.createElement("div");
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