@marimo-team/islands 0.25.3-dev95 → 0.25.3-dev97

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 (57) hide show
  1. package/dist/{ErrorBoundary-Cghv7nv2.js → ErrorBoundary-DrRRjQqE.js} +1 -1
  2. package/dist/{chat-ui-D0jT6WV3.js → chat-ui-BIgPxfPP.js} +5 -4
  3. package/dist/{code-visibility-CLLrmROz.js → code-visibility-B72H-y9s.js} +2 -2
  4. package/dist/{common-BUSxx69_.js → common-Cm_wLVIe.js} +27 -26
  5. package/dist/{data-grid-overlay-editor-CWCIs2Mn.js → data-grid-overlay-editor-DWrrVdAU.js} +1 -1
  6. package/dist/{error-entries-BZiemZfD.js → error-entries-TAO6kEhF.js} +243 -230
  7. package/dist/{feedback-button-Ct9dNjaH.js → feedback-button--tVNH0ec.js} +3 -3
  8. package/dist/{glide-data-editor-D30nP9Xm.js → glide-data-editor-DWkgIB7l.js} +3 -3
  9. package/dist/{globals-B7SyAmCL.js → globals-D3_nrphz.js} +1 -1
  10. package/dist/{html-to-image-DBOTlHF9.js → html-to-image-CWdayGZU.js} +1 -1
  11. package/dist/main.js +1872 -1852
  12. package/dist/{number-overlay-editor-8Y4ehcLw.js → number-overlay-editor-DYj-4eDE.js} +1 -1
  13. package/dist/{process-output-BKLVbFnU.js → process-output-DD-oSvZk.js} +2 -2
  14. package/dist/{reveal-component-D8VAiQY5.js → reveal-component-pH1bfDAz.js} +7 -7
  15. package/package.json +1 -1
  16. package/src/components/app-config/ai-config.tsx +1 -0
  17. package/src/components/chat/acp/blocks.tsx +2 -2
  18. package/src/components/chat/chat-components.tsx +2 -0
  19. package/src/components/chat/chat-panel.tsx +6 -1
  20. package/src/components/data-table/__tests__/column-explorer.test.tsx +27 -1
  21. package/src/components/data-table/charts/__tests__/chart-panel.test.tsx +74 -1
  22. package/src/components/data-table/charts/__tests__/storage.test.ts +4 -1
  23. package/src/components/data-table/charts/charts.tsx +21 -6
  24. package/src/components/data-table/charts/storage.ts +16 -5
  25. package/src/components/data-table/column-explorer-panel/column-explorer.tsx +1 -1
  26. package/src/components/datasources/__tests__/column-actions.test.tsx +97 -0
  27. package/src/components/datasources/__tests__/column-preview.test.tsx +63 -2
  28. package/src/components/datasources/column-preview.tsx +8 -2
  29. package/src/components/datasources/datasources.tsx +12 -14
  30. package/src/components/debugger/debugger-code.tsx +5 -0
  31. package/src/components/dependency-graph/panels.tsx +2 -1
  32. package/src/components/editor/ai/ai-completion-editor.tsx +1 -0
  33. package/src/components/editor/alerts/startup-logs-alert.tsx +1 -0
  34. package/src/components/editor/cell/DeleteButton.tsx +1 -0
  35. package/src/components/editor/chrome/panels/cache-panel.tsx +1 -0
  36. package/src/components/editor/chrome/wrapper/__tests__/pending-ai-cells.test.tsx +58 -1
  37. package/src/components/editor/chrome/wrapper/pending-ai-cells.tsx +12 -2
  38. package/src/components/editor/notebook-banner.tsx +1 -0
  39. package/src/components/editor/output/ansi-reduce.ts +6 -0
  40. package/src/components/editor/output/console/ConsoleOutput.tsx +11 -7
  41. package/src/components/editor/package-alert.tsx +6 -4
  42. package/src/components/editor/renderers/vertical-layout/sidebar/toggle.tsx +7 -1
  43. package/src/components/mcp/mcp-status-indicator.tsx +1 -1
  44. package/src/components/scratchpad/scratchpad.tsx +2 -2
  45. package/src/components/slides/reveal-component.tsx +4 -2
  46. package/src/components/slides/slide-form.tsx +1 -0
  47. package/src/core/cells/__tests__/__snapshots__/cells.test.ts.snap +4 -0
  48. package/src/core/cells/__tests__/cell.test.ts +39 -0
  49. package/src/core/cells/__tests__/cells.test.ts +3 -1
  50. package/src/core/cells/__tests__/collapseConsoleOutputs.test.ts +54 -0
  51. package/src/core/cells/cell.ts +14 -3
  52. package/src/core/cells/cells.ts +1 -0
  53. package/src/core/cells/types.ts +10 -1
  54. package/src/core/codemirror/language/panel/panel.tsx +1 -0
  55. package/src/plugins/impl/RefreshPlugin.tsx +1 -0
  56. package/src/plugins/impl/chat/chat-ui.tsx +2 -1
  57. package/src/stories/button.stories.tsx +1 -0
