@marimo-team/islands 0.23.15-dev73 → 0.23.15-dev75

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.
@@ -36291,7 +36291,7 @@ ${d}`,
36291
36291
  return Logger.warn("Failed to get version from mount config"), null;
36292
36292
  }
36293
36293
  }
36294
- marimoVersionAtom = atom(getVersionFromMountConfig() || "0.23.15-dev73");
36294
+ marimoVersionAtom = atom(getVersionFromMountConfig() || "0.23.15-dev75");
36295
36295
  showCodeInRunModeAtom = atom(true);
36296
36296
  atom(null);
36297
36297
  var import_compiler_runtime = require_compiler_runtime();
package/dist/main.js CHANGED
@@ -26,7 +26,7 @@ import { $ as notebookOutline, $t as requestClientAtom, A as ChevronDownIcon, Bt
26
26
  import { __tla as __tla_1 } from "./chunk-5FQGJX7Z-B9WoXK1R.js";
27
27
  import { o as useSize, s as Root$1, u as createLucideIcon } from "./dist-B96tMAst.js";
28
28
  import { A as SquareFunction, C as DEFAULT_COLOR_SCHEME, D as SCALE_TYPE_DESCRIPTIONS, E as EMPTY_VALUE$1, O as TIME_UNIT_DESCRIPTIONS, S as DEFAULT_AGGREGATION, T as DEFAULT_TIME_UNIT, _ as AGGREGATION_TYPE_DESCRIPTIONS, a as AGGREGATION_FNS$1, b as COLOR_SCHEMES, c as COLOR_BY_FIELDS, d as NONE_VALUE, f as SELECTABLE_DATA_TYPES, g as TIME_UNITS, h as STRING_AGGREGATION_FNS, i as convertDataTypeToSelectable, j as ChartColumn, k as escapeFieldName, l as COMBINED_TIME_UNITS, m as SORT_TYPES, n as createSpecWithoutData, o as BIN_AGGREGATION, p as SINGLE_TIME_UNITS, r as isFieldSet, s as CHART_TYPES, t as augmentSpecWithData, u as ChartType, v as AGGREGATION_TYPE_ICON, w as DEFAULT_MAX_BINS_FACET, x as COUNT_FIELD, y as CHART_TYPE_ICON } from "./spec-CnaxwDqy.js";
29
- import { $ as ContextAwarePanelItem, $t as EmotionCacheProvider, A as prettifyRowColumnCount, At as loadTableData, B as useSelectList, Bt as dateToLocalISOTime, C as Filenames, Ct as ColumnChartContext, D as EmptyState, Dt as usePrevious$1, E as ColumnPreviewContainer, Et as useIntersectionObserver, F as ContextMenuContent, Ft as getMimeValues, G as ComboboxItem, Gt as ChartErrorState, H as DatePicker, Ht as TabsContent, I as ContextMenuItem, It as isNullishFilter, J as CommandInput, Jt as LazyVegaEmbed, K as Command, Kt as ChartInfoState, L as ContextMenuSeparator, Lt as Maps, M as getColumnCountForDisplay, Mt as SELECT_COLUMN_ID, N as getUserColumnVisibilityCounts, Nt as TOO_MANY_ROWS, O as ErrorState, Ot as getPageIndexForRow, P as ContextMenu, Pt as toFieldTypes, Q as smartMatch, Qt as HtmlOutput, R as ContextMenuTrigger, Rt as dateToLocalISODate, S as Progress, St as renderCellValue, T as ColumnName, Tt as DelayMount, U as DateRangePicker, Ut as TabsList, V as CompactChipRow, Vt as Tabs, W as Combobox, Wt as TabsTrigger, X as CommandList, Xt as RenderTextWithLinks, Y as CommandItem, Yt as useOverflowDetection, Z as CommandSeparator, Zt as Kbd, _ as InstallPackageButton, _n as ArrowDownWideNarrow, _t as TableHeader, an as GripHorizontal, at as SlotNames, b as downloadByURL, bt as generateColumns, d as RadioGroup, dn as Code, dt as Fill, en as hasFunctionProperty, et as PANEL_TYPES, f as RadioGroupItem, fn as ChevronsUpDown, ft as Provider$1, g as DataTable, gn as ChevronLeft, gt as TableHead, h as OutputRenderer, hn as ChevronsDownUp, ht as TableCell, in as TextWrap, it as isCellAwareAtom, j as prettifyRowCount, jt as INDEX_COLUMN_NAME, k as LoadingState, kt as loadTableAndRawData, l as Slide, ln as Ellipsis, m as OutputArea, mn as ChevronsLeft, mt as TableBody, n as marimoVersionAtom, nn as $fae977aafc393c5c$export$588937bcd60ade55, nt as contextAwarePanelOwner, on as Funnel, ot as slotsController, p as JsonOutput, pn as ChevronsRight, pt as Table, q as CommandEmpty, qt as ChartLoadingState, r as showCodeInRunModeAtom, rn as $fae977aafc393c5c$export$6b862160d295c8e, rt as contextAwarePanelType, s as SLIDE_TYPE_OPTIONS_BY_VALUE, sn as EyeOff, st as Toggle, t as useNotebookCodeAvailable, tn as isRecord, tt as contextAwarePanelOpen, u as Switch, un as Download, v as ADD_PRINTING_CLASS, vt as TableRow, w as downloadSizeLimitAtom, wt as ColumnChartSpecModel, x as downloadHTMLAsImage, xt as inferFieldTypes, y as downloadBlob, yt as NAMELESS_COLUMN_PREFIX, z as useInternalStateWithSync, zt as dateToLocalISODateTime, __tla as __tla_2 } from "./code-visibility-B0L8DXul.js";
29
+ import { $ as ContextAwarePanelItem, $t as EmotionCacheProvider, A as prettifyRowColumnCount, At as loadTableData, B as useSelectList, Bt as dateToLocalISOTime, C as Filenames, Ct as ColumnChartContext, D as EmptyState, Dt as usePrevious$1, E as ColumnPreviewContainer, Et as useIntersectionObserver, F as ContextMenuContent, Ft as getMimeValues, G as ComboboxItem, Gt as ChartErrorState, H as DatePicker, Ht as TabsContent, I as ContextMenuItem, It as isNullishFilter, J as CommandInput, Jt as LazyVegaEmbed, K as Command, Kt as ChartInfoState, L as ContextMenuSeparator, Lt as Maps, M as getColumnCountForDisplay, Mt as SELECT_COLUMN_ID, N as getUserColumnVisibilityCounts, Nt as TOO_MANY_ROWS, O as ErrorState, Ot as getPageIndexForRow, P as ContextMenu, Pt as toFieldTypes, Q as smartMatch, Qt as HtmlOutput, R as ContextMenuTrigger, Rt as dateToLocalISODate, S as Progress, St as renderCellValue, T as ColumnName, Tt as DelayMount, U as DateRangePicker, Ut as TabsList, V as CompactChipRow, Vt as Tabs, W as Combobox, Wt as TabsTrigger, X as CommandList, Xt as RenderTextWithLinks, Y as CommandItem, Yt as useOverflowDetection, Z as CommandSeparator, Zt as Kbd, _ as InstallPackageButton, _n as ArrowDownWideNarrow, _t as TableHeader, an as GripHorizontal, at as SlotNames, b as downloadByURL, bt as generateColumns, d as RadioGroup, dn as Code, dt as Fill, en as hasFunctionProperty, et as PANEL_TYPES, f as RadioGroupItem, fn as ChevronsUpDown, ft as Provider$1, g as DataTable, gn as ChevronLeft, gt as TableHead, h as OutputRenderer, hn as ChevronsDownUp, ht as TableCell, in as TextWrap, it as isCellAwareAtom, j as prettifyRowCount, jt as INDEX_COLUMN_NAME, k as LoadingState, kt as loadTableAndRawData, l as Slide, ln as Ellipsis, m as OutputArea, mn as ChevronsLeft, mt as TableBody, n as marimoVersionAtom, nn as $fae977aafc393c5c$export$588937bcd60ade55, nt as contextAwarePanelOwner, on as Funnel, ot as slotsController, p as JsonOutput, pn as ChevronsRight, pt as Table, q as CommandEmpty, qt as ChartLoadingState, r as showCodeInRunModeAtom, rn as $fae977aafc393c5c$export$6b862160d295c8e, rt as contextAwarePanelType, s as SLIDE_TYPE_OPTIONS_BY_VALUE, sn as EyeOff, st as Toggle, t as useNotebookCodeAvailable, tn as isRecord, tt as contextAwarePanelOpen, u as Switch, un as Download, v as ADD_PRINTING_CLASS, vt as TableRow, w as downloadSizeLimitAtom, wt as ColumnChartSpecModel, x as downloadHTMLAsImage, xt as inferFieldTypes, y as downloadBlob, yt as NAMELESS_COLUMN_PREFIX, z as useInternalStateWithSync, zt as dateToLocalISODateTime, __tla as __tla_2 } from "./code-visibility-DfgBK6L-.js";
30
30
  import { c as Calendar, i as createReducerAndAtoms, n as useOnUnmount, o as ToggleLeft, t as useOnMount } from "./useLifecycle-DqPxHgAN.js";
31
31
  import { t as Check } from "./check-Cs-KV5XY.js";
32
32
  import { A as Icon, C as logNever, D as $18f2051aff69b9bf$export$a54013f0d02a8f82, E as $18f2051aff69b9bf$export$43bb16f9c6d9e3f7, F as createCollection, I as X, M as clamp$2, N as usePrevious$2, P as useDirection, R as ChevronDown, S as assertNever, a as SelectGroup, c as SelectSeparator, d as NativeSelect, i as SelectContent, j as Trigger$1, l as SelectTrigger, n as capitalize, o as SelectItem, r as Select, s as SelectLabel, t as Strings, u as SelectValue, w as $a916eb452884faea$export$b7a616150fdb9f44 } from "./strings-BYAhZnQi.js";
@@ -38157,7 +38157,7 @@ ${c}
38157
38157
  function _temp2$2(e) {
38158
38158
  e.target === e.currentTarget && e.key === "Enter" && (e.preventDefault(), e.stopPropagation(), e.currentTarget.click());
38159
38159
  }
38160
- var LazySlidesComponent = import_react.lazy(() => import("./reveal-component-BY3OPogD.js"));
38160
+ var LazySlidesComponent = import_react.lazy(() => import("./reveal-component-CP1pGjn-.js"));
38161
38161
  const SlidesLayoutRenderer = ({ layout: e, setLayout: r, cells: c, mode: l }) => {
38162
38162
  var _a5;
38163
38163
  let u = useAtomValue(kioskModeAtom), d = l === "read" || u, f = useAtomValue(numColumnsAtom) > 1, [p, m] = (0, import_react.useState)(null), { slideCells: h, skippedIds: g, noOutputIds: _, slideTypes: v, startCellIndex: y } = (0, import_react.useMemo)(() => computeSlideCellsInfo(c, e), [
@@ -9,7 +9,7 @@ import { t as require_compiler_runtime } from "./compiler-runtime-CEbnTgxf.js";
9
9
  import { dt as kioskModeAtom, ht as outputIsStale } from "./html-to-image-DDENewl2.js";
10
10
  import "./chunk-5FQGJX7Z-B9WoXK1R.js";
11
11
  import { u as createLucideIcon } from "./dist-B96tMAst.js";
12
- import { a as DEFAULT_DECK_VERTICAL_ALIGN, c as SlideSidebar, cn as Expand, ct as Panel, dn as Code, i as DEFAULT_DECK_TRANSITION, l as Slide, lt as PanelGroup, o as DEFAULT_SLIDE_TYPE, sn as EyeOff, t as useNotebookCodeAvailable, ut as PanelResizeHandle } from "./code-visibility-B0L8DXul.js";
12
+ import { a as DEFAULT_DECK_VERTICAL_ALIGN, c as SlideSidebar, cn as Expand, ct as Panel, dn as Code, i as DEFAULT_DECK_TRANSITION, l as Slide, lt as PanelGroup, o as DEFAULT_SLIDE_TYPE, sn as EyeOff, t as useNotebookCodeAvailable, ut as PanelResizeHandle } from "./code-visibility-DfgBK6L-.js";
13
13
  import { X as useDebouncedCallback } from "./input-DStQIuqD.js";
14
14
  import "./toDate-B3IRfHhw.js";
15
15
  import "./react-dom-BTJzcVJ9.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marimo-team/islands",
3
- "version": "0.23.15-dev73",
3
+ "version": "0.23.15-dev75",
4
4
  "main": "dist/main.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -50,6 +50,7 @@ function wrapper({ children }: { children: React.ReactNode }) {
50
50
  describe("FeedbackModal issue reporting", () => {
51
51
  beforeEach(() => {
52
52
  vi.clearAllMocks();
53
+ localStorage.clear();
53
54
  store.set(requestClientAtom, MockRequestClient.create());
54
55
  store.set(viewStateAtom, { mode: "edit", cellAnchor: null });
55
56
  store.set(connectionAtom, { state: WebSocketState.OPEN });
@@ -87,7 +88,7 @@ describe("FeedbackModal issue reporting", () => {
87
88
  ).toBeInTheDocument();
88
89
  });
89
90
 
90
- it("copies partial details when the environment request fails", async () => {
91
+ it("copies partial environment JSON when the environment request fails", async () => {
91
92
  store.set(
92
93
  requestClientAtom,
93
94
  MockRequestClient.create({
@@ -97,7 +98,9 @@ describe("FeedbackModal issue reporting", () => {
97
98
  render(<FeedbackModal onClose={vi.fn()} />, { wrapper });
98
99
 
99
100
  await screen.findByText("Server environment information unavailable");
100
- fireEvent.click(screen.getByRole("button", { name: "Copy issue details" }));
101
+ fireEvent.click(
102
+ screen.getByRole("button", { name: "Copy environment JSON" }),
103
+ );
101
104
  await waitFor(() =>
102
105
  expect(copyModule.copyToClipboard).toHaveBeenCalledWith(
103
106
  expect.stringContaining("Environment Collection Error"),
@@ -125,10 +128,8 @@ describe("FeedbackModal issue reporting", () => {
125
128
  );
126
129
  });
127
130
 
128
- it("includes notebook source only after explicit opt-in", async () => {
129
- const readCode = vi.fn().mockResolvedValue({
130
- contents: "secret = 'review before posting'",
131
- });
131
+ it("prefills the notebook code after checking include code", async () => {
132
+ const readCode = vi.fn().mockResolvedValue({ contents: "import marimo" });
132
133
  store.set(
133
134
  requestClientAtom,
134
135
  MockRequestClient.create({
@@ -137,51 +138,43 @@ describe("FeedbackModal issue reporting", () => {
137
138
  }),
138
139
  );
139
140
  render(<FeedbackModal onClose={vi.fn()} />, { wrapper });
141
+ const link = screen.getByRole("link", { name: "Open GitHub issue" });
140
142
  await screen.findByText("Environment details");
141
143
 
142
144
  expect(readCode).not.toHaveBeenCalled();
145
+ expect(link.getAttribute("href") ?? "").not.toContain("reproduction-code=");
146
+
143
147
  fireEvent.click(
144
- screen.getByRole("checkbox", { name: "Include full notebook source" }),
148
+ screen.getByRole("checkbox", { name: "Include notebook code" }),
145
149
  );
146
150
  await waitFor(() => expect(readCode).toHaveBeenCalledOnce());
147
-
148
- fireEvent.click(screen.getByRole("button", { name: "Copy issue details" }));
149
- await waitFor(() =>
150
- expect(copyModule.copyToClipboard).toHaveBeenCalledWith(
151
- expect.stringContaining("Notebook source: example.py"),
152
- ),
153
- );
151
+ await waitFor(() => {
152
+ const href = link.getAttribute("href") ?? "";
153
+ expect(href).toContain("reproduction-code=");
154
+ expect(href).toContain(encodeURIComponent("import marimo"));
155
+ });
154
156
  });
155
157
 
156
- it("discards notebook source after unchecking", async () => {
157
- const readCode = vi.fn().mockResolvedValue({ contents: "x = 1" });
158
+ it("disables include code and explains why when disconnected", async () => {
159
+ store.set(connectionAtom, { state: WebSocketState.CONNECTING });
158
160
  store.set(
159
161
  requestClientAtom,
160
162
  MockRequestClient.create({
161
163
  getEnvironmentInfo: vi.fn().mockResolvedValue(environment),
162
- readCode,
163
164
  }),
164
165
  );
165
166
  render(<FeedbackModal onClose={vi.fn()} />, { wrapper });
166
167
  await screen.findByText("Environment details");
167
168
 
168
- const checkbox = screen.getByRole("checkbox", {
169
- name: "Include full notebook source",
170
- });
171
- fireEvent.click(checkbox);
172
- await waitFor(() => expect(readCode).toHaveBeenCalledOnce());
173
- fireEvent.click(checkbox);
174
-
175
- fireEvent.click(screen.getByRole("button", { name: "Copy issue details" }));
176
- await waitFor(() =>
177
- expect(copyModule.copyToClipboard).toHaveBeenCalledWith(
178
- expect.not.stringContaining("Notebook source"),
179
- ),
180
- );
169
+ expect(
170
+ screen.getByRole("checkbox", { name: "Include notebook code" }),
171
+ ).toBeDisabled();
172
+ expect(
173
+ screen.getByText("Connect the notebook to include its source."),
174
+ ).toBeInTheDocument();
181
175
  });
182
176
 
183
- it("disables the notebook option and explains why when disconnected", async () => {
184
- store.set(connectionAtom, { state: WebSocketState.CONNECTING });
177
+ it("disables include errors when there are no errors", async () => {
185
178
  store.set(
186
179
  requestClientAtom,
187
180
  MockRequestClient.create({
@@ -192,11 +185,8 @@ describe("FeedbackModal issue reporting", () => {
192
185
  await screen.findByText("Environment details");
193
186
 
194
187
  expect(
195
- screen.getByRole("checkbox", { name: "Include full notebook source" }),
188
+ screen.getByRole("checkbox", { name: "Include errors" }),
196
189
  ).toBeDisabled();
197
- expect(
198
- screen.getByText("Connect the notebook to include its source."),
199
- ).toBeInTheDocument();
200
190
  });
201
191
 
202
192
  it("prefills the GitHub issue link with the environment once loaded", async () => {
@@ -5,7 +5,7 @@ import { Slot as SlotPrimitive } from "radix-ui";
5
5
  const Slot = SlotPrimitive.Slot;
6
6
 
7
7
  import { useAtomValue } from "jotai";
8
- import { CheckIcon, ExternalLinkIcon, TriangleAlertIcon } from "lucide-react";
8
+ import { ExternalLinkIcon, TriangleAlertIcon } from "lucide-react";
9
9
  import React, { type PropsWithChildren, useMemo, useState } from "react";
10
10
  import { CopyClipboardIcon } from "@/components/icons/copy-icon";
11
11
  import { useImperativeModal } from "@/components/modal/ImperativeModal";
@@ -18,16 +18,16 @@ import {
18
18
  DialogTitle,
19
19
  } from "@/components/ui/dialog";
20
20
  import { Skeleton } from "@/components/ui/skeleton";
21
- import { toast } from "@/components/ui/use-toast";
22
21
  import { notebookAtom } from "@/core/cells/cells";
23
22
  import { Constants } from "@/core/constants";
24
23
  import {
25
24
  buildBugReportUrl,
26
- buildIssueDetails,
27
25
  createPartialEnvironment,
28
26
  type EnvironmentDiagnostics,
29
27
  enrichEnvironment,
30
- type NotebookSource,
28
+ formatCodeSection,
29
+ formatEnvironmentSection,
30
+ formatErrorsSection,
31
31
  } from "@/core/diagnostics/issue-details";
32
32
  import {
33
33
  formatCellError,
@@ -41,8 +41,8 @@ import { filenameAtom } from "@/core/saving/file-state";
41
41
  import { store } from "@/core/state/jotai";
42
42
  import { WebSocketState } from "@/core/websocket/types";
43
43
  import { useAsyncData } from "@/hooks/useAsyncData";
44
+ import { useLocalStorage } from "@/hooks/useLocalStorage";
44
45
  import { cn } from "@/utils/cn";
45
- import { copyToClipboard } from "@/utils/copy";
46
46
 
47
47
  const CollapsiblePreview: React.FC<{ content: string }> = ({ content }) => {
48
48
  const [expanded, setExpanded] = useState(false);
@@ -108,8 +108,6 @@ export const FeedbackModal: React.FC<{
108
108
  codeAvailable &&
109
109
  connection.state === WebSocketState.OPEN;
110
110
 
111
- const [includeNotebook, setIncludeNotebook] = useState(false);
112
-
113
111
  const notebookSourceReason = notebookSourceAvailable
114
112
  ? undefined
115
113
  : filename === null
@@ -118,13 +116,14 @@ export const FeedbackModal: React.FC<{
118
116
  ? "Notebook source is hidden in this view."
119
117
  : "Connect the notebook to include its source.";
120
118
 
121
- const notebookSourceRequest = useAsyncData(async () => {
122
- if (!includeNotebook || !notebookSourceAvailable || filename === null) {
123
- return undefined;
124
- }
125
- const { contents } = await readCode();
126
- return { filename, contents } satisfies NotebookSource;
127
- }, [includeNotebook, notebookSourceAvailable, filename, readCode]);
119
+ const [includeErrors, setIncludeErrors] = useLocalStorage(
120
+ "marimo:issue-report:include-errors",
121
+ false,
122
+ );
123
+ const [includeCode, setIncludeCode] = useLocalStorage(
124
+ "marimo:issue-report:include-code",
125
+ false,
126
+ );
128
127
 
129
128
  const environment: EnvironmentDiagnostics | undefined =
130
129
  environmentRequest.data
@@ -138,83 +137,122 @@ export const FeedbackModal: React.FC<{
138
137
  )
139
138
  : undefined;
140
139
 
141
- const [issueDetailsCopied, setIssueDetailsCopied] = useState(false);
142
-
143
- const githubIssueUrl = useMemo(() => {
144
- if (!environment) {
145
- return Constants.bugReportUrl;
140
+ const codeRequest = useAsyncData(async () => {
141
+ if (!includeCode || !notebookSourceAvailable) {
142
+ return undefined;
146
143
  }
147
- return buildBugReportUrl(
148
- Constants.bugReportUrl,
149
- buildIssueDetails({ environment, errors }),
150
- );
151
- }, [environment, errors]);
144
+ const { contents } = await readCode();
145
+ return contents;
146
+ }, [includeCode, notebookSourceAvailable, readCode]);
152
147
 
153
- const copyIssueDetails = async () => {
148
+ const { url: githubIssueUrl, omitted } = useMemo(() => {
154
149
  if (!environment) {
155
- return;
150
+ return { url: Constants.bugReportUrl, omitted: [] as string[] };
151
+ }
152
+ const fields: Record<string, string> = {
153
+ env: formatEnvironmentSection(environment),
154
+ };
155
+ if (includeErrors && errors.length > 0) {
156
+ fields["bug-description"] = formatErrorsSection(errors);
156
157
  }
157
- const notebookForReport = includeNotebook
158
- ? notebookSourceRequest.data
159
- : undefined;
160
- await copyToClipboard(
161
- buildIssueDetails({ environment, errors, notebook: notebookForReport }),
162
- );
163
- setIssueDetailsCopied(true);
164
- setTimeout(() => setIssueDetailsCopied(false), 2000);
165
- toast({
166
- title:
167
- environmentRequest.status === "error"
168
- ? "Partial issue details copied"
169
- : "Issue details copied",
170
- });
171
- };
158
+ if (includeCode && codeRequest.data) {
159
+ fields["reproduction-code"] = formatCodeSection(codeRequest.data);
160
+ }
161
+ return buildBugReportUrl(Constants.bugReportUrl, fields);
162
+ }, [environment, errors, includeErrors, includeCode, codeRequest.data]);
163
+
164
+ const omittedLabels = omitted
165
+ .map((field) =>
166
+ field === "bug-description"
167
+ ? "errors"
168
+ : field === "reproduction-code"
169
+ ? "code"
170
+ : field,
171
+ )
172
+ .join(", ");
172
173
 
173
174
  return (
174
175
  <DialogContent className="w-[540px] max-w-[90vw]">
175
176
  <DialogHeader>
176
177
  <DialogTitle>Report an issue</DialogTitle>
177
178
  <DialogDescription>
178
- Copy your environment and any current errors to include in a GitHub
179
- bug report. Nothing is uploaded automatically; review the details
180
- before posting.
179
+ Open a GitHub bug report prefilled with your environment, and
180
+ optionally your current errors and notebook code. Nothing is uploaded
181
+ automatically; review the details before posting.
181
182
  </DialogDescription>
182
183
  </DialogHeader>
183
184
 
184
185
  <div className="flex flex-col gap-4 max-h-[60vh] overflow-y-auto">
185
- <div className="flex flex-wrap gap-2">
186
+ <div className="flex flex-col gap-3">
186
187
  <Button
187
188
  type="button"
188
189
  variant="default"
189
190
  size="xs"
190
- disabled={
191
- !environment ||
192
- (includeNotebook && notebookSourceRequest.isFetching)
193
- }
194
- onClick={copyIssueDetails}
191
+ asChild={true}
192
+ className="self-start"
195
193
  >
196
- {issueDetailsCopied && (
197
- <CheckIcon
198
- className="w-4 h-4 mr-2 text-(--grass-11)"
199
- color="white"
200
- />
201
- )}
202
- {issueDetailsCopied ? "Copied!" : "Copy issue details"}
203
- </Button>
204
- <Button type="button" variant="outline" size="xs" asChild={true}>
205
194
  <a href={githubIssueUrl} target="_blank" rel="noreferrer">
206
195
  <ExternalLinkIcon className="w-4 h-4 mr-2" />
207
196
  Open GitHub issue
208
197
  </a>
209
198
  </Button>
210
- </div>
211
199
 
212
- {includeNotebook && (
213
- <p className="text-xs text-muted-foreground">
214
- The GitHub link prefills your environment only. Use Copy issue
215
- details to include the full notebook source.
216
- </p>
217
- )}
200
+ <div className="flex flex-col gap-2">
201
+ <div className="flex items-center gap-2 text-sm">
202
+ <Checkbox
203
+ id="issue-include-errors"
204
+ checked={includeErrors}
205
+ disabled={errors.length === 0}
206
+ onCheckedChange={(checked) =>
207
+ setIncludeErrors(checked === true)
208
+ }
209
+ aria-label="Include errors"
210
+ />
211
+ <label htmlFor="issue-include-errors">Include errors</label>
212
+ </div>
213
+
214
+ <div className="flex flex-col gap-1">
215
+ <div className="flex items-center gap-2 text-sm">
216
+ <Checkbox
217
+ id="issue-include-code"
218
+ checked={includeCode}
219
+ disabled={!notebookSourceAvailable}
220
+ onCheckedChange={(checked) =>
221
+ setIncludeCode(checked === true)
222
+ }
223
+ aria-label="Include notebook code"
224
+ />
225
+ <label htmlFor="issue-include-code">
226
+ Include notebook code
227
+ </label>
228
+ </div>
229
+ {notebookSourceReason && (
230
+ <span className="text-xs text-muted-foreground ml-6">
231
+ {notebookSourceReason}
232
+ </span>
233
+ )}
234
+ {includeCode && codeRequest.status === "error" && (
235
+ <span className="text-xs text-(--red-11) ml-6">
236
+ Notebook source could not be loaded.
237
+ </span>
238
+ )}
239
+ </div>
240
+ </div>
241
+
242
+ {omittedLabels ? (
243
+ <div className="flex items-start gap-2 text-xs text-(--yellow-11)">
244
+ <TriangleAlertIcon className="w-3.5 h-3.5 shrink-0 mt-0.5" />
245
+ <span>
246
+ Too large to prefill and left out of the link: {omittedLabels}.
247
+ Paste them into the issue manually.
248
+ </span>
249
+ </div>
250
+ ) : (
251
+ <p className="text-xs text-muted-foreground">
252
+ Your environment is always included.
253
+ </p>
254
+ )}
255
+ </div>
218
256
 
219
257
  {environmentRequest.status === "pending" && (
220
258
  <div className="flex flex-col gap-2">
@@ -261,7 +299,15 @@ export const FeedbackModal: React.FC<{
261
299
 
262
300
  {environment && errors.length > 0 && (
263
301
  <div className="flex flex-col gap-1">
264
- <span className="text-sm font-medium">Current errors</span>
302
+ <div className="flex items-center justify-between">
303
+ <span className="text-sm font-medium">Errors</span>
304
+ <CopyClipboardIcon
305
+ className="w-3.5 h-3.5"
306
+ value={formatErrorsSection(errors)}
307
+ ariaLabel="Copy errors"
308
+ toastTitle="Errors copied"
309
+ />
310
+ </div>
265
311
  <CollapsiblePreview
266
312
  content={errors.map(formatCellError).join("\n\n---\n\n")}
267
313
  />
@@ -270,46 +316,10 @@ export const FeedbackModal: React.FC<{
270
316
 
271
317
  {environment && errors.length === 0 && (
272
318
  <span className="text-sm text-muted-foreground">
273
- No current errors detected.
319
+ No errors detected.
274
320
  </span>
275
321
  )}
276
322
 
277
- <div className="flex flex-col gap-1">
278
- <div className="flex items-start gap-2 text-sm">
279
- <Checkbox
280
- id="issue-include-notebook"
281
- className="mt-0.5"
282
- checked={includeNotebook}
283
- disabled={!notebookSourceAvailable}
284
- onCheckedChange={(checked) =>
285
- setIncludeNotebook(checked === true)
286
- }
287
- aria-label="Include full notebook source"
288
- />
289
- <label htmlFor="issue-include-notebook">
290
- Include full notebook source
291
- </label>
292
- </div>
293
- <p className="text-xs text-muted-foreground ml-6">
294
- Copies the entire saved Python source, including comments, literal
295
- data, embedded credentials, and package metadata. Outputs and
296
- external files are not included. Review it before posting.{" "}
297
- <span className="font-bold">
298
- For private notebooks, paste a minimal reproduction instead.
299
- </span>
300
- </p>
301
- {notebookSourceReason && (
302
- <span className="text-xs text-muted-foreground ml-6">
303
- {notebookSourceReason}
304
- </span>
305
- )}
306
- {includeNotebook && notebookSourceRequest.status === "error" && (
307
- <span className="text-xs text-(--red-11) ml-6">
308
- Notebook source could not be loaded.
309
- </span>
310
- )}
311
- </div>
312
-
313
323
  <div className="border-t pt-3">
314
324
  <p className="text-sm text-muted-foreground">
315
325
  Other feedback? Take our{" "}
@@ -6,9 +6,11 @@ import type { CellErrorEntry } from "@/core/errors/error-entries";
6
6
  import type { EnvironmentInfo } from "@/core/network/types";
7
7
  import {
8
8
  buildBugReportUrl,
9
- buildIssueDetails,
10
9
  createPartialEnvironment,
11
10
  enrichEnvironment,
11
+ formatCodeSection,
12
+ formatEnvironmentSection,
13
+ formatErrorsSection,
12
14
  markdownCodeFence,
13
15
  MAX_PREFILL_URL_LENGTH,
14
16
  } from "../issue-details";
@@ -86,20 +88,16 @@ describe("markdownCodeFence", () => {
86
88
  });
87
89
  });
88
90
 
89
- describe("buildIssueDetails", () => {
90
- it("includes the environment and omits notebook source unless provided", () => {
91
- const markdown = buildIssueDetails({
92
- environment,
93
- errors: [],
94
- notebook: undefined,
95
- });
91
+ describe("formatEnvironmentSection", () => {
92
+ it("wraps the environment JSON in a collapsible section", () => {
93
+ const markdown = formatEnvironmentSection(environment);
96
94
  expect(markdown).toContain("<summary>Environment</summary>");
97
95
  expect(markdown).toContain('"marimo": "1.2.3"');
98
- expect(markdown).not.toContain("Notebook source");
99
- expect(markdown).not.toContain("Current errors");
100
96
  });
97
+ });
101
98
 
102
- it("includes current errors as plain text without notebook source", () => {
99
+ describe("formatErrorsSection", () => {
100
+ it("wraps errors in a collapsible section without cell source", () => {
103
101
  const errors: CellErrorEntry[] = [
104
102
  {
105
103
  cellId: cellId("cell-1"),
@@ -110,38 +108,19 @@ describe("buildIssueDetails", () => {
110
108
  '<span class="gr">ValueError</span>: <span class="n">bad value</span>',
111
109
  },
112
110
  ];
113
- const markdown = buildIssueDetails({ environment, errors });
114
- expect(markdown).toContain("<summary>Current errors</summary>");
111
+ const markdown = formatErrorsSection(errors);
112
+ expect(markdown).toContain("<summary>Errors</summary>");
113
+ expect(markdown).toContain("```text\n");
115
114
  expect(markdown).toContain("ValueError: bad value");
