@marimo-team/frontend 0.25.3-dev3 → 0.25.3-dev5

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 (113) hide show
  1. package/dist/assets/{CellStatus-BP2mavAq.js → CellStatus-nZpuDkm7.js} +1 -1
  2. package/dist/assets/{ErrorBoundary-Gl5nCA8C.js → ErrorBoundary-B4n9EnHB.js} +2 -2
  3. package/dist/assets/JsonOutput-d-k8v_17.js +11 -0
  4. package/dist/assets/MarimoErrorOutput-CLYUxiKW.js +10 -0
  5. package/dist/assets/{RenderHTML-BbOqmHy3.js → RenderHTML-9bMgbYmo.js} +1 -1
  6. package/dist/assets/{RunButton-CK3LhnP5.js → RunButton-CFQUNN5c.js} +1 -1
  7. package/dist/assets/{add-cell-with-ai-8UBC0xwR.js → add-cell-with-ai-C_SruRWS.js} +9 -9
  8. package/dist/assets/{add-connection-dialog-DXQnd6eB.js → add-connection-dialog-CoE_PhuD.js} +1 -1
  9. package/dist/assets/{agent-panel-D7kMZLG-.js → agent-panel-0b3k4QzJ.js} +5 -5
  10. package/dist/assets/ai-model-dropdown-C6GHHEdR.js +5 -0
  11. package/dist/assets/{app-config-button-BKEG4x9u.js → app-config-button-iXdCWvfC.js} +1 -1
  12. package/dist/assets/{blob-D5nfNHOo.js → blob-iOBwGaP9.js} +1 -1
  13. package/dist/assets/{cell-editor-ADVfycpN.js → cell-editor-CR-tZNnc.js} +9 -9
  14. package/dist/assets/{cell-link-LSth_8hT.js → cell-link-1CynTVGQ.js} +1 -1
  15. package/dist/assets/{cells-BRkCaSsh.js → cells-rA_jsZCj.js} +1 -1
  16. package/dist/assets/{chat-display-Dnagy4Nv.js → chat-display-luF7psRA.js} +1 -1
  17. package/dist/assets/{chat-panel-DQ6HkWEF.js → chat-panel-DDDTnLvB.js} +2 -2
  18. package/dist/assets/{chat-ui-Df8Vcj6n.js → chat-ui-nV_m4Btr.js} +1 -1
  19. package/dist/assets/{column-preview-Cm2D6vyX.js → column-preview-uosAAL4X.js} +1 -1
  20. package/dist/assets/{command-palette-DTSQ7gOC.js → command-palette-BZ13RKDJ.js} +1 -1
  21. package/dist/assets/{common-Cu6CDbuw.js → common-lisZ0l6a.js} +1 -1
  22. package/dist/assets/{components-CGw16R_N.js → components-S7ZwOrvO.js} +1 -1
  23. package/dist/assets/config-FzDtjF1-.js +1 -0
  24. package/dist/assets/{datasource-Cq6x_5ML.js → datasource-V0-52XOh.js} +1 -1
  25. package/dist/assets/{dates-BbFQ2oE4.js → dates-CfmdTL6B.js} +1 -1
  26. package/dist/assets/{dependency-graph-panel--Kgtktcw.js → dependency-graph-panel-B0uP-_id.js} +1 -1
  27. package/dist/assets/{documentation-panel-BHmbeznw.js → documentation-panel-DVl_ebWy.js} +1 -1
  28. package/dist/assets/{download-DCJXy66H.js → download-Bx55LMki.js} +1 -1
  29. package/dist/assets/{edit-page-ChcSgpke.js → edit-page-CGyjT-CQ.js} +6 -6
  30. package/dist/assets/{error-entries-Dk3VZ-OK.js → error-entries-DOWpdGvI.js} +1 -1
  31. package/dist/assets/{error-panel-BPRYmC5J.js → error-panel-C-uxLOHF.js} +1 -1
  32. package/dist/assets/{feedback-button-Bik2-e33.js → feedback-button-B99ThNJW.js} +1 -1
  33. package/dist/assets/feedback-button-DlqZkJwz.js +1 -0
  34. package/dist/assets/{file-explorer-panel-BEEK5lcl.js → file-explorer-panel-D3CphidS.js} +1 -1
  35. package/dist/assets/{file-name-input-Cdc-TVqO.js → file-name-input-ChnBsDE_.js} +1 -1
  36. package/dist/assets/{floating-outline-XD9FAV-f.js → floating-outline-MYqQW4Vp.js} +1 -1
  37. package/dist/assets/{gallery-page-Dm6mGwBi.js → gallery-page-DSL0J4md.js} +1 -1
  38. package/dist/assets/{glide-data-editor-J9CAb70d.js → glide-data-editor-B1MWlKeM.js} +1 -1
  39. package/dist/assets/{home-page-DMHnsRxm.js → home-page-B1M3X9Yf.js} +1 -1
  40. package/dist/assets/{hooks-93IwHd2u.js → hooks-Bj0C0zrZ.js} +1 -1
  41. package/dist/assets/index-CXIE-QEG.css +2 -0
  42. package/dist/assets/{index-s-M1OXjs.js → index-DnIgeCyQ.js} +7 -7
  43. package/dist/assets/{layout-DmcNEu3P.js → layout-DBuMeWh0.js} +3 -3
  44. package/dist/assets/{logs-panel-ABM16htT.js → logs-panel-y0XnfulJ.js} +1 -1
  45. package/dist/assets/{markdown-renderer-BG3sp2rs.js → markdown-renderer-Q5AhbRU3.js} +1 -1
  46. package/dist/assets/{name-cell-input-BgEBmqyL.js → name-cell-input-BNOnF6-0.js} +1 -1
  47. package/dist/assets/{outline-panel-DCXQ3_F6.js → outline-panel-Be7stWwv.js} +1 -1
  48. package/dist/assets/{packages-panel-BMGOmZkR.js → packages-panel-9Qs1npCZ.js} +1 -1
  49. package/dist/assets/{panel-accordion-state-C1sdPp0m.js → panel-accordion-state-GeMEdCPw.js} +1 -1
  50. package/dist/assets/panels-B9x935By.js +4 -0
  51. package/dist/assets/{process-output-D0KGq04R.js → process-output-DNFKMODr.js} +1 -1
  52. package/dist/assets/{readonly-code-display-Do50PHI8.js → readonly-code-display-YULOXf4h.js} +1 -1
  53. package/dist/assets/{readonly-python-code-DCSMIsTX.js → readonly-python-code-CadgXgRu.js} +1 -1
  54. package/dist/assets/{reveal-component-ltaepAqw.js → reveal-component-B9FLjTg_.js} +1 -1
  55. package/dist/assets/{run-page-DF3c5cDk.js → run-page-CjdGaic3.js} +1 -1
  56. package/dist/assets/{runtime-details-state-CSPobEdR.js → runtime-details-state-DuChzsA6.js} +1 -1
  57. package/dist/assets/{scratchpad-panel-Dwd6W5PE.js → scratchpad-panel-DtB0A8xj.js} +1 -1
  58. package/dist/assets/session-panel-B6tA28BD.js +1 -0
  59. package/dist/assets/{snippets-panel-Bh1oTw4m.js → snippets-panel-CQ1dn3lA.js} +1 -1
  60. package/dist/assets/state-BUsmcyaT.js +3 -0
  61. package/dist/assets/{state-XsPO8hNi.js → state-Bfh_2SLl.js} +1 -1
  62. package/dist/assets/{tracing-Di6pUmX8.js → tracing-BARlJ_Dk.js} +1 -1
  63. package/dist/assets/{tracing-panel-PeRXHD8_.js → tracing-panel-dgnr2fIN.js} +2 -2
  64. package/dist/assets/{tree-actions-GM5E_bUs.js → tree-actions-DAd80MbP.js} +1 -1
  65. package/dist/assets/{useAddCell-GR7eEQrT.js → useAddCell-DGstJK0S.js} +1 -1
  66. package/dist/assets/{useCellActionButton-B-9eG5z_.js → useCellActionButton-DY9RlJTb.js} +1 -1
  67. package/dist/assets/{useDeleteCell-2_bMClHd.js → useDeleteCell-DFBGlSQk.js} +1 -1
  68. package/dist/assets/{useDependencyPanelTab-k3C3M18S.js → useDependencyPanelTab-DydxsE5d.js} +1 -1
  69. package/dist/assets/{useNotebookActions-NHN6A-cN.js → useNotebookActions-CupIPSlg.js} +1 -1
  70. package/dist/assets/{useRunCells-B2vUhREg.js → useRunCells-BFjFX3hz.js} +1 -1
  71. package/dist/assets/{useSplitCell-DEb03htp.js → useSplitCell-Tqv5pPtL.js} +1 -1
  72. package/dist/index.html +24 -25
  73. package/package.json +1 -1
  74. package/src/components/data-table/__tests__/context-menu.test.ts +176 -0
  75. package/src/components/data-table/__tests__/date-filter-inputs.test.tsx +24 -1
  76. package/src/components/data-table/context-menu.tsx +125 -26
  77. package/src/components/data-table/date-filter-inputs.tsx +15 -42
  78. package/src/components/data-table/utils.ts +18 -0
  79. package/src/components/editor/Output.tsx +5 -2
  80. package/src/components/editor/chrome/panels/error-panel.tsx +1 -0
  81. package/src/components/editor/chrome/wrapper/__tests__/attachment-status.test.tsx +167 -0
  82. package/src/components/editor/chrome/wrapper/footer-item.tsx +32 -24
  83. package/src/components/editor/chrome/wrapper/footer-items/attachment-status.tsx +82 -0
  84. package/src/components/editor/chrome/wrapper/footer.tsx +2 -0
  85. package/src/components/editor/errors/__tests__/send-error-report-button.test.tsx +434 -0
  86. package/src/components/editor/errors/send-error-report-button.tsx +66 -0
  87. package/src/components/editor/output/MarimoErrorOutput.tsx +21 -5
  88. package/src/components/editor/output/MarimoTracebackOutput.tsx +9 -0
  89. package/src/components/editor/output/__tests__/ansi-reduce.test.ts +18 -0
  90. package/src/components/editor/output/ansi-reduce.ts +6 -0
  91. package/src/core/attachments/state.ts +21 -0
  92. package/src/core/cells/__tests__/cells.test.ts +52 -0
  93. package/src/core/cells/__tests__/error-report.test.ts +89 -0
  94. package/src/core/cells/__tests__/session.test.ts +34 -0
  95. package/src/core/cells/error-report.ts +55 -0
  96. package/src/core/cells/session.ts +9 -0
  97. package/src/core/islands/__tests__/bootstrap.test.ts +26 -0
  98. package/src/core/islands/bootstrap.ts +1 -0
  99. package/src/core/websocket/__tests__/useMarimoKernelConnection.hook.test.tsx +193 -0
  100. package/src/core/websocket/useMarimoKernelConnection.tsx +9 -0
  101. package/src/utils/__tests__/dates.test.ts +116 -0
  102. package/src/utils/dates.ts +94 -0
  103. package/dist/assets/JsonOutput-D6HIIsCb.js +0 -11
  104. package/dist/assets/MarimoErrorOutput-D60XkCny.js +0 -6
  105. package/dist/assets/ai-model-dropdown-B0z6-zhp.js +0 -5
  106. package/dist/assets/api-BeC_VSGv.js +0 -1
  107. package/dist/assets/config-BOKzA3tF.js +0 -1
  108. package/dist/assets/feedback-button-B2gKC5cn.js +0 -1
  109. package/dist/assets/index-Bcdzex-3.css +0 -2
  110. package/dist/assets/pair-CaQyK6En.js +0 -1
  111. package/dist/assets/panels-CxVG8-v2.js +0 -4
  112. package/dist/assets/session-panel-VdDhEBa-.js +0 -1
  113. package/dist/assets/state-_Qai_QyX.js +0 -3
