@marimo-team/frontend 0.22.5-dev9 → 0.22.6-dev1

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 (102) hide show
  1. package/dist/assets/{CellStatus-vLQ0PRhL.js → CellStatus-CNNGwOIK.js} +1 -1
  2. package/dist/assets/{ConnectedDataExplorerComponent-CAllw3YA.js → ConnectedDataExplorerComponent-CfU-ThkK.js} +1 -1
  3. package/dist/assets/JsonOutput-9XtRRx5l.js +49 -0
  4. package/dist/assets/{MarimoErrorOutput-CbOtWgTE.js → MarimoErrorOutput-Bc9JufDr.js} +2 -2
  5. package/dist/assets/{RenderHTML-HmYLAtrW.js → RenderHTML-0dk6-mYI.js} +1 -1
  6. package/dist/assets/{add-cell-with-ai-DVz3Rqa3.js → add-cell-with-ai-CLklC7KS.js} +8 -8
  7. package/dist/assets/{add-connection-dialog-Bu5E77IS.js → add-connection-dialog-ux7eCDRM.js} +1 -1
  8. package/dist/assets/{agent-panel-HMju_soU.js → agent-panel-CiMrqUfl.js} +3 -3
  9. package/dist/assets/{ai-model-dropdown-DUK_vWZh.js → ai-model-dropdown-CRtaHcCu.js} +3 -3
  10. package/dist/assets/{app-config-button-CVraCieA.js → app-config-button-CnX21edo.js} +1 -1
  11. package/dist/assets/{cache-panel-5QBCQUqv.js → cache-panel-8E_Y5OSb.js} +1 -1
  12. package/dist/assets/{cell-editor-CEok7I_G.js → cell-editor-D7IQ3F4W.js} +10 -10
  13. package/dist/assets/{cell-link-D5GhiNrn.js → cell-link-CcAqXeeg.js} +1 -1
  14. package/dist/assets/{cells-CuaAKcwV.js → cells-EJo3u4za.js} +43 -43
  15. package/dist/assets/{chat-display-ktpBhrn7.js → chat-display-BxDRpNsl.js} +1 -1
  16. package/dist/assets/{chat-panel-BzN2cf87.js → chat-panel-dBoLqgjH.js} +1 -1
  17. package/dist/assets/{chat-ui-Da4qjTuA.js → chat-ui-DdZo1L-v.js} +1 -1
  18. package/dist/assets/{column-preview-BLzfoQuq.js → column-preview-DrU255Z3.js} +1 -1
  19. package/dist/assets/{command-palette-DNvYHUpi.js → command-palette-n6NnK6GP.js} +1 -1
  20. package/dist/assets/{common-BKyn8lE1.js → common-Bty2yo-n.js} +1 -1
  21. package/dist/assets/{components-CtOW1DR4.js → components-B8TZ_vT_.js} +1 -1
  22. package/dist/assets/{components-CBihADZo.js → components-Dh-L-jYg.js} +1 -1
  23. package/dist/assets/config-DoZCLcOb.js +1 -0
  24. package/dist/assets/{datasource-DwmhT5-D.js → datasource-DY0N42ZB.js} +1 -1
  25. package/dist/assets/{dependency-graph-panel-Cq1gKP3a.js → dependency-graph-panel-C23HsAdh.js} +1 -1
  26. package/dist/assets/{documentation-panel-Dd6ys__C.js → documentation-panel-okcEKCQM.js} +1 -1
  27. package/dist/assets/{download-CxGVI9eo.js → download-TSo32ofd.js} +3 -3
  28. package/dist/assets/{edit-page-DJprVtJ6.js → edit-page-RhmoqI7E.js} +7 -7
  29. package/dist/assets/{error-panel-BiGfbiiW.js → error-panel-aq2j0jIa.js} +1 -1
  30. package/dist/assets/{file-explorer-panel-DiNhLdAc.js → file-explorer-panel-CzYUz358.js} +1 -1
  31. package/dist/assets/{file-icons-DaGma7HH.js → file-icons-DBaXCICA.js} +1 -1
  32. package/dist/assets/{floating-outline-oPCmn9_F.js → floating-outline-BTmyhMGv.js} +1 -1
  33. package/dist/assets/{focus-B524Cy57.js → focus-DXeddo75.js} +1 -1
  34. package/dist/assets/{form-BV-yji2Y.js → form-BiDLPu7R.js} +1 -1
  35. package/dist/assets/{gallery-page-CI72Q71Y.js → gallery-page-XSrY7bw_.js} +1 -1
  36. package/dist/assets/{globals-Bh85lAn7.js → globals-DQM2RvzM.js} +1 -1
  37. package/dist/assets/{home-page-kYSYG7Zh.js → home-page-BntiR5eS.js} +2 -2
  38. package/dist/assets/{hooks-SmuOPKfj.js → hooks-BgwM3Mb2.js} +1 -1
  39. package/dist/assets/{html-to-image-C-c-Hfuw.js → html-to-image-BJiJlwQY.js} +1 -1
  40. package/dist/assets/index-CMEhtk8a.js +42 -0
  41. package/dist/assets/index-DBs2il8a.css +2 -0
  42. package/dist/assets/{kiosk-mode-CnJjuo6B.js → kiosk-mode-JCcLyeoQ.js} +1 -1
  43. package/dist/assets/{layout-BxUONa-J.js → layout-CF-7BNtf.js} +3 -3
  44. package/dist/assets/{logs-panel-BH5Q_Nct.js → logs-panel-BzhPrie8.js} +1 -1
  45. package/dist/assets/{markdown-renderer-2XpTunxF.js → markdown-renderer-B9RsGqHb.js} +1 -1
  46. package/dist/assets/{mermaid-KL-Hgqp7.js → mermaid-BJFSZcG6.js} +1 -1
  47. package/dist/assets/{name-cell-input-CIZAWlBG.js → name-cell-input-CYsY4A1G.js} +1 -1
  48. package/dist/assets/{outline-panel-BXefyCJ4.js → outline-panel-BCAWCKi6.js} +1 -1
  49. package/dist/assets/{packages-panel-De0Fg43N.js → packages-panel-5axf3DuF.js} +1 -1
  50. package/dist/assets/{panels-DuR2pNy9.js → panels-7-kbDRzv.js} +1 -1
  51. package/dist/assets/{process-output-C-VBRULx.js → process-output-DqiZsqG9.js} +1 -1
  52. package/dist/assets/{readonly-python-code-BhME4c6A.js → readonly-python-code-D8ITm60r.js} +1 -1
  53. package/dist/assets/{run-page-BDV1C8Oi.js → run-page-9OQqe8IY.js} +1 -1
  54. package/dist/assets/{scratchpad-panel-CWU7CyZh.js → scratchpad-panel-DkqxnSH6.js} +1 -1
  55. package/dist/assets/{secrets-panel-CyJ4KdCC.js → secrets-panel-C6X5jB8Q.js} +1 -1
  56. package/dist/assets/{session-panel-B8t0Xymv.js → session-panel-BuzMiMf3.js} +1 -1
  57. package/dist/assets/{snippets-panel-BaV08Ib1.js → snippets-panel--mh2FUXA.js} +1 -1
  58. package/dist/assets/{state-WTTs5oP6.js → state-6D_2UAw3.js} +2 -2
  59. package/dist/assets/{state-DuVk71Dw.js → state-BDrig0S2.js} +1 -1
  60. package/dist/assets/{state-9-n7I_Bo.js → state-BgrGQPFs.js} +1 -1
  61. package/dist/assets/{switch-C2idsSNO.js → switch-C6xjg01T.js} +1 -1
  62. package/dist/assets/{terminal-BBTjIXBz.js → terminal-BEaHyVIQ.js} +1 -1
  63. package/dist/assets/{textarea-CI3yaazO.js → textarea-Cfp3upzK.js} +1 -1
  64. package/dist/assets/{tracing-ChWqFQa-.js → tracing-BExYhl1z.js} +1 -1
  65. package/dist/assets/{tracing-panel-B46P3LAM.js → tracing-panel-Co5DeX-F.js} +2 -2
  66. package/dist/assets/{useAddCell-BMYemCZ-.js → useAddCell-BaTlDxTu.js} +1 -1
  67. package/dist/assets/{useAsyncData-CaAFMbY9.js → useAsyncData-aCoWDe-l.js} +1 -1
  68. package/dist/assets/{useBoolean-ugd5JdXd.js → useBoolean-BvsK1Xcs.js} +1 -1
  69. package/dist/assets/{useCellActionButton-BKZyr81R.js → useCellActionButton-DftkIqUl.js} +1 -1
  70. package/dist/assets/{useDeleteCell-6SLN_jZa.js → useDeleteCell-d6yWnL3H.js} +1 -1
  71. package/dist/assets/{useDependencyPanelTab-JOuBqQ1y.js → useDependencyPanelTab-BaVcOBM4.js} +1 -1
  72. package/dist/assets/{useNotebookActions-D2fp_HNm.js → useNotebookActions-DihtSJ4g.js} +1 -1
  73. package/dist/assets/{useRunCells-B5o8P7HV.js → useRunCells-d2edY6Tu.js} +1 -1
  74. package/dist/assets/{useSplitCell-Dn4N4Evl.js → useSplitCell-DOiFyMgH.js} +1 -1
  75. package/dist/assets/{vega-component-D_AgSSfE.js → vega-component-CiVPyAwP.js} +1 -1
  76. package/dist/index.html +29 -28
  77. package/package.json +2 -2
  78. package/src/components/data-table/__tests__/columns.test.tsx +92 -13
  79. package/src/components/data-table/column-header.tsx +81 -56
  80. package/src/components/data-table/columns.tsx +25 -32
  81. package/src/components/data-table/data-table.tsx +8 -1
  82. package/src/components/data-table/renderers.tsx +19 -6
  83. package/src/components/data-table/types.ts +4 -0
  84. package/src/components/editor/Output.tsx +1 -1
  85. package/src/components/editor/__tests__/Output.test.tsx +36 -1
  86. package/src/core/cells/__tests__/cells.test.ts +41 -0
  87. package/src/core/cells/__tests__/collapseConsoleOutputs.test.ts +38 -0
  88. package/src/core/cells/cells.ts +1 -1
  89. package/src/core/cells/collapseConsoleOutputs.tsx +3 -0
  90. package/src/core/cells/document-changes.ts +12 -0
  91. package/src/core/runtime/__tests__/runtime.test.ts +138 -2
  92. package/src/core/runtime/runtime.ts +25 -5
  93. package/src/core/saving/file-state.ts +16 -0
  94. package/src/hooks/useAsyncData.ts +1 -1
  95. package/src/mount.tsx +17 -1
  96. package/src/plugins/impl/DataTablePlugin.tsx +1 -1
  97. package/src/plugins/impl/plotly/__tests__/selection.test.ts +22 -0
  98. package/src/plugins/impl/plotly/selection.ts +1 -0
  99. package/dist/assets/JsonOutput-Dl2dfhmz.js +0 -49
  100. package/dist/assets/config-Cgj0Ahvb.js +0 -1
  101. package/dist/assets/index-BNN_F0CC.css +0 -2
  102. package/dist/assets/index-By2ge4IZ.js +0 -42