@@ -1,6 +1,6 @@
1
1
  import { a as e } from "./rolldown-runtime-Cb76FRpK.js";
2
2
  import { t } from "./react-sHtZdjeP.js";
3
- import { a as n } from "./glide-data-editor-D30nP9Xm.js";
3
+ import { a as n } from "./glide-data-editor-DWkgIB7l.js";
4
4
  //#region ../node_modules/.pnpm/@glideapps+glide-data-grid@6.0.4-alpha24_lodash@4.17.21_marked@15.0.12_react-dom@19.3.0_27cffe7f1bc30287d59ac27db3240c5b/node_modules/@glideapps/glide-data-grid/dist/esm/internal/data-grid-overlay-editor/private/number-overlay-editor-style.js
5
5
  var r = /* @__PURE__ */ e(t()), i = /*#__PURE__*/ n("div")({
6
6
  name: "NumberOverlayEditorStyle",
@@ -1,6 +1,6 @@
1
- import { O as e } from "./html-to-image-DBOTlHF9.js";
1
+ import { O as e } from "./html-to-image-CWdayGZU.js";
2
2
  import { h as t } from "./tooltip-CKO1pHJo.js";
3
- import { C as n, w as r } from "./error-entries-BZiemZfD.js";
3
+ import { C as n, w as r } from "./error-entries-TAO6kEhF.js";
4
4
  import { b as i } from "./select-B0LRDH0g.js";
5
5
  import { n as a } from "./semaphore-89jsoHtS.js";
6
6
  var o = t("file", [["path", {
@@ -3,15 +3,15 @@ import { c as t, g as n, h as r, m as i, t as a } from "./button-BxB3RDjN.js";
3
3
  import { t as o } from "./react-sHtZdjeP.js";
4
4
  import { t as s } from "./compiler-runtime-CldRdSsv.js";
5
5
  import { h as c, t as l } from "./tooltip-CKO1pHJo.js";
6
- import { O as u } from "./error-entries-BZiemZfD.js";
7
- import { Q as d, X as f, Z as p, an as m, cn as h, i as g, in as _, l as v, n as y, r as b, t as x, u as S } from "./common-BUSxx69_.js";
6
+ import { O as u } from "./error-entries-TAO6kEhF.js";
7
+ import { Q as d, X as f, Z as p, an as m, cn as h, i as g, in as _, l as v, n as y, r as b, t as x, u as S } from "./common-Cm_wLVIe.js";
8
8
  import { M as C } from "./select-B0LRDH0g.js";
9
9
  import { q as ee } from "./dropdown-menu-B_7xD2X4.js";
10
10
  import { _ as w } from "./toDate-DKH0_kMo.js";
11
11
  import { t as T } from "./jsx-runtime-3BD1kT0s.js";
12
12
  import { q as E } from "./use-restore-focus-DBCITLeF.js";
13
13
  import { A as D, C as O, b as k } from "./useTheme-CfeaVw5D.js";
14
- import { a as A, n as te, t as ne } from "./code-visibility-CLLrmROz.js";
14
+ import { a as A, n as te, t as ne } from "./code-visibility-B72H-y9s.js";
15
15
  /* empty css */
16
16
  var j = c("sticky-note", [["path", {
17
17
  d: "M21 9a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 15 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2z",
@@ -6516,13 +6516,13 @@ var ia = (e) => {
6516
6516
  c = K, s = tr, u = m ? "mo-slide-content flex flex-col gap-3" : "mo-slide-content", d = o, f = n.blocks.map((e, t) => {
6517
6517
  let n = e.cells.map((e) => r(e.id) ? i ? /* @__PURE__ */ (0, G.jsx)(cr, { cell: e }, e.id) : /* @__PURE__ */ (0, G.jsx)(lr, { cell: e }, e.id) : /* @__PURE__ */ (0, G.jsx)(ia, { cell: e }, e.id));
6518
6518
  if (e.isFragment) {
6519
- let e = p.get(t);
6519
+ let r = p.get(t);
6520
6520
  return /* @__PURE__ */ (0, G.jsxs)(Jn, {
6521
6521
  as: "div",
6522
- children: [n, e && /* @__PURE__ */ (0, G.jsx)(Qi, { text: e })]
6523
- }, t);
6522
+ children: [n, r && /* @__PURE__ */ (0, G.jsx)(Qi, { text: r })]
6523
+ }, e.cells[0].id);
6524
6524
  }
6525
- return /* @__PURE__ */ (0, G.jsx)(M.Fragment, { children: n }, t);
6525
+ return /* @__PURE__ */ (0, G.jsx)(M.Fragment, { children: n }, e.cells[0].id);
6526
6526
  }), t[0] = o, t[1] = i, t[2] = r, t[3] = a, t[4] = n, t[5] = s, t[6] = c, t[7] = l, t[8] = u, t[9] = d, t[10] = f;
6527
6527
  } else s = t[5], c = t[6], l = t[7], u = t[8], d = t[9], f = t[10];
6528
6528
  let p;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marimo-team/islands",
3
- "version": "0.25.3-dev95",
3
+ "version": "0.25.3-dev97",
4
4
  "main": "dist/main.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -502,6 +502,7 @@ const ModelListItem: React.FC<ModelListItemProps> = ({
502
502
  <ModelInfoCard model={model} />
503
503
  {model.custom && (
504
504
  <Button
505
+ aria-label={`Delete model ${model.name}`}
505
506
  variant="ghost"
506
507
  size="icon"
507
508
  onClick={handleDelete}
@@ -328,9 +328,9 @@ export const PlansBlock = (props: { data: PlanNotificationEvent[] }) => {
328
328
  </span>
329
329
  </div>
330
330
  <ul className="flex flex-col gap-1">
331
- {plans.map((item, index) => (
331
+ {plans.map((item) => (
332
332
  <li
333
- key={`${item.status}-${index}`}
333
+ key={item.content}
334
334
  className={`
335
335
  flex items-center gap-2 px-2 py-1 rounded
336
336
  `}
@@ -124,6 +124,7 @@ export const AddContextButton = ({
124
124
  return (
125
125
  <Tooltip content="Add context">
126
126
  <Button
127
+ aria-label="Add context"
127
128
  variant="text"
128
129
  size="icon"
129
130
  onClick={handleAddContext}
@@ -148,6 +149,7 @@ export const AttachFileButton = ({
148
149
  <>
149
150
  <Tooltip content="Attach a file">
150
151
  <Button
152
+ aria-label="Attach a file"
151
153
  variant="text"
152
154
  size="icon"
153
155
  onClick={() => fileInputRef.current?.click()}
@@ -166,7 +166,12 @@ const ChatHeader: React.FC<ChatHeaderProps> = ({
166
166
  return (
167
167
  <div className="flex border-b px-2 py-1 justify-between shrink-0 items-center">
168
168
  <Tooltip content="New chat">
169
- <Button variant="text" size="icon" onClick={onNewChat}>
169
+ <Button
170
+ aria-label="New chat"
171
+ variant="text"
172
+ size="icon"
173
+ onClick={onNewChat}
174
+ >
170
175
  <PlusIcon className="h-4 w-4" />
171
176
  </Button>
172
177
  </Tooltip>
@@ -8,6 +8,7 @@ import {
8
8
  import { fireEvent, render, screen, within } from "@testing-library/react";
9
9
  import { beforeAll, describe, expect, it, vi } from "vitest";
10
10
  import { TooltipProvider } from "@/components/ui/tooltip";
11
+ import type { PreviewColumn } from "@/plugins/impl/DataTablePlugin";
11
12
  import { ColumnExplorerPanel } from "../column-explorer-panel/column-explorer";
12
13
  import type { FieldTypesWithExternalType } from "../types";
13
14
 
@@ -36,11 +37,13 @@ const TEST_COLUMNS: ColumnDef<Row>[] = [
36
37
  interface HarnessProps {
37
38
  totalColumns?: number;
38
39
  initiallyHidden?: readonly string[];
40
+ previewColumn?: PreviewColumn;
39
41
  }
40
42
 
41
43
  function PanelHarness({
42
44
  totalColumns = 3,
43
45
  initiallyHidden = EMPTY_HIDDEN_COLUMNS,
46
+ previewColumn = vi.fn().mockResolvedValue({}),
44
47
  }: HarnessProps) {
45
48
  const table = useReactTable<Row>({
46
49
  data: [],
@@ -55,7 +58,7 @@ function PanelHarness({
55
58
  });
56
59
  return (
57
60
  <ColumnExplorerPanel
58
- previewColumn={vi.fn().mockResolvedValue({})}
61
+ previewColumn={previewColumn}
59
62
  fieldTypes={FIELD_TYPES}
60
63
  totalRows={3}
61
64
  totalColumns={totalColumns}
@@ -124,6 +127,29 @@ describe("ColumnExplorerPanel search", () => {
124
127
  });
125
128
  });
126
129
 
130
+ describe("ColumnExplorerPanel chart actions", () => {
131
+ it("names chart actions for each expanded column", async () => {
132
+ const previewColumn = vi.fn<PreviewColumn>().mockResolvedValue({
133
+ chart_code: "chart_code",
134
+ chart_spec: null,
135
+ error: null,
136
+ missing_packages: null,
137
+ stats: null,
138
+ });
139
+ renderPanel({ previewColumn });
140
+ fireEvent.click(screen.getByText("cust_age"));
141
+
142
+ for (const column of ["customer_name", "cust_age"]) {
143
+ expect(
144
+ await screen.findByRole("button", {
145
+ name: `Add chart to notebook for ${column}`,
146
+ }),
147
+ ).toBeInTheDocument();
148
+ expect(previewColumn).toHaveBeenCalledWith({ column });
149
+ }
150
+ });
151
+ });
152
+
127
153
  describe("ColumnExplorerPanel header counts", () => {
128
154
  it("uses rendered-subset total when a clipped column is hidden", () => {
129
155
  // Dataset has 100 columns server-side; only 3 are rendered into the
@@ -10,13 +10,27 @@ import { cellId } from "@/__tests__/branded";
10
10
  import { LazyVegaEmbed } from "@/components/charts/lazy";
11
11
  import { vegaLoader } from "@/plugins/impl/vega/loader";
12
12
  import { ChartPanel, TablePanel, type TablePanelProps } from "../charts";
13
- import { getChartTabName, tabsStorageAtom } from "../storage";
13
+ import { createTabId, getChartTabName, tabsStorageAtom } from "../storage";
14
14
  import { ChartType, NONE_VALUE } from "../types";
15
+ import { TOO_MANY_ROWS } from "../../types";
15
16
 
16
17
  vi.mock("@/components/charts/lazy", () => ({
17
18
  LazyVegaEmbed: vi.fn(() => null),
18
19
  }));
19
20
 
21
+ vi.mock("../components/chart-items", async (importOriginal) => ({
22
+ ...(await importOriginal<typeof import("../components/chart-items")>()),
23
+ ChartTypeSelect: ({
24
+ onValueChange,
25
+ }: {
26
+ onValueChange: (value: string) => void;
27
+ }) => (
28
+ <button type="button" onClick={() => onValueChange("line")}>
29
+ Change chart type
30
+ </button>
31
+ ),
32
+ }));
33
+
20
34
  beforeAll(() => {
21
35
  SetupMocks.resizeObserver();
22
36
  });
@@ -38,6 +52,7 @@ describe("ChartPanel", () => {
38
52
  id,
39
53
  [
40
54
  {
55
+ id: createTabId(),
41
56
  tabName,
42
57
  chartType: ChartType.BAR,
43
58
  config: {
@@ -283,4 +298,62 @@ describe("ChartPanel", () => {
283
298
  });
284
299
  },
285
300
  );
301
+ it("keeps panel state when a chart type change renames the tab", async () => {
302
+ const store = createStore();
303
+ const id = cellId("rename-keeps-state");
304
+ const tabName = getChartTabName(0, ChartType.BAR);
305
+ store.set(
306
+ tabsStorageAtom,
307
+ new Map([
308
+ [
309
+ id,
310
+ [
311
+ {
312
+ id: createTabId(),
313
+ tabName,
314
+ chartType: ChartType.BAR,
315
+ config: {
316
+ general: {
317
+ xColumn: { field: "value", type: "number" },
318
+ yColumn: {
319
+ field: "value",
320
+ type: "integer",
321
+ aggregate: NONE_VALUE,
322
+ },
323
+ },
324
+ },
325
+ },
326
+ ],
327
+ ],
328
+ ]),
329
+ );
330
+ const sample = [{ value: 1 }];
331
+ const props: TablePanelProps = {
332
+ cellId: id,
333
+ data: sample,
334
+ dataTable: <div />,
335
+ totalRows: TOO_MANY_ROWS,
336
+ columns: 1,
337
+ displayHeader: true,
338
+ fieldTypes: [["value", ["number", "float64"]]],
339
+ };
340
+ const { getByRole, findByRole, queryByRole } = render(
341
+ <Provider store={store}>
342
+ <Tooltip.Provider>
343
+ <TablePanel {...props} />
344
+ </Tooltip.Provider>
345
+ </Provider>,
346
+ );
347
+ fireEvent.click(getByRole("tab", { name: tabName }));
348
+ fireEvent.click(await findByRole("button", { name: "Proceed" }));
349
+ await waitFor(() => {
350
+ expect(queryByRole("button", { name: "Proceed" })).toBeNull();
351
+ });
352
+
353
+ fireEvent.click(getByRole("button", { name: "Change chart type" }));
354
+
355
+ const renamed = getChartTabName(0, ChartType.LINE);
356
+ await findByRole("tab", { name: renamed });
357
+ expect(queryByRole("button", { name: "Proceed" })).toBeNull();
358
+ });
286
359
  });
@@ -16,7 +16,7 @@ import { cellId } from "@/__tests__/branded";
16
16
  import { availableStorage } from "@/utils/storage/storage";
17
17
  import { ChartSchema } from "../schemas";
18
18
  import type { TabName } from "../storage";
19
- import { KEY, tabsStorageAtom } from "../storage";
19
+ import { createTabId, KEY, tabsStorageAtom } from "../storage";
20
20
  import { ChartType } from "../types";
21
21
 
22
22
  describe("Chart Transforms Storage", () => {
@@ -39,6 +39,7 @@ describe("Chart Transforms Storage", () => {
39
39
  const store = getDefaultStore();
40
40
  const cid = cellId("cell-1");
41
41
  const tabData = {
42
+ id: createTabId(),
42
43
  tabName: "Tab 1" as TabName,
43
44
  chartType: ChartType.LINE,
44
45
  config: ChartSchema.parse({
@@ -63,6 +64,7 @@ describe("Chart Transforms Storage", () => {
63
64
  const store = getDefaultStore();
64
65
  const cid = cellId("cell-1");
65
66
  const tabData1 = {
67
+ id: createTabId(),
66
68
  tabName: "Tab 1" as TabName,
67
69
  chartType: ChartType.LINE,
68
70
  config: ChartSchema.parse({
@@ -73,6 +75,7 @@ describe("Chart Transforms Storage", () => {
73
75
  }),
74
76
  };
75
77
  const tabData2 = {
78
+ id: createTabId(),
76
79
  tabName: "Tab 2" as TabName,
77
80
  chartType: ChartType.BAR,
78
81
  config: ChartSchema.parse({
@@ -48,7 +48,12 @@ import { HeatmapForm } from "./forms/heatmap";
48
48
  import { PieForm } from "./forms/pie";
49
49
  import { LazyChart } from "./lazy-chart";
50
50
  import { ChartSchema, type ChartSchemaType, getChartDefaults } from "./schemas";
51
- import { getChartTabName, type TabName, tabsStorageAtom } from "./storage";
51
+ import {
52
+ createTabId,
53
+ getChartTabName,
54
+ type TabName,
55
+ tabsStorageAtom,
56
+ } from "./storage";
52
57
  import { ChartType } from "./types";
53
58
 
54
59
  const NEW_CHART_TYPE = "bar" as ChartType;
@@ -114,7 +119,12 @@ export const TablePanel: React.FC<TablePanelProps> = ({
114
119
  const tabName = getChartTabName(0, NEW_CHART_TYPE);
115
120
  const newTabs = new Map(tabsMap);
116
121
  newTabs.set(cellId, [
117
- { tabName, chartType: NEW_CHART_TYPE, config: getChartDefaults() },
122
+ {
123
+ id: createTabId(),
124
+ tabName,
125
+ chartType: NEW_CHART_TYPE,
126
+ config: getChartDefaults(),
127
+ },
118
128
  ]);
119
129
  saveTabsMap(newTabs);
120
130
  setTabCounter(1);
@@ -135,6 +145,7 @@ export const TablePanel: React.FC<TablePanelProps> = ({
135
145
  newTabs.set(cellId, [
136
146
  ...tabs,
137
147
  {
148
+ id: createTabId(),
138
149
  tabName,
139
150
  chartType: NEW_CHART_TYPE,
140
151
  config: getChartDefaults(),
@@ -233,9 +244,9 @@ export const TablePanel: React.FC<TablePanelProps> = ({
233
244
  >
234
245
  Table
235
246
  </TabsTrigger>
236
- {tabs.map((tab, idx) => (
247
+ {tabs.map((tab) => (
237
248
  <TabsTrigger
238
- key={idx}
249
+ key={tab.id}
239
250
  className="text-xs"
240
251
  value={tab.tabName}
241
252
  onClick={() => setSelectedTab(tab.tabName)}
@@ -263,7 +274,7 @@ export const TablePanel: React.FC<TablePanelProps> = ({
263
274
  <TabsContent className="mt-1 overflow-hidden" value={DEFAULT_TAB_NAME}>
264
275
  {dataTable}
265
276
  </TabsContent>
266
- {tabs.map((tab, idx) => {
277
+ {tabs.map((tab) => {
267
278
  const saveChart = (formValues: ChartSchemaType) => {
268
279
  saveTabChart({
269
280
  tabName: tab.tabName,
@@ -275,7 +286,11 @@ export const TablePanel: React.FC<TablePanelProps> = ({
275
286
  saveTabChartType(tab.tabName, chartType);
276
287
  };
277
288
  return (
278
- <TabsContent key={idx} value={tab.tabName} className="h-[400px] mt-0">
289
+ <TabsContent
290
+ key={tab.id}
291
+ value={tab.tabName}
292
+ className="h-[400px] mt-0"
293
+ >
279
294
  <ChartPanel
280
295
  tableData={data}
281
296
  chartConfig={tab.config}
@@ -7,13 +7,16 @@ import { Logger } from "@/utils/Logger";
7
7
  import { NotebookScopedLocalStorage } from "@/utils/storage/typed";
8
8
  import { capitalize } from "@/utils/strings";
9
9
  import type { TypedString } from "@/utils/typed";
10
+ import { generateUUID } from "@/utils/uuid";
10
11
  import { ChartSchema, type ChartSchemaType } from "./schemas";
11
12
  import type { ChartType } from "./types";
12
13
 
13
14
  export type TabName = TypedString<"TabName">;
15
+ export type TabId = TypedString<"TabId">;
14
16
  export const KEY = "marimo:charts:v2";
15
17
 
16
18
  interface TabStorage {
19
+ id: TabId;
17
20
  tabName: TabName; // unique within cell
18
21
  chartType: ChartType;
19
22
  config: ChartSchemaType;
@@ -21,11 +24,15 @@ interface TabStorage {
21
24
 
22
25
  type TabStorageMap = Map<CellId, TabStorage[]>;
23
26
 
24
- const TabStorageSchema = z.object({
25
- tabName: z.string().transform((name) => name as TabName),
26
- chartType: z.string().transform((type) => type as ChartType),
27
- config: ChartSchema,
28
- });
27
+ const TabStorageSchema = z
28
+ .object({
29
+ id: z.string().optional(),
30
+ tabName: z.string().transform((name) => name as TabName),
31
+ chartType: z.string().transform((type) => type as ChartType),
32
+ config: ChartSchema,
33
+ })
34
+ // Stored tabs without an id use their name, which is unique within a cell.
35
+ .transform((tab) => ({ ...tab, id: (tab.id ?? tab.tabName) as TabId }));
29
36
  const TabStorageEntriesSchema = z.array(
30
37
  z.tuple([
31
38
  z.string().transform((name) => name as CellId),
@@ -64,6 +71,10 @@ export const tabsStorageAtom = atomWithStorage<TabStorageMap>(
64
71
  mapStorage,
65
72
  );
66
73
 
74
+ export function createTabId(): TabId {
75
+ return generateUUID() as TabId;
76
+ }
77
+
67
78
  /**
68
79
  * Convenience function to get the tab name for a given tab number and chart type
69
80
  */
@@ -345,7 +345,7 @@ const ColumnPreview = ({
345
345
  const chart = chart_spec && renderChart(chart_spec, theme);
346
346
 
347
347
  const addDataframeChart = chart_code && (
348
- <AddDataframeChart chartCode={chart_code} />
348
+ <AddDataframeChart chartCode={chart_code} columnName={columnName} />
349
349
  );
350
350
 
351
351
  return (
@@ -0,0 +1,97 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import {
4
+ fireEvent,
5
+ render,
6
+ screen,
7
+ waitFor,
8
+ within,
9
+ } from "@testing-library/react";
10
+ import { createStore, Provider } from "jotai";
11
+ import { beforeAll, describe, expect, it, vi } from "vitest";
12
+ import { MockRequestClient } from "@/__mocks__/requests";
13
+ import { Command, CommandList } from "@/components/ui/command";
14
+ import { TooltipProvider } from "@/components/ui/tooltip";
15
+ import type { DataTable } from "@/core/kernel/messages";
16
+ import { requestClientAtom } from "@/core/network/requests";
17
+ import { copyToClipboard } from "@/utils/copy";
18
+ import { TableList } from "../datasources";
19
+
20
+ vi.mock("@/utils/copy", () => ({
21
+ copyToClipboard: vi.fn().mockResolvedValue(undefined),
22
+ }));
23
+
24
+ beforeAll(() => {
25
+ HTMLElement.prototype.scrollIntoView = vi.fn();
26
+ });
27
+
28
+ const table: DataTable = {
29
+ name: "users",
30
+ columns: [
31
+ {
32
+ name: "email",
33
+ type: "string",
34
+ external_type: "VARCHAR",
35
+ sample_values: [],
36
+ },
37
+ {
38
+ name: "name",
39
+ type: "string",
40
+ external_type: "VARCHAR",
41
+ sample_values: [],
42
+ },
43
+ ],
44
+ source: "memory",
45
+ source_type: "local",
46
+ type: "table",
47
+ engine: null,
48
+ indexes: null,
49
+ num_columns: 2,
50
+ num_rows: null,
51
+ variable_name: null,
52
+ primary_keys: null,
53
+ };
54
+
55
+ describe("Column actions", () => {
56
+ it("copies each column through one named button without expanding its preview", async () => {
57
+ const store = createStore();
58
+ const client = MockRequestClient.create();
59
+ store.set(requestClientAtom, client);
60
+ render(
61
+ <Provider store={store}>
62
+ <TooltipProvider>
63
+ <Command>
64
+ <CommandList>
65
+ <TableList tables={[table]} />
66
+ </CommandList>
67
+ </Command>
68
+ </TooltipProvider>
69
+ </Provider>,
70
+ );
71
+
72
+ fireEvent.click(await screen.findByText("users"));
73
+
74
+ for (const column of table.columns) {
75
+ const copyButton = await screen.findByRole("button", {
76
+ name: `Copy column name ${column.name}`,
77
+ });
78
+ const row = screen.getByRole("option", {
79
+ name: `${column.name} ${column.external_type}`,
80
+ });
81
+ expect(within(row).getAllByRole("button")).toEqual([copyButton]);
82
+ expect(copyButton.querySelector("button")).toBeNull();
83
+
84
+ fireEvent.focus(copyButton);
85
+ expect(
86
+ await screen.findByRole("tooltip", { name: "Copy column name" }),
87
+ ).toBeInTheDocument();
88
+
89
+ fireEvent.click(copyButton);
90
+ await waitFor(() => {
91
+ expect(copyToClipboard).toHaveBeenLastCalledWith(column.name);
92
+ });
93
+ expect(client.previewDatasetColumn).not.toHaveBeenCalled();
94
+ }
95
+ expect(copyToClipboard).toHaveBeenCalledTimes(2);
96
+ });
97
+ });
@@ -1,16 +1,24 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
- import { render } from "@testing-library/react";
4
- import { Provider } from "jotai";
3
+ import { fireEvent, render, screen } from "@testing-library/react";
4
+ import { createStore, Provider } from "jotai";
5
5
  import type React from "react";
6
6
  import { beforeEach, describe, expect, it, vi } from "vitest";
7
7
  import { MockRequestClient } from "@/__mocks__/requests";
8
+ import { TooltipProvider } from "@/components/ui/tooltip";
8
9
  import type { SQLTableContext } from "@/core/datasets/data-source-connections";
9
10
  import type { DataTable, DataTableColumn } from "@/core/kernel/messages";
10
11
  import { requestClientAtom } from "@/core/network/requests";
11
12
  import { store } from "@/core/state/jotai";
12
13
  import { DatasetColumnPreview } from "../column-preview";
13
14
 
15
+ const { createNewCell } = vi.hoisted(() => ({ createNewCell: vi.fn() }));
16
+
17
+ vi.mock("@/core/cells/cells", async (importOriginal) => ({
18
+ ...(await importOriginal<typeof import("@/core/cells/cells")>()),
19
+ useCellActions: () => ({ createNewCell }),
20
+ }));
21
+
14
22
  const wrapper = ({ children }: { children: React.ReactNode }) => (
15
23
  <Provider store={store}>{children}</Provider>
16
24
  );
@@ -95,3 +103,56 @@ describe("DatasetColumnPreview fullyQualifiedTableName", () => {
95
103
  );
96
104
  });
97
105
  });
106
+
107
+ describe("DatasetColumnPreview chart actions", () => {
108
+ it("identifies each column and inserts its corresponding chart", () => {
109
+ const store = createStore();
110
+ store.set(requestClientAtom, MockRequestClient.create());
111
+ const columns: DataTableColumn[] = [
112
+ {
113
+ name: "email",
114
+ type: "string",
115
+ external_type: "str",
116
+ sample_values: [],
117
+ },
118
+ { name: "age", type: "integer", external_type: "int", sample_values: [] },
119
+ ];
120
+ render(
121
+ <Provider store={store}>
122
+ <TooltipProvider>
123
+ {columns.map((column) => (
124
+ <DatasetColumnPreview
125
+ key={column.name}
126
+ table={{ ...table, source_type: "local", engine: null }}
127
+ column={column}
128
+ onAddColumnChart={vi.fn()}
129
+ preview={{
130
+ table_name: table.name,
131
+ column_name: column.name,
132
+ chart_code: `chart_${column.name}`,
133
+ chart_spec: null,
134
+ error: null,
135
+ missing_packages: null,
136
+ stats: {},
137
+ }}
138
+ />
139
+ ))}
140
+ </TooltipProvider>
141
+ </Provider>,
142
+ );
143
+
144
+ for (const column of columns) {
145
+ fireEvent.click(
146
+ screen.getByRole("button", {
147
+ name: `Add chart to notebook for ${column.name}`,
148
+ }),
149
+ );
150
+ expect(createNewCell).toHaveBeenLastCalledWith({
151
+ code: `chart_${column.name}`,
152
+ before: false,
153
+ cellId: "__end__",
154
+ });
155
+ }
156
+ expect(createNewCell).toHaveBeenCalledTimes(2);
157
+ });
158
+ });
@@ -112,12 +112,16 @@ export const DatasetColumnPreview: React.FC<{
112
112
 
113
113
  const addDataframeChart = preview.chart_code &&
114
114
  table.source_type === "local" && (
115
- <AddDataframeChart chartCode={preview.chart_code} />
115
+ <AddDataframeChart
116
+ chartCode={preview.chart_code}
117
+ columnName={column.name}
118
+ />
116
119
  );
117
120
 
118
121
  const addSQLChart = table.source_type === "duckdb" && (
119
122
  <Tooltip content="Add SQL cell" delayDuration={400}>
120
123
  <Button
124
+ aria-label={`Add SQL cell for ${column.name}`}
121
125
  variant="outline"
122
126
  size="icon"
123
127
  className="z-10 bg-background absolute right-1 -top-1"
@@ -238,7 +242,8 @@ export function renderChart(chartSpec: string, theme: Theme) {
238
242
 
239
243
  export const AddDataframeChart: React.FC<{
240
244
  chartCode: string;
241
- }> = ({ chartCode }) => {
245
+ columnName: string;
246
+ }> = ({ chartCode, columnName }) => {
242
247
  const autoInstantiate = useAtomValue(autoInstantiateAtom);
243
248
  const lastFocusedCellId = useLastFocusedCellId();
244
249
  const { createNewCell } = useCellActions();
@@ -261,6 +266,7 @@ export const AddDataframeChart: React.FC<{
261
266
  return (
262
267
  <Tooltip content="Add chart to notebook" delayDuration={400}>
263
268
  <Button
269
+ aria-label={`Add chart to notebook for ${columnName}`}
264
270
  variant="outline"
265
271
  size="icon"
266
272
  className="z-10 bg-background absolute right-1 -top-0.5"