@@ -1,7 +1,30 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
  import { describe, expect, it } from "vitest";
3
3
  import { dateToLocalISODate } from "@/utils/dates";
4
- import { parsePastedRange } from "../date-filter-inputs";
4
+ import { parsePastedDate, parsePastedRange } from "../date-filter-inputs";
5
+
6
+ describe("parsePastedDate", () => {
7
+ it("parses a time-only string for the time filter type", () => {
8
+ const result = parsePastedDate("time", "2:30 PM");
9
+ expect(result).toEqual(new Date(1970, 0, 1, 14, 30));
10
+ });
11
+
12
+ it("parses a local ISO datetime for the datetime filter type", () => {
13
+ const result = parsePastedDate("datetime", "2026-02-01T08:30:00");
14
+ expect(result).toEqual(new Date(2026, 1, 1, 8, 30, 0));
15
+ });
16
+
17
+ it("falls back to the Date constructor for other formats", () => {
18
+ const result = parsePastedDate("datetime", "Feb 1, 2026 08:30:00");
19
+ expect(result).toBeDefined();
20
+ expect(result?.getFullYear()).toBe(2026);
21
+ });
22
+
23
+ it("returns undefined for an empty or unparsable string", () => {
24
+ expect(parsePastedDate("date", "")).toBeUndefined();
25
+ expect(parsePastedDate("time", "not-a-time")).toBeUndefined();
26
+ });
27
+ });
5
28
 