@@ -2,7 +2,13 @@
2
2
  "use no memo";
3
3
 
4
4
  import type { Column, Table } from "@tanstack/react-table";
5
- import { FilterIcon, MinusIcon, TextIcon, XIcon } from "lucide-react";
5
+ import {
6
+ EllipsisIcon,
7
+ FilterIcon,
8
+ MinusIcon,
9
+ TextIcon,
10
+ XIcon,
11
+ } from "lucide-react";
6
12
  import { useMemo, useRef, useState } from "react";
7
13
  import { useLocale } from "react-aria";
8
14
  import {
@@ -69,7 +75,7 @@ interface DataTableColumnHeaderProps<
69
75
  > extends React.HTMLAttributes<HTMLDivElement> {
70
76
  column: Column<TData, TValue>;
71
77
  header: React.ReactNode;
72
- justify?: "left" | "center" | "right";
78
+ subheader?: React.ReactNode;
73
79
  calculateTopKRows?: CalculateTopKRows;
74
80
  table?: Table<TData>;
75
81
  }
@@ -77,7 +83,7 @@ interface DataTableColumnHeaderProps<
77
83
  export const DataTableColumnHeader = <TData, TValue>({
78
84
  column,
79
85
  header,
80
- justify,
86
+ subheader,
81
87
  className,
82
88
  calculateTopKRows,
83
89
  table,
@@ -92,49 +98,51 @@ export const DataTableColumnHeader = <TData, TValue>({
92
98
 
93
99
  // No sorting or filtering
94
100
  if (!column.getCanSort() && !column.getCanFilter()) {
95
- return <div className={cn(className)}>{header}</div>;
101
+ return (
102
+ <div className={cn(className)}>
103
+ {header}
104
+ {subheader}
105
+ </div>
106
+ );
96
107
  }
97
108
 
98
109
  const hasFilter = column.getFilterValue() !== undefined;
99
- const hideIcon = !column.getIsSorted() && !hasFilter;
100
110
 
101
111
  return (
102
112
  <>
103
- <DropdownMenu modal={false}>
104
- <DropdownMenuTrigger asChild={true}>
105
- <div
106
- className={cn(
107
- "group flex items-center my-1 space-between w-full select-none gap-2 border hover:border-border border-transparent hover:bg-(--slate-3) data-[state=open]:bg-(--slate-3) data-[state=open]:border-border rounded px-1 -mx-1",
108
- justify === "right" && "flex-row-reverse",
109
- className,
110
- )}
111
- data-testid="data-table-sort-button"
112
- >
113
- <span className="flex-1">{header}</span>
114
- <span
115
- className={cn(
116
- "h-5 py-1 px-1",
117
- hideIcon &&
118
- "invisible group-hover:visible data-[state=open]:visible",
119
- )}
120
- >
121
- {renderSortFilterIcon(column)}
122
- </span>
123
- </div>
124
- </DropdownMenuTrigger>
125
- <DropdownMenuContent align="start">
126
- {renderDataType(column)}
127
- {renderSorts(column, table)}
128
- {renderCopyColumn(column)}
129
- {renderColumnPinning(column)}
130
- {renderColumnWrapping(column)}
131
- {renderFormatOptions(column, locale)}
132
- <DropdownMenuSeparator />
133
- {renderMenuItemFilter(column)}
134
- {renderFilterByValues(column, setIsFilterValueOpen)}
135
- {hasFilter && <ClearFilterMenuItem column={column} />}
136
- </DropdownMenuContent>
137
- </DropdownMenu>
113
+ <div
114
+ className={cn("group flex flex-col my-1 w-full select-none", className)}
115
+ >
116
+ <div className="flex items-center gap-1">
117
+ <span>{header}</span>
118
+ {column.getCanSort() && <SortButton column={column} />}
119
+ <DropdownMenu modal={false}>
120
+ <DropdownMenuTrigger asChild={true}>
121
+ <button
122
+ type="button"
123
+ className="inline-flex items-center justify-center h-5 w-5 rounded hover:bg-(--slate-4) text-muted-foreground opacity-0 group-hover:opacity-100 focus:opacity-100 group-focus-within:opacity-100 data-[state=open]:opacity-100 data-[state=open]:text-accent-foreground"
124
+ aria-label="Column options"
125
+ data-testid="data-table-column-menu-button"
126
+ >
127
+ <EllipsisIcon className="h-3.5 w-3.5" />
128
+ </button>
129
+ </DropdownMenuTrigger>
130
+ <DropdownMenuContent align="start">
131
+ {renderDataType(column)}
132
+ {renderSorts(column, table)}
133
+ {renderCopyColumn(column)}
134
+ {renderColumnPinning(column)}
135
+ {renderColumnWrapping(column)}
136
+ {renderFormatOptions(column, locale)}
137
+ <DropdownMenuSeparator />
138
+ {renderMenuItemFilter(column)}
139
+ {renderFilterByValues(column, setIsFilterValueOpen)}
140
+ {hasFilter && <ClearFilterMenuItem column={column} />}
141
+ </DropdownMenuContent>
142
+ </DropdownMenu>
143
+ </div>
144
+ {subheader}
145
+ </div>
138
146
  {isFilterValueOpen && (
139
147
  <PopoverFilterByValues
140
148
  setIsFilterValueOpen={setIsFilterValueOpen}
@@ -146,28 +154,45 @@ export const DataTableColumnHeader = <TData, TValue>({
146
154
  );
147
155
  };
148
156
 
149
- export const DataTableColumnHeaderWithSummary = <TData, TValue>({
157
+ const SortButton = <TData, TValue>({
150
158
  column,
151
- header,
152
- summary,
153
- className,
154
- }: DataTableColumnHeaderProps<TData, TValue> & {
155
- summary: React.ReactNode;
159
+ }: {
160
+ column: Column<TData, TValue>;
156
161
  }) => {
162
+ const sortDirection = column.getIsSorted();
163
+
164
+ const handleClick = (e: React.MouseEvent) => {
165
+ e.stopPropagation();
166
+ if (!sortDirection) {
167
+ column.toggleSorting(false, true); // asc
168
+ } else if (sortDirection === "asc") {
169
+ column.toggleSorting(true, true); // desc
170
+ } else {
171
+ column.clearSorting();
172
+ }
173
+ };
174
+
157
175
  return (
158
- <div
176
+ <button
177
+ type="button"
178
+ onClick={handleClick}
159
179
  className={cn(
160
- "flex flex-col h-full pt-0.5 pb-3 justify-between items-start",
161
- className,
180
+ "inline-flex items-center justify-center h-5 w-5 rounded hover:bg-(--slate-4)",
181
+ sortDirection
182
+ ? "text-accent-foreground"
183
+ : "text-muted-foreground opacity-0 group-hover:opacity-100 focus:opacity-100 group-focus-within:opacity-100",
162
184
  )}
185
+ aria-label={
186
+ sortDirection === "asc"
187
+ ? "Sorted ascending, click to sort descending"
188
+ : sortDirection === "desc"
189
+ ? "Sorted descending, click to clear sort"
190
+ : "Sort column ascending"
191
+ }
192
+ data-testid="data-table-sort-button"
163
193
  >
164
- <DataTableColumnHeader
165
- column={column}
166
- header={header}
167
- className={className}
168
- />
169
- {summary}
170
- </div>
194
+ {renderSortFilterIcon(column)}
195
+ </button>
171
196
  );
172
197
  };
173
198
 
@@ -206,41 +206,30 @@ export function generateColumns<T>({
206
206
  </div>
207
207
  ) : null;
208
208
 
209
- const justify = getJustify(key);
210
-
211
- const headerWithType = (
212
- <div
209
+ const headerName = (
210
+ <span
213
211
  className={cn(
214
- "flex flex-col",
215
- justify === "center" && "items-center",
216
- justify === "right" && "items-end",
212
+ "font-bold",
213
+ headerTitle && "underline decoration-dotted",
217
214
  )}
218
215
  >
219
- <span
220
- className={cn(
221
- "font-bold",
222
- headerTitle && "underline decoration-dotted",
223
- )}
224
- >
225
- {key === "" ? " " : key}
226
- </span>
227
- {dtypeHeader}
228
- </div>
216
+ {key === "" ? " " : key}
217
+ </span>
229
218
  );
230
219
 
231
220
  const headerWithTooltip = headerTitle ? (
232
221
  <Tooltip content={headerTitle} delayDuration={300}>
233
- {headerWithType}
222
+ {headerName}
234
223
  </Tooltip>
235
224
  ) : (
236
- headerWithType
225
+ headerName
237
226
  );
238
227
 
239
228
  const dataTableColumnHeader = (
240
229
  <DataTableColumnHeader
241
230
  header={headerWithTooltip}
231
+ subheader={dtypeHeader}
242
232
  column={column}
243
- justify={justify}
244
233
  calculateTopKRows={calculateTopKRows}
245
234
  table={table}
246
235
  />
@@ -255,8 +244,6 @@ export function generateColumns<T>({
255
244
  <div
256
245
  className={cn(
257
246
  "flex flex-col h-full pt-0.5 pb-3 justify-between items-start",
258
- justify === "center" && "items-center",
259
- justify === "right" && "items-end",
260
247
  )}
261
248
  >
262
249
  {dataTableColumnHeader}
@@ -283,13 +270,13 @@ export function generateColumns<T>({
283
270
 
284
271
  const dataType = column.columnDef.meta?.dataType;
285
272
  const isNumeric = dataType === "number" || dataType === "integer";
286
- const cellStyles = getCellStyleClass(
273
+ const cellStyles = getCellStyleClass({
287
274
  justify,
288
275
  wrapped,
289
276
  canSelectCell,
290
- isCellSelected,
277
+ isSelected: isCellSelected,
291
278
  isNumeric,
292
- );
279
+ });
293
280
 
294
281
  const renderedCell = renderCellValue({
295
282
  column,
@@ -448,13 +435,19 @@ function getFilterTypeForFieldType(
448
435
  }
449
436
  }
450
437
 
451
- function getCellStyleClass(
452
- justify: "left" | "center" | "right" | undefined,
453
- wrapped: boolean | undefined,
454
- canSelectCell: boolean,
455
- isSelected: boolean,
456
- isNumeric?: boolean,
457
- ): string {
438
+ function getCellStyleClass({
439
+ justify = "left",
440
+ wrapped,
441
+ canSelectCell,
442
+ isSelected,
443
+ isNumeric = false,
444
+ }: {
445
+ justify: "left" | "center" | "right" | undefined;
446
+ wrapped: boolean | undefined;
447
+ canSelectCell: boolean;
448
+ isSelected: boolean;
449
+ isNumeric?: boolean;
450
+ }): string {
458
451
  return cn(
459
452
  canSelectCell && "cursor-pointer",
460
453
  isSelected &&
@@ -47,6 +47,7 @@ import { DataTableBody, renderTableHeader } from "./renderers";
47
47
  import { TableBottomBar } from "./TableBottomBar";
48
48
  import { TableTopBar } from "./TableTopBar";
49
49
  import {
50
+ AUTO_WIDTH_MAX_COLUMNS,
50
51
  type DataTableSelection,
51
52
  MIN_ROWS_TO_VIRTUALIZE,
52
53
  type TooManyRows,
@@ -300,7 +301,13 @@ const DataTableInternal = <TData,>({
300
301
  isAnyPanelOpen={isAnyPanelOpen}
301
302
  downloadAs={downloadAs}
302
303
  />
303
- <Table className="relative" ref={tableRef}>
304
+ <Table
305
+ className={cn(
306
+ "relative",
307
+ columns.length <= AUTO_WIDTH_MAX_COLUMNS ? "w-auto" : "w-full",
308
+ )}
309
+ ref={tableRef}
310
+ >
304
311
  {showLoadingBar && (
305
312
  <thead className="absolute top-0 left-0 h-[3px] w-1/2 bg-primary animate-slide" />
306
313
  )}
@@ -27,7 +27,7 @@ import { DataTableContextMenu } from "./context-menu";
27
27
  import { CellRangeSelectionIndicator } from "./range-focus/cell-selection-indicator";
28
28
  import { useCellRangeSelection } from "./range-focus/use-cell-range-selection";
29
29
  import { useScrollIntoViewOnFocus } from "./range-focus/use-scroll-into-view";
30
- import { TABLE_ROW_HEIGHT_PX } from "./types";
30
+ import { AUTO_WIDTH_MAX_COLUMNS, TABLE_ROW_HEIGHT_PX } from "./types";
31
31
  import { stringifyUnknownValue } from "./utils";
32
32
 
33
33
  export function renderTableHeader<TData>(
@@ -46,7 +46,7 @@ export function renderTableHeader<TData>(
46
46
  <TableHead
47
47
  key={header.id}
48
48
  className={cn(
49
- "h-auto min-h-10 whitespace-pre align-top",
49
+ "h-auto min-h-10 whitespace-pre align-top border-r border-r-border/75",
50
50
  className,
51
51
  )}
52
52
  style={style}
@@ -69,6 +69,13 @@ export function renderTableHeader<TData>(
69
69
  {renderHeaderGroup(table.getLeftHeaderGroups())}
70
70
  {renderHeaderGroup(table.getCenterHeaderGroups())}
71
71
  {renderHeaderGroup(table.getRightHeaderGroups())}
72
+ {table.getAllColumns().length <= AUTO_WIDTH_MAX_COLUMNS && (
73
+ <th
74
+ className="w-full border-0"
75
+ aria-hidden="true"
76
+ role="presentation"
77
+ />
78
+ )}
72
79
  </TableRow>
73
80
  </TableHeader>
74
81
  );
@@ -163,7 +170,7 @@ export const DataTableBody = <TData,>({
163
170
  {...getCellDomProps(cell.id)}
164
171
  key={cell.id}
165
172
  className={cn(
166
- "whitespace-pre truncate max-w-[300px] outline-hidden",
173
+ "whitespace-pre truncate max-w-[300px] outline-hidden border-r border-r-border/75",
167
174
  cell.column.getColumnWrapping &&
168
175
  cell.column.getColumnWrapping?.() === "wrap" &&
169
176
  COLUMN_WRAPPING_STYLES,
@@ -230,15 +237,21 @@ export const DataTableBody = <TData,>({
230
237
  {renderCells(row.getLeftVisibleCells())}
231
238
  {renderCells(row.getCenterVisibleCells())}
232
239
  {renderCells(row.getRightVisibleCells())}
240
+ {columns.length <= AUTO_WIDTH_MAX_COLUMNS && (
241
+ <td className="border-0" aria-hidden="true" role="presentation" />
242
+ )}
233
243
  </TableRow>
234
244
  );
235
245
  };
236
246
 
247
+ const hasFillerColumn = columns.length <= AUTO_WIDTH_MAX_COLUMNS;
248
+ const totalColSpan = columns.length + (hasFillerColumn ? 1 : 0);
249
+
237
250
  const renderRows = () => {
238
251
  if (rows.length === 0) {
239
252
  return (
240
253
  <TableRow>
241
- <TableCell colSpan={columns.length} className="h-24 text-center">
254
+ <TableCell colSpan={totalColSpan} className="h-24 text-center">
242
255
  No results.
243
256
  </TableCell>
244
257
  </TableRow>
@@ -255,7 +268,7 @@ export const DataTableBody = <TData,>({
255
268
  data-virtual-spacer=""
256
269
  style={{ height: virtualItems[0].start }}
257
270
  >
258
- <td colSpan={columns.length} />
271
+ <td colSpan={totalColSpan} />
259
272
  </tr>
260
273
  )}
261
274
  {virtualItems.map((vItem) => renderRow(rows[vItem.index]))}
@@ -266,7 +279,7 @@ export const DataTableBody = <TData,>({
266
279
  height: totalSize - (virtualItems.at(-1)?.end ?? totalSize),
267
280
  }}
268
281
  >
269
- <td colSpan={columns.length} />
282
+ <td colSpan={totalColSpan} />
270
283
  </tr>
271
284
  )}
272
285
  </>
@@ -16,6 +16,10 @@ declare module "@tanstack/react-table" {
16
16
  export const TABLE_ROW_HEIGHT_PX = 24;
17
17
  export const TABLE_HEADER_HEIGHT_PX = 40;
18
18
 
19
+ // Below this column count, the table uses w-auto with a filler column
20
+ // to prevent columns from stretching unnecessarily
21
+ export const AUTO_WIDTH_MAX_COLUMNS = 4;
22
+
19
23
  // Default number of visible rows when virtualizing without an explicit maxHeight.
20
24
  export const DEFAULT_VIRTUAL_ROWS = 15;
21
25
 
@@ -367,7 +367,7 @@ export const OutputArea = React.memo(
367
367
  forceExpand,
368
368
  className,
369
369
  }: OutputAreaProps) => {
370
- if (output === null) {
370
+ if (output == null) {
371
371
  return null;
372
372
  }
373
373
  if (output.channel === "output" && output.data === "") {
@@ -1,7 +1,9 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
  import { render, screen } from "@testing-library/react";
3
3
  import { describe, expect, it } from "vitest";
4
- import { OutputRenderer } from "../Output";
4
+ import { cellId } from "@/__tests__/branded";
5
+ import { TooltipProvider } from "@/components/ui/tooltip";
6
+ import { OutputArea, OutputRenderer } from "../Output";
5
7
 
6
8
  describe("OutputRenderer renderFallback prop", () => {
7
9
  it("should use renderFallback for unsupported mimetypes", () => {
@@ -65,6 +67,39 @@ describe("OutputRenderer renderFallback prop", () => {
65
67
  });
66
68
  });
67
69
 
70
+ describe("OutputArea null/undefined handling", () => {
71
+ it("should render null when output is null", () => {
72
+ const { container } = render(
73
+ <TooltipProvider>
74
+ <OutputArea
75
+ output={null}
76
+ cellId={cellId("test")}
77
+ stale={false}
78
+ loading={false}
79
+ allowExpand={true}
80
+ />
81
+ </TooltipProvider>,
82
+ );
83
+ expect(container.innerHTML).toBe("");
84
+ });
85
+
86
+ it("should render null when output is undefined", () => {
87
+ const { container } = render(
88
+ <TooltipProvider>
89
+ <OutputArea
90
+ // @ts-expect-error -- testing runtime safety for undefined output
91
+ output={undefined}
92
+ cellId={cellId("test")}
93
+ stale={false}
94
+ loading={false}
95
+ allowExpand={true}
96
+ />
97
+ </TooltipProvider>,
98
+ );
99
+ expect(container.innerHTML).toBe("");
100
+ });
101
+ });
102
+
68
103
  describe("OutputRenderer image and SVG rendering", () => {
69
104
  const plainSvgString =
70
105
  '<svg><rect x="0" y="0" width="10" height="10"></rect></svg>';
@@ -1209,6 +1209,47 @@ describe("cell reducer", () => {
1209
1209
  ]);
1210
1210
  });
1211
1211
 
1212
+ it("does not crash when setStdinResponse has out-of-bounds outputIndex", () => {
1213
+ const STDOUT: OutputMessage = {
1214
+ channel: "stdout",
1215
+ mimetype: "text/plain",
1216
+ data: "hello!",
1217
+ timestamp: 1,
1218
+ };
1219
+
1220
+ // Set the cell to running with a console output
1221
+ actions.prepareForRun({ cellId: firstCellId });
1222
+ actions.handleCellMessage({
1223
+ cell_id: firstCellId,
1224
+ output: undefined,
1225
+ console: null,
1226
+ status: "running",
1227
+ stale_inputs: null,
1228
+ timestamp: new Date(20).getTime() as Seconds,
1229
+ });
1230
+ actions.handleCellMessage({
1231
+ cell_id: firstCellId,
1232
+ output: undefined,
1233
+ console: STDOUT,
1234
+ status: undefined,
1235
+ stale_inputs: null,
1236
+ timestamp: new Date(22).getTime() as Seconds,
1237
+ });
1238
+
1239
+ // Try to set stdin response with an out-of-bounds index
1240
+ // This should not crash - it should return state unchanged
1241
+ actions.setStdinResponse({
1242
+ response: "test",
1243
+ cellId: firstCellId,
1244
+ outputIndex: 999,
1245
+ });
1246
+
1247
+ // Cell state should be unchanged
1248
+ const cell = cells[0];
1249
+ expect(cell.consoleOutputs).toHaveLength(1);
1250
+ expect(cell.consoleOutputs[0]).toMatchObject(STDOUT);
1251
+ });
1252
+
1212
1253
  it("can receive console when the cell is idle and will clear when starts again", () => {
1213
1254
  const OLD_STDOUT: OutputMessage = {
1214
1255
  channel: "stdout",
@@ -241,6 +241,44 @@ describe("collapseConsoleOutputs", () => {
241
241
  expect(result[2].data).toBe("<pre>E\nF\nG\nH\n</pre>");
242
242
  });
243
243
 
244
+ it("should not crash when truncating with a single output at the limit boundary", () => {
245
+ // Create outputs that push truncation to the exact boundary
246
+ const consoleOutputs: OutputMessage[] = [
247
+ {
248
+ mimetype: "text/html",
249
+ channel: "output",
250
+ data: "<div>html1</div>",
251
+ timestamp: 0,
252
+ },
253
+ {
254
+ mimetype: "text/html",
255
+ channel: "output",
256
+ data: "<div>html2</div>",
257
+ timestamp: 0,
258
+ },
259
+ ];
260
+ // With limit=1, truncation must handle edge cases gracefully
261
+ const result = collapseConsoleOutputs(consoleOutputs, 1);
262
+ expect(result[0].data).toContain("Streaming output truncated");
263
+ });
264
+
265
+ it("should handle truncation when cutoff indexes past the end of the array", () => {
266
+ // With maxLines=0, the truncation loop never runs, causing cutoff
267
+ // to index past the array. This exercises the `output == null`
268
+ // defensive branch in truncateHead().
269
+ const consoleOutputs: OutputMessage[] = [
270
+ {
271
+ mimetype: "text/html",
272
+ channel: "output",
273
+ data: "<div>content</div>",
274
+ timestamp: 0,
275
+ },
276
+ ];
277
+ const result = collapseConsoleOutputs(consoleOutputs, 0);
278
+ expect(result).toHaveLength(1);
279
+ expect(result[0].data).toContain("Streaming output truncated");
280
+ });
281
+
244
282
  describe("ANSI escape sequences", () => {
245
283
  it("should handle cursor movement with collapse", () => {
246
284
  const consoleOutputs: OutputMessage[] = [
@@ -947,7 +947,7 @@ const {
947
947
  cellReducer: (cell) => {
948
948
  const consoleOutputs = [...cell.consoleOutputs];
949
949
  const stdinOutput = consoleOutputs[outputIndex];
950
- if (stdinOutput.channel !== "stdin") {
950
+ if (stdinOutput == null || stdinOutput.channel !== "stdin") {
951
951
  Logger.warn("Expected stdin output");
952
952
  return cell;
953
953
  }
@@ -108,6 +108,9 @@ function truncateHead(consoleOutputs: OutputMessage[], limit: number) {
108
108
  timestamp: -1,
109
109
  };
110
110
  const output = consoleOutputs[cutoff];
111
+ if (output == null) {
112
+ return [warningOutput, ...consoleOutputs.slice(cutoff + 1)];
113
+ }
111
114
  if (output.mimetype === "text/plain") {
112
115
  invariant(typeof output.data === "string", "expected string");
113
116
  const outputLines = output.data.split("\n");
@@ -25,6 +25,7 @@ import type { NotebookDocumentTransactionRequest } from "../network/types";
25
25
  import { store } from "../state/jotai";
26
26
  import type { CellActions, NotebookState } from "./cells";
27
27
  import type { CellId } from "./ids";
28
+ import { SCRATCH_CELL_ID } from "./ids";
28
29
  import type { CellData } from "./types";
29
30
 
30
31
  export type DocumentChange =
@@ -572,10 +573,21 @@ const flushChanges = debounce(() => {
572
573
  void getRequestClient().sendDocumentTransaction({ changes });
573
574
  }, 400);
574
575
 
576
+ function isScratchChange(change: DocumentChange): boolean {
577
+ if ("cellId" in change && change.cellId === SCRATCH_CELL_ID) {
578
+ return true;
579
+ }
580
+ return false;
581
+ }
582
+
575
583
  function enqueue(change: DocumentChange) {
576
584
  if (store.get(kioskModeAtom)) {
577
585
  return;
578
586
  }
587
+ // The scratchpad cell is local-only — don't sync it to the document.
588
+ if (isScratchChange(change)) {
589
+ return;
590
+ }
579
591
  pendingChanges.push(change);
580
592
  flushChanges();
581
593
  }