116
115
  expect(markdown).not.toContain("password");
117
116
  });
117
+ });
118
118
 
119
- it("includes notebook source under its basename when provided", () => {
120
- const markdown = buildIssueDetails({
121
- environment,
122
- errors: [],
123
- notebook: {
124
- filename: "/project/example.py",
125
- contents: "x = 1",
126
- },
127
- });
128
- expect(markdown).toContain(
129
- "<summary>Notebook source: example.py</summary>",
130
- );
131
- expect(markdown).toContain("x = 1");
132
- });
133
-
134
- it("escapes the summary label as text", () => {
135
- const markdown = buildIssueDetails({
136
- environment,
137
- errors: [],
138
- notebook: {
139
- filename: "/project/<script>.py",
140
- contents: "x = 1",
141
- },
142
- });
143
- expect(markdown).toContain("&lt;script&gt;.py");
144
- expect(markdown).not.toContain("<script>.py");
119
+ describe("formatCodeSection", () => {
120
+ it("wraps the code in a collapsible python section", () => {
121
+ const markdown = formatCodeSection("import marimo");
122
+ expect(markdown).toContain("<summary>Code</summary>");
123
+ expect(markdown).toContain("```python\nimport marimo");
145
124
  });
146
125
  });
147
126
 
@@ -149,25 +128,48 @@ describe("buildBugReportUrl", () => {
149
128
  const baseUrl =
150
129
  "https://github.com/marimo-team/marimo/issues/new?template=bug_report.yaml";
151
130
 
152
- it("prefills the env field with the encoded issue details", () => {
153
- const url = buildBugReportUrl(baseUrl, "hello world");
154
- expect(url).toBe(`${baseUrl}&env=hello%20world`);
131
+ it("prefills each field keyed by its template id", () => {
132
+ const { url, omitted } = buildBugReportUrl(baseUrl, {
133
+ env: "hello world",
134
+ "bug-description": "boom",
135
+ });
136
+ expect(url).toBe(`${baseUrl}&env=hello%20world&bug-description=boom`);
137
+ expect(omitted).toEqual([]);
155
138
  });
156
139
 
157
- it("appends env with a query separator when the base URL has none", () => {
158
- const url = buildBugReportUrl("https://example.com/new", "x");
140
+ it("appends fields with a query separator when the base URL has none", () => {
141
+ const { url } = buildBugReportUrl("https://example.com/new", { env: "x" });
159
142
  expect(url).toBe("https://example.com/new?env=x");
160
143
  });
161
144
 
145
+ it("skips empty fields", () => {
146
+ const { url } = buildBugReportUrl(baseUrl, {
147
+ env: "x",
148
+ "bug-description": "",
149
+ });
150
+ expect(url).toBe(`${baseUrl}&env=x`);
151
+ expect(url).not.toContain("bug-description");
152
+ });
153
+
154
+ it("returns the base URL when no fields have content", () => {
155
+ const { url } = buildBugReportUrl(baseUrl, { env: "" });
156
+ expect(url).toBe(baseUrl);
157
+ });
158
+
162
159
  it("encodes markdown so it survives as a single query param", () => {
163
- const details = buildIssueDetails({ environment, errors: [] });
164
- const url = buildBugReportUrl(baseUrl, details);
160
+ const details = formatEnvironmentSection(environment);
161
+ const { url } = buildBugReportUrl(baseUrl, { env: details });
165
162
  expect(url).toContain("&env=");
166
163
  expect(new URL(url).searchParams.get("env")).toBe(details);
167
164
  });
168
165
 
169
- it("falls back to the plain base URL when the prefill exceeds the cap", () => {
166
+ it("keeps earlier fields and omits only the oversized later one", () => {
170
167
  const huge = "x".repeat(MAX_PREFILL_URL_LENGTH);
171
- expect(buildBugReportUrl(baseUrl, huge)).toBe(baseUrl);
168
+ const { url, omitted } = buildBugReportUrl(baseUrl, {
169
+ env: "small",
170
+ "reproduction-code": huge,
171
+ });
172
+ expect(url).toBe(`${baseUrl}&env=small`);
173
+ expect(omitted).toEqual(["reproduction-code"]);
172
174
  });
173
175
  });
@@ -5,7 +5,6 @@ import {
5
5
  formatCellError,
6
6
  } from "@/core/errors/error-entries";
7
7
  import type { EnvironmentInfo } from "@/core/network/types";
8
- import { Paths } from "@/utils/paths";
9
8
  import { Strings } from "@/utils/strings";
10
9
 
11
10
  /**
@@ -23,12 +22,6 @@ export interface NotebookSource {
23
22
  contents: string;
24
23
  }
25
24
 
26
- export interface IssueDetailsInput {
27
- environment: EnvironmentDiagnostics;
28
- errors: CellErrorEntry[];
29
- notebook?: NotebookSource;
30
- }
31
-
32
25
  /**
33
26
  * Replace the server-detected browser with the active browser's user agent.
34
27
  *
@@ -82,17 +75,35 @@ export function markdownCodeFence(language: string, contents: string): string {
82
75
  export const MAX_PREFILL_URL_LENGTH = 6000;
83
76
 
84
77
  /**
85
- * Build a bug-report URL with `issueDetails` prefilled into the form's `env`
86
- * field. Falls back to `baseUrl` unchanged when the prefilled URL would exceed
87
- * `MAX_PREFILL_URL_LENGTH`.
78
+ * Build a bug-report URL with `fields` prefilled into the issue form, keyed by
79
+ * each field's template `id`. Fields are added greedily in order; any that
80
+ * would push the URL past `MAX_PREFILL_URL_LENGTH` are skipped and returned in
81
+ * `omitted`, so an oversized later field never discards the ones before it.
88
82
  */
89
83
  export function buildBugReportUrl(
90
84
  baseUrl: string,
91
- issueDetails: string,
92
- ): string {
85
+ fields: Record<string, string>,
86
+ ): { url: string; omitted: string[] } {
93
87
  const separator = baseUrl.includes("?") ? "&" : "?";
94
- const prefilled = `${baseUrl}${separator}env=${encodeURIComponent(issueDetails)}`;
95
- return prefilled.length > MAX_PREFILL_URL_LENGTH ? baseUrl : prefilled;
88
+ const omitted: string[] = [];
89
+ let url = baseUrl;
90
+ let added = 0;
91
+
92
+ for (const [key, value] of Object.entries(fields)) {
93
+ if (value.length === 0) {
94
+ continue;
95
+ }
96
+ const param = `${key}=${encodeURIComponent(value)}`;
97
+ const candidate = `${url}${added === 0 ? separator : "&"}${param}`;
98
+ if (candidate.length > MAX_PREFILL_URL_LENGTH) {
99
+ omitted.push(key);
100
+ continue;
101
+ }
102
+ url = candidate;
103
+ added += 1;
104
+ }
105
+
106
+ return { url, omitted };
96
107
  }
97
108
 
98
109
  function detailsSection(summary: string, body: string): string {
@@ -106,34 +117,34 @@ function detailsSection(summary: string, body: string): string {
106
117
  ].join("\n");
107
118
  }
108
119
 
109
- export function buildIssueDetails(input: IssueDetailsInput): string {
110
- const sections = [
111
- detailsSection(
112
- "Environment",
113
- markdownCodeFence("json", JSON.stringify(input.environment, null, 2)),
114
- ),
115
- ];
116
-
117
- if (input.errors.length > 0) {
118
- sections.push(
119
- detailsSection(
120
- "Current errors",
121
- markdownCodeFence(
122
- "text",
123
- input.errors.map(formatCellError).join("\n\n---\n\n"),
124
- ),
125
- ),
126
- );
127
- }
120
+ /**
121
+ * Format the environment as a collapsible section for the issue form's
122
+ * `env` field.
123
+ */
124
+ export function formatEnvironmentSection(
125
+ environment: EnvironmentDiagnostics,
126
+ ): string {
127
+ return detailsSection(
128
+ "Environment",
129
+ markdownCodeFence("json", JSON.stringify(environment, null, 2)),
130
+ );
131
+ }
128
132
 
129
- if (input.notebook) {
130
- sections.push(
131
- detailsSection(
132
- `Notebook source: ${Paths.basename(input.notebook.filename)}`,
133
- markdownCodeFence("python", input.notebook.contents),
134
- ),
135
- );
136
- }
133
+ /**
134
+ * Format cell errors as a collapsible section for the issue form's
135
+ * `bug-description` field.
136
+ */
137
+ export function formatErrorsSection(errors: CellErrorEntry[]): string {
138
+ return detailsSection(
139
+ "Errors",
140
+ markdownCodeFence("text", errors.map(formatCellError).join("\n\n---\n\n")),
141
+ );
142
+ }
137
143
 
138
- return sections.join("\n\n");
144
+ /**
145
+ * Format notebook source as a collapsible section for the issue form's
146
+ * `reproduction-code` field.
147
+ */
148
+ export function formatCodeSection(contents: string): string {
149
+ return detailsSection("Code", markdownCodeFence("python", contents));
139
150
  }