6
29
  describe("parsePastedRange", () => {
7
30
  it.each([
@@ -1,12 +1,19 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
+ import type { DataType } from "@/core/kernel/messages";
4
+ import { assertNever } from "@/utils/assertNever";
5
+ import { copyToClipboard } from "@/utils/copy";
6
+ import {
7
+ parseLocalISODate,
8
+ parseLocalISODateTime,
9
+ parseTimeToDate,
10
+ } from "@/utils/dates";
11
+ import { Logger } from "@/utils/Logger";
3
12
  import type { Cell } from "@tanstack/react-table";
4
13
  import { useAtomValue } from "jotai";
5
14
  import { CopyIcon, FilterIcon, SquareStack } from "lucide-react";
6
15
  import type { RefObject } from "react";
7
16
  import useEvent from "react-use-event-hook";
8
- import { copyToClipboard } from "@/utils/copy";
9
- import { Logger } from "@/utils/Logger";
10
17
  import {
11
18
  ContextMenu,
12
19
  ContextMenuContent,
@@ -16,13 +23,94 @@ import {
16
23
  ContextMenuTrigger,
17
24
  } from "../ui/context-menu";
18
25
  import { DATA_CELL_ID } from "./cell-utils";
19
- import {
20
- Filter,
21
- isMembershipFilterType,
22
- type MembershipFilterType,
23
- } from "./filters";
26
+ import { type FilterType, Filter } from "./filters";
24
27
  import { selectedCellsAtom } from "./range-focus/atoms";
25
- import { getClipboardContent, getRawValue } from "./utils";
28
+ import { getClipboardContent, getRawValue, isNullishSentinel } from "./utils";
29
+
30
+ type Comparison = "eq" | "neq";
31
+
32
+ const symbolOp = {
33
+ eq: "==",
34
+ neq: "!=",
35
+ } as const;
36
+
37
+ const textOp = {
38
+ eq: "equals",
39
+ neq: "does_not_equal",
40
+ } as const;
41
+
42
+ export function buildCellFilter({
43
+ type,
44
+ value,
45
+ comparison = "eq",
46
+ dataType,
47
+ dtype,
48
+ }: {
49
+ type: FilterType;
50
+ value: unknown;
51
+ comparison?: Comparison;
52
+ dataType?: DataType;
53
+ dtype?: string;
54
+ }) {
55
+ if (isNullishSentinel(value, dataType, dtype)) {
56
+ return { type, operator: comparison === "eq" ? "is_null" : "is_not_null" };
57
+ }
58
+
59
+ switch (type) {
60
+ case "number":
61
+ if (typeof value !== "number") {
62
+ return undefined;
63
+ }
64
+
65
+ return Filter.number({
66
+ operator: symbolOp[comparison],
67
+ value,
68
+ });
69
+ case "text":
70
+ return Filter.text({
71
+ operator: textOp[comparison],
72
+ text: value as string,
73
+ });
74
+ case "date": {
75
+ const date = parseLocalISODate(value as string);
76
+ if (!date) {
77
+ return undefined;
78
+ }
79
+ return Filter.date({
80
+ operator: symbolOp[comparison],
81
+ value: date,
82
+ });
83
+ }
84
+ case "datetime": {
85
+ const date = parseLocalISODateTime(value as string);
86
+ if (!date) {
87
+ return undefined;
88
+ }
89
+ return Filter.datetime({
90
+ operator: symbolOp[comparison],
91
+ value: date,
92
+ });
93
+ }
94
+ case "time": {
95
+ const time = parseTimeToDate(value as string);
96
+ if (!time) {
97
+ return undefined;
98
+ }
99
+ return Filter.time({
100
+ operator: symbolOp[comparison],
101
+ value: time,
102
+ });
103
+ }
104
+ case "boolean": {
105
+ const target = comparison === "eq" ? value : !(value as boolean);
106
+ return Filter.boolean({
107
+ operator: target ? "is_true" : "is_false",
108
+ });
109
+ }
110
+ default:
111
+ assertNever(type);
112
+ }
113
+ }
26
114
 
27
115
  export const DataTableContextMenu = <TData,>({
28
116
  contextMenuRef,
@@ -102,19 +190,34 @@ export const CellContextMenu = <TData,>({
102
190
 
103
191
  const column = cell.column;
104
192
  const filterType = column.columnDef.meta?.filterType;
105
- const membershipFilterType: MembershipFilterType | undefined =
106
- column.getCanFilter() && filterType && isMembershipFilterType(filterType)
107
- ? filterType
193
+ const dataType = column.columnDef.meta?.dataType;
194
+ const dtype = column.columnDef.meta?.dtype;
195
+
196
+ const canFilter = Boolean(filterType && column.getCanFilter());
197
+ const buildFilter = (comparison: Comparison) =>
198
+ canFilter
199
+ ? buildCellFilter({
200
+ type: filterType as FilterType,
201
+ value: rawValue,
202
+ comparison,
203
+ dataType,
204
+ dtype,
205
+ })
108
206
  : undefined;
207
+ const eqFilter = buildFilter("eq");
208
+ const canFilterByValue = eqFilter !== undefined;
209
+
210
+ const handleFilterCellByValue = (comparison: Comparison) => {
211
+ if (!filterType) {
212
+ return;
213
+ }
214
+
215
+ const filter = comparison === "eq" ? eqFilter : buildFilter("neq");
216
+
217
+ if (!filter) {
218
+ return;
219
+ }
109
220
 
110
- const handleFilterCell = (
111
- type: MembershipFilterType,
112
- operator: "in" | "not_in",
113
- ) => {
114
- const filter =
115
- type === "number"
116
- ? Filter.number({ operator, values: [rawValue] })
117
- : Filter.text({ operator, values: [rawValue] });
118
221
  column.setFilterValue(filter);
119
222
  };
120
223
 
@@ -130,18 +233,14 @@ export const CellContextMenu = <TData,>({
130
233
  Copy selected cells
131
234
  </ContextMenuItem>
132
235
  )}
133
- {membershipFilterType && (
236
+ {canFilterByValue && (
134
237
  <>
135
238
  <ContextMenuSeparator />
136
- <ContextMenuItem
137
- onClick={() => handleFilterCell(membershipFilterType, "in")}
138
- >
239
+ <ContextMenuItem onClick={() => handleFilterCellByValue("eq")}>
139
240
  <FilterIcon className="mo-dropdown-icon h-3 w-3" />
140
241
  Filter by this value
141
242
  </ContextMenuItem>
142
- <ContextMenuItem
143
- onClick={() => handleFilterCell(membershipFilterType, "not_in")}
144
- >
243
+ <ContextMenuItem onClick={() => handleFilterCellByValue("neq")}>
145
244
  <FilterIcon className="mo-dropdown-icon h-3 w-3" />
146
245
  Remove rows with this value
147
246
  </ContextMenuItem>
@@ -14,6 +14,9 @@ import {
14
14
  dateToLocalISODate,
15
15
  dateToLocalISODateTime,
16
16
  dateToLocalISOTime,
17
+ parseLocalISODate,
18
+ parseLocalISODateTime,
19
+ parseTimeToDate,
17
20
  } from "@/utils/dates";
18
21
  import type { FilterType } from "./filters";
19
22
 
@@ -72,53 +75,23 @@ export function parsePastedDate(
72
75
  return undefined;
73
76
  }
74
77
 
75
- const timeMatch =
76
- filterType === "time"
77
- ? trimmed.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(am|pm)?$/i)
78
- : null;
79
- if (timeMatch) {
80
- const [, hStr, mStr, sStr, ampm] = timeMatch;
81
- let hour = Number.parseInt(hStr, 10);
82
- const minute = Number.parseInt(mStr, 10);
83
- const second = sStr ? Number.parseInt(sStr, 10) : 0;
84
- if (ampm) {
85
- const isPm = ampm.toLowerCase() === "pm";
86
- if (hour === 12) {
87
- hour = isPm ? 12 : 0;
88
- } else if (isPm) {
89
- hour += 12;
90
- }
78
+ if (filterType === "time") {
79
+ const date = parseTimeToDate(trimmed);
80
+ if (date) {
81
+ return date;
91
82
  }
92
- return new Date(1970, 0, 1, hour, minute, second);
93
83
  }
94
84
 
95
- const dateOnlyMatch = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})$/);
96
- if (dateOnlyMatch) {
97
- const [, y, m, d] = dateOnlyMatch;
98
- return new Date(
99
- Number.parseInt(y, 10),
100
- Number.parseInt(m, 10) - 1,
101
- Number.parseInt(d, 10),
102
- );
85
+ if (filterType === "date") {
86
+ const date = parseLocalISODate(trimmed);
87
+ if (date) {
88
+ return date;
89
+ }
103
90
  }
104
91
 
105
- // Parse ISO datetimes as wall-clock to stay consistent with the picker's
106
- // local-time basis. Trailing `Z` or offsets are stripped so that pasting
107
- // `2024-01-15T08:30:00Z` displays `08:30` instead of being shifted by the
108
- // viewer's timezone.
109
- const isoMatch = trimmed.match(
110
- /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?$/,
111
- );
112
- if (isoMatch) {
113
- const [, y, mo, d, h, mi, s] = isoMatch;
114
- return new Date(
115
- Number.parseInt(y, 10),
116
- Number.parseInt(mo, 10) - 1,
117
- Number.parseInt(d, 10),
118
- Number.parseInt(h, 10),
119
- Number.parseInt(mi, 10),
120
- s ? Number.parseInt(s, 10) : 0,
121
- );
92
+ const isoDateTime = parseLocalISODateTime(trimmed);
93
+ if (isoDateTime) {
94
+ return isoDateTime;
122
95
  }
123
96
 
124
97
  const parsed = new Date(trimmed);
@@ -228,6 +228,24 @@ export function detectSentinel(
228
228
  return null;
229
229
  }
230
230
 
231
+ type NullishSentinelType = Extract<CellValueSentinel["type"], "null" | "nat">;
232
+
233
+ const NULLISH_SENTINEL_TYPES: ReadonlySet<CellValueSentinel["type"]> =
234
+ new Set<NullishSentinelType>(["null", "nat"]);
235
+
236
+ /**
237
+ * True when the cell value represents a missing value (null, NaT)
238
+ * rather than a real value that happens to look special.
239
+ */
240
+ export function isNullishSentinel(
241
+ value: unknown,
242
+ dataType: DataType | undefined,
243
+ dtype?: string,
244
+ ): boolean {
245
+ const sentinel = detectSentinel(value, dataType, dtype);
246
+ return sentinel !== null && NULLISH_SENTINEL_TYPES.has(sentinel.type);
247
+ }
248
+
231
249
  /**
232
250
  * Stringify an unknown value. Converts objects to JSON strings.
233
251
  * @param opts.value - The value to stringify.
@@ -66,7 +66,7 @@ export type OnRefactorWithAI = (opts: {
66
66
  * Renders an output based on an OutputMessage.
67
67
  */
68
68
  export const OutputRenderer: React.FC<{
69
- message: Pick<OutputMessage, "channel" | "data" | "mimetype">;
69
+ message: Pick<OutputMessage, "channel" | "data" | "mimetype" | "code">;
70
70
  cellId?: CellId;
71
71
  onRefactorWithAI?: OnRefactorWithAI;
72
72
  wrapText?: boolean;
@@ -172,7 +172,9 @@ export const OutputRenderer: React.FC<{
172
172
 
173
173
  case "application/vnd.marimo+error":
174
174
  invariant(Array.isArray(data), "Expected array data");
175
- return <MarimoErrorOutput cellId={cellId} errors={data} />;
175
+ return (
176
+ <MarimoErrorOutput cellId={cellId} errors={data} code={message.code} />
177
+ );
176
178
 
177
179
  case "application/vnd.marimo+traceback":
178
180
  invariant(
@@ -183,6 +185,7 @@ export const OutputRenderer: React.FC<{
183
185
  <MarimoTracebackOutput
184
186
  onRefactorWithAI={onRefactorWithAI}
185
187
  traceback={data}
188
+ code={message.code}
186
189
  cellId={cellId}
187
190
  />
188
191
  );
@@ -25,6 +25,7 @@ const ErrorsPanel: React.FC = () => {
25
25
  <MarimoErrorOutput
26
26
  key={error.cellId}
27
27
  errors={error.output.data}
28
+ code={error.output.code}
28
29
  cellId={error.cellId}
29
30
  />
30
31
  </div>
@@ -0,0 +1,167 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ // @vitest-environment jsdom
3
+
4
+ import { act, fireEvent, render, screen } from "@testing-library/react";
5
+ import { createStore, Provider } from "jotai";
6
+ import { userEvent } from "storybook/test";
7
+ import { describe, expect, it } from "vitest";
8
+ import { PAIR_PREVIEW } from "@/__tests__/fixtures/pair-preview";
9
+ import { TooltipProvider } from "@/components/ui/tooltip";
10
+ import { type Attachment, attachmentsAtom } from "@/core/attachments/state";
11
+ import { pairPreviewAtom } from "@/core/config/pair";
12
+ import { connectionAtom } from "@/core/network/connection";
13
+ import { WebSocketClosedReason, ConnectionState } from "@/core/websocket/types";
14
+ import { AttachmentStatus } from "../footer-items/attachment-status";
15
+
16
+ const AGENT: Attachment = {
17
+ id: "a1",
18
+ kind: "agent",
19
+ name: "Pi",
20
+ notebook_id: "nb-test",
21
+ identity: "pi-thread",
22
+ since: new Date().toISOString(),
23
+ };
24
+
25
+ function renderStatus(attachments: Attachment[] = [AGENT], preview = true) {
26
+ const store = createStore();
27
+ store.set(connectionAtom, { state: ConnectionState.OPEN });
28
+ store.set(pairPreviewAtom, preview ? PAIR_PREVIEW : undefined);
29
+ store.set(attachmentsAtom, attachments);
30
+ return {
31
+ store,
32
+ ...render(
33
+ <Provider store={store}>
34
+ <TooltipProvider>
35
+ <AttachmentStatus />
36
+ </TooltipProvider>
37
+ </Provider>,
38
+ ),
39
+ };
40
+ }
41
+
42
+ describe("AttachmentStatus", () => {
43
+ it("renders nothing outside the preview", () => {
44
+ expect(renderStatus([AGENT], false).container).toBeEmptyDOMElement();
45
+ });
46
+
47
+ it("renders nothing without agents", () => {
48
+ expect(
49
+ renderStatus([{ ...AGENT, kind: "client" }]).container,
50
+ ).toBeEmptyDOMElement();
51
+ });
52
+
53
+ it("lists each agent by its self-described name", () => {
54
+ renderStatus([
55
+ AGENT,
56
+ { ...AGENT, id: "a2", identity: "claude-thread", name: "Claude Code" },
57
+ { ...AGENT, id: "c1", kind: "client", name: "Browser" },
58
+ ]);
59
+ expect(screen.getAllByTestId("footer-attachment-status")).toHaveLength(2);
60
+ expect(screen.getByLabelText("Pi: Connected")).toBeInTheDocument();
61
+ expect(screen.getByLabelText("Claude Code: Connected")).toBeInTheDocument();
62
+ expect(screen.queryByText("Browser")).not.toBeInTheDocument();
63
+ fireEvent.click(screen.getByLabelText("Pi: Connected"));
64
+ expect(screen.getByText(/^Connected /)).toBeInTheDocument();
65
+ });
66
+
67
+ it("uses Agent when no name was supplied", () => {
68
+ renderStatus([{ ...AGENT, name: null }]);
69
+ expect(screen.getByLabelText("Agent: Connected")).toBeInTheDocument();
70
+ });
71
+
72
+ it("groups attachments by identity while retaining each connection row", () => {
73
+ renderStatus([
74
+ AGENT,
75
+ { ...AGENT, id: "a2", since: "2026-10-14T00:00:00Z" },
76
+ ]);
77
+ expect(screen.getAllByTestId("footer-attachment-status")).toHaveLength(1);
78
+ fireEvent.click(screen.getByLabelText("Pi: Connected"));
79
+ expect(screen.getAllByText(/^Connected /)).toHaveLength(2);
80
+ expect(screen.getByText("a1")).toBeInTheDocument();
81
+ expect(screen.getByText("a2")).toBeInTheDocument();
82
+ });
83
+
84
+ it("preserves an open popover when the same identity reconnects", () => {
85
+ const { store } = renderStatus();
86
+ fireEvent.click(screen.getByLabelText("Pi: Connected"));
87
+ act(() =>
88
+ store.set(attachmentsAtom, [
89
+ { ...AGENT, id: "a2", since: "2026-10-14T00:00:00Z" },
90
+ ]),
91
+ );
92
+ expect(
93
+ screen.getByRole("button", { name: "Pi: Connected" }),
94
+ ).toHaveAttribute("aria-expanded", "true");
95
+ expect(screen.queryByText("a1")).not.toBeInTheDocument();
96
+ expect(screen.getByText("a2")).toBeInTheDocument();
97
+ });
98
+
99
+ it("does not open a closed popover when the same identity reconnects", () => {
100
+ const { store } = renderStatus();
101
+ act(() => store.set(attachmentsAtom, [{ ...AGENT, id: "a2" }]));
102
+ expect(
103
+ screen.getByRole("button", { name: "Pi: Connected" }),
104
+ ).toHaveAttribute("aria-expanded", "false");
105
+ });
106
+
107
+ it("keeps anonymous attachments separate from matching client identities", () => {
108
+ renderStatus([
109
+ { ...AGENT, identity: undefined },
110
+ { ...AGENT, id: "a2", identity: undefined },
111
+ { ...AGENT, id: "a3", identity: "a1" },
112
+ ]);
113
+ expect(screen.getAllByTestId("footer-attachment-status")).toHaveLength(3);
114
+ });
115
+
116
+ it("accepts client identities that match object property names", () => {
117
+ renderStatus([
118
+ { ...AGENT, identity: "__proto__" },
119
+ { ...AGENT, id: "a2", identity: "constructor" },
120
+ ]);
121
+ expect(screen.getAllByTestId("footer-attachment-status")).toHaveLength(2);
122
+ });
123
+
124
+ it.each(["{Enter}", " "])("opens from the keyboard with %j", async (key) => {
125
+ const user = userEvent.setup();
126
+ renderStatus();
127
+ const button = screen.getByRole("button", { name: "Pi: Connected" });
128
+ expect(button.tagName).toBe("BUTTON");
129
+ expect(button).toHaveAttribute("type", "button");
130
+ await user.tab();
131
+ expect(button).toHaveFocus();
132
+ await user.keyboard(key);
133
+ expect(button).toHaveAttribute("aria-expanded", "true");
134
+ expect(screen.getByText(/^Connected /)).toBeInTheDocument();
135
+ await user.keyboard("{Escape}");
136
+ expect(button).toHaveAttribute("aria-expanded", "false");
137
+ expect(button).toHaveFocus();
138
+ });
139
+
140
+ it.each([
141
+ { state: ConnectionState.CONNECTING },
142
+ { state: ConnectionState.CLOSING },
143
+ { state: ConnectionState.NOT_STARTED },
144
+ {
145
+ state: ConnectionState.CLOSED,
146
+ code: WebSocketClosedReason.KERNEL_DISCONNECTED,
147
+ reason: "offline",
148
+ },
149
+ ])("hides connected claims while the browser is %j", (connection) => {
150
+ const { store, container } = renderStatus();
151
+ act(() => store.set(connectionAtom, connection));
152
+ expect(container).toBeEmptyDOMElement();
153
+ });
154
+
155
+ it("removes detached agents when the list is replaced", () => {
156
+ const { store, container } = renderStatus([
157
+ AGENT,
158
+ { ...AGENT, id: "a2", identity: "claude-thread", name: "Claude Code" },
159
+ ]);
160
+ act(() => store.set(attachmentsAtom, [AGENT]));
161
+ expect(
162
+ screen.queryByLabelText("Claude Code: Connected"),
163
+ ).not.toBeInTheDocument();
164
+ act(() => store.set(attachmentsAtom, []));
165
+ expect(container).toBeEmptyDOMElement();
166
+ });
167
+ });
@@ -1,6 +1,7 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
3
  import type React from "react";
4
+ import { Slot as SlotPrimitive } from "radix-ui";
4
5
  import { forwardRef } from "react";
5
6
  import { Tooltip } from "@/components/ui/tooltip";
6
7
  import { cn } from "@/utils/cn";
@@ -8,36 +9,43 @@ import { cn } from "@/utils/cn";
8
9
  type FooterItemProps = {
9
10
  selected: boolean;
10
11
  tooltip: React.ReactNode;
12
+ asChild?: boolean;
11
13
  } & React.HTMLAttributes<HTMLDivElement>;
12
14
 
13
15
  export const FooterItem: React.FC<FooterItemProps> = forwardRef<
14
16
  HTMLDivElement,
15
17
  FooterItemProps
16
- >(({ children, tooltip, selected, className, ...rest }, ref) => {
17
- const content = (
18
- <div
19
- ref={ref}
20
- className={cn(
21
- "h-full flex items-center p-2 text-sm shadow-inset font-mono cursor-pointer rounded",
22
- !selected && "hover:bg-(--sage-3)",
23
- selected && "bg-(--sage-4)",
24
- className,
25
- )}
26
- {...rest}
27
- >
28
- {children}
29
- </div>
30
- );
31
-
32
- if (tooltip) {
33
- return (
34
- <Tooltip content={tooltip} side="top" delayDuration={200}>
35
- {content}
36
- </Tooltip>
18
+ >(
19
+ (
20
+ { children, tooltip, selected, className, asChild = false, ...rest },
21
+ ref,
22
+ ) => {
23
+ const Component = asChild ? SlotPrimitive.Slot : "div";
24
+ const content = (
25
+ <Component
26
+ ref={ref}
27
+ className={cn(
28
+ "h-full flex items-center p-2 text-sm shadow-inset font-mono cursor-pointer rounded",
29
+ !selected && "hover:bg-(--sage-3)",
30
+ selected && "bg-(--sage-4)",
31
+ className,
32
+ )}
33
+ {...rest}
34
+ >
35
+ {children}
36
+ </Component>
37
37
  );
38
- }
39
38
 
40
- return content;
41
- });
39
+ if (tooltip) {
40
+ return (
41
+ <Tooltip content={tooltip} side="top" delayDuration={200}>
42
+ {content}
43
+ </Tooltip>
44
+ );
45
+ }
46
+
47
+ return content;
48
+ },
49
+ );
42
50
 
43
51
  FooterItem.displayName = "FooterItem";
@@ -0,0 +1,82 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { useAtomValue } from "jotai";
4
+ import { BotIcon } from "lucide-react";
5
+ import { useState } from "react";
6
+ import {
7
+ Popover,
8
+ PopoverContent,
9
+ PopoverTrigger,
10
+ } from "@/components/ui/popover";
11
+ import {
12
+ type Attachment,
13
+ agentAttachmentsAtom,
14
+ attachmentLabel,
15
+ } from "@/core/attachments/state";
16
+ import { pairPreviewAtom } from "@/core/config/pair";
17
+ import { useTimeAgo } from "@/hooks/useFormatting";
18
+ import { Objects } from "@/utils/objects";
19
+ import { FooterItem } from "../footer-item";
20
+
21
+ export function AttachmentStatus() {
22
+ const pairPreview = useAtomValue(pairPreviewAtom);
23
+ const agents = useAtomValue(agentAttachmentsAtom);
24
+ if (!pairPreview) {
25
+ return null;
26
+ }
27
+ const groups = Objects.groupBy(
28
+ agents,
29
+ (attachment) =>
30
+ attachment.identity
31
+ ? `identity:${attachment.identity}`
32
+ : `attachment:${attachment.id}`,
33
+ (attachment) => attachment,
34
+ );
35
+ return Object.entries(groups).map(([identity, attachments]) => (
36
+ <AttachmentStatusItem key={identity} attachments={attachments} />
37
+ ));
38
+ }
39
+
40
+ function AttachmentStatusItem({ attachments }: { attachments: Attachment[] }) {
41
+ const [open, setOpen] = useState(false);
42
+ const timeAgo = useTimeAgo();
43
+ const label = attachmentLabel(attachments[0]);
44
+ return (
45
+ <Popover open={open} onOpenChange={setOpen}>
46
+ <PopoverTrigger asChild={true}>
47
+ <FooterItem
48
+ asChild={true}
49
+ tooltip={`${label}: connected`}
50
+ selected={open}
51
+ data-testid="footer-attachment-status"
52
+ aria-label={`${label}: Connected`}
53
+ >
54
+ <button type="button">
55
+ <span className="flex items-center gap-1.5">
56
+ <BotIcon className="w-4 h-4" />
57
+ <span className="max-w-48 truncate">{label}</span>
58
+ </span>
59
+ </button>
60
+ </FooterItem>
61
+ </PopoverTrigger>
62
+ <PopoverContent className="w-64 text-sm">
63
+ <h4 className="font-medium">{label}</h4>
64
+ <ul className="space-y-2">
65
+ {attachments.map((attachment) => (
66
+ <li key={attachment.id}>
67
+ <p
68
+ className="truncate text-xs text-muted-foreground"
69
+ title={attachment.id}
70
+ >
71
+ {attachment.id}
72
+ </p>
73
+ <p className="text-muted-foreground">
74
+ Connected {timeAgo(attachment.since)}
75
+ </p>
76
+ </li>
77
+ ))}
78
+ </ul>
79
+ </PopoverContent>
80
+ </Popover>
81
+ );
82
+ }