@marimo-team/islands 0.25.3-dev129 → 0.25.3-dev134

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 (99) hide show
  1. package/dist/{ConnectedDataExplorerComponent-q0C1F7Sn.js → ConnectedDataExplorerComponent-BAiSVaHj.js} +3 -1
  2. package/dist/{ErrorBoundary-BwvkPkyC.js → ErrorBoundary-CImYa86e.js} +1 -1
  3. package/dist/{chat-ui-C0nJ84Wf.js → chat-ui-DqcauBr6.js} +21 -20
  4. package/dist/{code-visibility-BI1HUhjs.js → code-visibility-BZoWT5f1.js} +2 -2
  5. package/dist/{common-CMm0Ot4s.js → common-CGmIoGK3.js} +4383 -4327
  6. package/dist/{data-grid-overlay-editor-DMLTMI_S.js → data-grid-overlay-editor-CtOna148.js} +1 -1
  7. package/dist/{error-entries-BL2ZBgVo.js → error-entries-CmFDQ9CA.js} +109 -105
  8. package/dist/{feedback-button-BrpQxIbi.js → feedback-button-7ZbDdjfz.js} +14 -14
  9. package/dist/{glide-data-editor-DBsCdnOC.js → glide-data-editor-D72zmCan.js} +3 -3
  10. package/dist/{globals-BWb8HdpC.js → globals-DE2S2io9.js} +1 -1
  11. package/dist/{html-to-image-D9gz793p.js → html-to-image-CfgsWObF.js} +19 -19
  12. package/dist/main.js +5504 -5477
  13. package/dist/{number-overlay-editor-CUARNHHp.js → number-overlay-editor-Br0lPnFE.js} +1 -1
  14. package/dist/{process-output-761ZoDH1.js → process-output-B64ZYWuI.js} +3 -3
  15. package/dist/{reveal-component-CJjh6lrq.js → reveal-component-DnbPU885.js} +12 -12
  16. package/dist/style.css +1 -1
  17. package/package.json +1 -1
  18. package/src/__tests__/module-entry.test.ts +32 -0
  19. package/src/components/ai/__tests__/ai-model-dropdown.test.tsx +63 -0
  20. package/src/components/ai/ai-model-dropdown.tsx +11 -6
  21. package/src/components/chat/acp/__tests__/session-tabs.test.tsx +43 -0
  22. package/src/components/chat/acp/blocks.tsx +1 -0
  23. package/src/components/chat/acp/session-tabs.tsx +8 -3
  24. package/src/components/data-table/TableTopBar.tsx +2 -0
  25. package/src/components/data-table/__tests__/data-table.test.tsx +1 -0
  26. package/src/components/data-table/__tests__/keyboard-selection.test.tsx +188 -0
  27. package/src/components/data-table/data-table.tsx +6 -0
  28. package/src/components/data-table/range-focus/atoms.ts +4 -6
  29. package/src/components/data-table/range-focus/use-cell-range-selection.ts +65 -38
  30. package/src/components/data-table/range-focus/use-scroll-into-view.ts +21 -2
  31. package/src/components/data-table/renderers.tsx +16 -7
  32. package/src/components/data-table/row-viewer-panel/__tests__/row-viewer.test.tsx +24 -1
  33. package/src/components/data-table/row-viewer-panel/row-viewer.tsx +2 -2
  34. package/src/components/datasources/datasources.tsx +1 -0
  35. package/src/components/editor/__tests__/package-alert.test.tsx +22 -1
  36. package/src/components/editor/cell/collapse.tsx +5 -3
  37. package/src/components/editor/chrome/panels/outline/__tests__/outline-keyboard.test.tsx +84 -0
  38. package/src/components/editor/chrome/panels/outline/floating-outline.tsx +39 -14
  39. package/src/components/editor/chrome/panels/outline/useActiveOutline.tsx +3 -2
  40. package/src/components/editor/chrome/panels/secrets-panel.tsx +1 -0
  41. package/src/components/editor/chrome/panels/snippets-panel.tsx +5 -1
  42. package/src/components/editor/chrome/wrapper/__tests__/attachment-status.test.tsx +4 -4
  43. package/src/components/editor/controls/__tests__/keyboard-shortcuts.test.tsx +61 -0
  44. package/src/components/editor/controls/keyboard-shortcuts.tsx +14 -5
  45. package/src/components/editor/errors/__tests__/send-error-report-button.test.tsx +4 -4
  46. package/src/components/editor/file-tree/__tests__/file-explorer-navigation.test.tsx +47 -0
  47. package/src/components/editor/file-tree/file-explorer.tsx +35 -3
  48. package/src/components/editor/file-tree/file-name-input.tsx +1 -0
  49. package/src/components/editor/header/status.tsx +28 -5
  50. package/src/components/editor/links/__tests__/cell-link.test.tsx +146 -0
  51. package/src/components/editor/links/cell-link.tsx +14 -5
  52. package/src/components/editor/navigation/focus-utils.ts +4 -4
  53. package/src/components/editor/notebook-cell.tsx +11 -2
  54. package/src/components/editor/output/MarimoTracebackOutput.tsx +17 -15
  55. package/src/components/editor/output/__tests__/traceback.test.tsx +27 -1
  56. package/src/components/editor/package-alert.tsx +8 -3
  57. package/src/components/scratchpad/scratchpad.test.tsx +95 -0
  58. package/src/components/scratchpad/scratchpad.tsx +14 -1
  59. package/src/components/tracing/tracing-keyboard.test.tsx +85 -0
  60. package/src/components/tracing/tracing.tsx +43 -39
  61. package/src/components/ui/__tests__/draggable-popover.test.tsx +24 -0
  62. package/src/components/ui/draggable-popover.tsx +24 -2
  63. package/src/core/attachments/state.ts +2 -2
  64. package/src/core/document/__tests__/codemirror.test.ts +3 -1
  65. package/src/core/document/__tests__/hooks.test.tsx +7 -7
  66. package/src/core/document/__tests__/model.test.ts +44 -11
  67. package/src/core/document/__tests__/scenario-fixture.ts +2 -1
  68. package/src/core/document/__tests__/test-notebook.ts +19 -9
  69. package/src/core/document/draft.ts +1 -1
  70. package/src/core/document/index.ts +3 -10
  71. package/src/core/document/model.ts +14 -11
  72. package/src/core/document/state.ts +1 -1
  73. package/src/core/document/sync.ts +24 -18
  74. package/src/core/host/AGENTS.md +3 -7
  75. package/src/core/host/__tests__/fixtures.ts +30 -0
  76. package/src/core/host/__tests__/{notebook-client.test.ts → replica.test.ts} +42 -74
  77. package/src/core/host/__tests__/wasm.test.ts +8 -0
  78. package/src/core/host/backend.ts +4 -0
  79. package/src/core/host/index.ts +14 -0
  80. package/src/core/host/{notebook-client.ts → replica.ts} +75 -86
  81. package/src/core/host/static.ts +2 -0
  82. package/src/core/host/wasm.ts +10 -1
  83. package/src/core/wasm/bridge.ts +1 -1
  84. package/src/core/websocket/__tests__/backend.test.ts +111 -22
  85. package/src/core/websocket/__tests__/useNotebookReplica.hook.test.tsx +141 -0
  86. package/src/core/websocket/__tests__/useNotebookSession.hook.test.tsx +4 -4
  87. package/src/core/websocket/backend.ts +43 -5
  88. package/src/core/websocket/connection-state.ts +1 -1
  89. package/src/core/websocket/state.ts +3 -3
  90. package/src/core/websocket/transports/__tests__/notebook.test.ts +11 -21
  91. package/src/core/websocket/types.ts +2 -2
  92. package/src/core/websocket/useNotebookReplica.ts +69 -0
  93. package/src/core/websocket/useNotebookSession.tsx +6 -6
  94. package/src/plugins/impl/chat/chat-ui.tsx +2 -1
  95. package/src/plugins/impl/data-explorer/components/column-summary.tsx +4 -2
  96. package/src/plugins/impl/data-frames/__tests__/sidebar.test.tsx +160 -0
  97. package/src/plugins/impl/data-frames/panel.tsx +43 -16
  98. package/src/core/websocket/__tests__/useNotebookClient.hook.test.tsx +0 -228
  99. package/src/core/websocket/useNotebookClient.ts +0 -89
@@ -0,0 +1,160 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { act, fireEvent, render, screen } from "@testing-library/react";
4
+ import { useState } from "react";
5
+ import { expect, it, vi } from "vitest";
6
+ import { TooltipProvider } from "@/components/ui/tooltip";
7
+ import { Sidebar, TransformPanel } from "../panel";
8
+ import { type TransformType, TransformTypeSchema } from "../schema";
9
+
10
+ const transform: TransformType = { type: "select_columns", column_ids: [] };
11
+
12
+ it("selects and deletes transforms through independent buttons", () => {
13
+ const onSelect = vi.fn();
14
+ const onDelete = vi.fn();
15
+ render(
16
+ <Sidebar
17
+ items={[{ id: "first", transform }]}
18
+ selected={0}
19
+ onSelect={onSelect}
20
+ onDelete={onDelete}
21
+ onAdd={vi.fn()}
22
+ />,
23
+ );
24
+ const select = screen.getByRole("button", { name: "Select Columns" });
25
+ expect(select).toHaveAttribute("aria-pressed", "true");
26
+ fireEvent.click(select);
27
+ expect(onSelect).toHaveBeenCalledExactlyOnceWith(0);
28
+ const remove = screen.getByRole("button", {
29
+ name: "Delete Select Columns transform 1",
30
+ });
31
+ remove.focus();
32
+ expect(remove).toHaveFocus();
33
+ fireEvent.click(remove);
34
+ expect(onDelete).toHaveBeenCalledExactlyOnceWith(0);
35
+ expect(onSelect).toHaveBeenCalledOnce();
36
+ });
37
+
38
+ it("preserves focused rows across value edits and removal of earlier duplicates", () => {
39
+ const first = { id: "first", transform };
40
+ const second = { id: "second", transform };
41
+ const props = {
42
+ selected: 1,
43
+ onSelect: vi.fn(),
44
+ onDelete: vi.fn(),
45
+ onAdd: vi.fn(),
46
+ };
47
+ const { rerender } = render(<Sidebar {...props} items={[first, second]} />);
48
+ const focused = screen.getAllByRole("button", { name: "Select Columns" })[1];
49
+ act(() => focused.focus());
50
+ const edited = {
51
+ ...second,
52
+ transform: TransformTypeSchema.parse({
53
+ ...transform,
54
+ column_ids: ["name"],
55
+ }),
56
+ };
57
+ rerender(<Sidebar {...props} items={[first, edited]} />);
58
+ expect(screen.getAllByRole("button", { name: "Select Columns" })[1]).toBe(
59
+ focused,
60
+ );
61
+ expect(focused).toHaveFocus();
62
+
63
+ rerender(<Sidebar {...props} items={[edited]} selected={0} />);
64
+ expect(screen.getByRole("button", { name: "Select Columns" })).toBe(focused);
65
+ expect(focused).toHaveFocus();
66
+ fireEvent.click(focused);
67
+ expect(props.onSelect).toHaveBeenLastCalledWith(0);
68
+ });
69
+
70
+ it.each([
71
+ { count: 3, removed: 0, destination: 1 },
72
+ { count: 3, removed: 1, destination: 0 },
73
+ { count: 3, removed: 2, destination: 1 },
74
+ { count: 1, removed: 0, destination: undefined },
75
+ ])(
76
+ "moves focus after deleting row $removed of $count",
77
+ ({ count, removed, destination }) => {
78
+ function Example() {
79
+ const [items, setItems] = useState(
80
+ Array.from({ length: count }, (_, index) => ({
81
+ id: `transform-${index}`,
82
+ transform,
83
+ })),
84
+ );
85
+ return (
86
+ <Sidebar
87
+ items={items}
88
+ selected={undefined}
89
+ onSelect={vi.fn()}
90
+ onDelete={(index) =>
91
+ setItems((current) => current.filter((_, i) => i !== index))
92
+ }
93
+ onAdd={vi.fn()}
94
+ />
95
+ );
96
+ }
97
+ render(<Example />);
98
+ const selectButtons = screen.getAllByRole("button", {
99
+ name: "Select Columns",
100
+ });
101
+ const target =
102
+ destination === undefined
103
+ ? screen.getByRole("button", { name: "Add" })
104
+ : selectButtons[destination];
105
+ const remove = screen.getByRole("button", {
106
+ name: `Delete Select Columns transform ${removed + 1}`,
107
+ });
108
+ act(() => remove.focus());
109
+ fireEvent.click(remove, { detail: 0 });
110
+ expect(remove).not.toBeInTheDocument();
111
+ expect(target).toHaveFocus();
112
+ },
113
+ );
114
+
115
+ it("uses persistent field-array IDs in the transformation panel", () => {
116
+ render(
117
+ <TooltipProvider>
118
+ <TransformPanel
119
+ initialValue={{ transforms: [transform, transform] }}
120
+ columns={new Map()}
121
+ lazy={true}
122
+ onChange={vi.fn()}
123
+ onInvalidChange={vi.fn()}
124
+ getColumnValues={async () => ({ values: [], too_many_values: false })}
125
+ />
126
+ </TooltipProvider>,
127
+ );
128
+ const survivor = screen.getAllByRole("button", { name: "Select Columns" })[1];
129
+ const remove = screen.getByRole("button", {
130
+ name: "Delete Select Columns transform 1",
131
+ });
132
+ act(() => remove.focus());
133
+ fireEvent.click(remove, { detail: 0 });
134
+ expect(screen.getByRole("button", { name: "Select Columns" })).toBe(survivor);
135
+ expect(survivor).toHaveFocus();
136
+ expect(survivor).toHaveAttribute("aria-pressed", "true");
137
+ });
138
+
139
+ it("does not move focus when deletion starts outside the focused row", () => {
140
+ render(
141
+ <>
142
+ <input aria-label="Transform setting" />
143
+ <Sidebar
144
+ items={[{ id: "first", transform }]}
145
+ selected={0}
146
+ onSelect={vi.fn()}
147
+ onDelete={vi.fn()}
148
+ onAdd={vi.fn()}
149
+ />
150
+ </>,
151
+ );
152
+ const input = screen.getByRole("textbox", { name: "Transform setting" });
153
+ act(() => input.focus());
154
+ fireEvent.click(
155
+ screen.getByRole("button", {
156
+ name: "Delete Select Columns transform 1",
157
+ }),
158
+ );
159
+ expect(input).toHaveFocus();
160
+ });
@@ -25,6 +25,7 @@ import React, {
25
25
  useEffect,
26
26
  useImperativeHandle,
27
27
  useMemo,
28
+ useRef,
28
29
  } from "react";
29
30
  import { useFieldArray, useForm } from "react-hook-form";
30
31
  import useEvent from "react-use-event-hook";
@@ -187,7 +188,10 @@ export const TransformPanel: React.FC<Props> = ({
187
188
  className="relative flex flex-row max-h-[400px] overflow-hidden bg-background"
188
189
  >
189
190
  <Sidebar
190
- items={form.watch("transforms")}
191
+ items={transformsField.fields.map((field, index) => ({
192
+ id: field.id,
193
+ transform: transforms[index],
194
+ }))}
191
195
  selected={selectedTransform}
192
196
  onSelect={(index) => {
193
197
  setSelectedTransform(index);
@@ -274,7 +278,7 @@ export const TransformPanel: React.FC<Props> = ({
274
278
  };
275
279
 
276
280
  interface SidebarProps {
277
- items: TransformType[];
281
+ items: { id: string; transform: TransformType }[];
278
282
  selected: number | undefined;
279
283
  onSelect: (index: number) => void;
280
284
  onDelete: (index: number) => void;
@@ -288,34 +292,56 @@ export const Sidebar: React.FC<SidebarProps> = ({
288
292
  onSelect,
289
293
  onDelete,
290
294
  }) => {
295
+ const selectionButtons = useRef(new Map<string, HTMLButtonElement>());
296
+ const addButton = useRef<HTMLButtonElement>(null);
297
+
291
298
  return (
292
299
  <div className="flex flex-col overflow-y-hidden w-[180px] shadow-xs h-full">
293
300
  <div className="flex flex-col overflow-y-auto grow">
294
- {items.map((item, idx) => {
301
+ {items.map(({ id, transform }, idx) => {
295
302
  return (
296
303
  <div
297
- key={`${JSON.stringify(item)}-${idx}`}
298
- onClick={() => {
299
- onSelect(idx);
300
- }}
304
+ key={id}
301
305
  className={cn(
302
- "flex flex-row min-h-[40px] items-center px-2 cursor-pointer hover:bg-accent/50 text-sm overflow-hidden hover-actions-parent border border-muted border-l-2 border-l-transparent",
306
+ "group flex flex-row min-h-[40px] items-center px-2 cursor-pointer hover:bg-accent/50 text-sm overflow-hidden border border-muted border-l-2 border-l-transparent",
303
307
  {
304
308
  "border-l-primary bg-accent text-accent-foreground":
305
309
  selected === idx,
306
310
  },
307
311
  )}
308
312
  >
309
- <div className="grow text-ellipsis">
310
- {Strings.startCase(item.type)}
311
- </div>
312
- <Trash2Icon
313
- className="w-3 h-3 hover-action text-muted-foreground hover:text-destructive"
314
- onClick={(e) => {
313
+ <button
314
+ ref={(button) => {
315
+ if (button) {
316
+ selectionButtons.current.set(id, button);
317
+ } else {
318
+ selectionButtons.current.delete(id);
319
+ }
320
+ }}
321
+ type="button"
322
+ className="grow text-left text-ellipsis py-2 focus-visible:outline focus-visible:outline-ring"
323
+ aria-pressed={selected === idx}
324
+ onClick={() => onSelect(idx)}
325
+ >
326
+ {Strings.startCase(transform.type)}
327
+ </button>
328
+ <button
329
+ type="button"
330
+ aria-label={`Delete ${Strings.startCase(transform.type)} transform ${idx + 1}`}
331
+ className="opacity-0 group-hover:opacity-100 focus-visible:opacity-100 text-muted-foreground hover:text-destructive focus-visible:outline focus-visible:outline-ring"
332
+ onClick={(event) => {
333
+ if (event.currentTarget.matches(":focus")) {
334
+ const neighbor = items[idx - 1] ?? items[idx + 1];
335
+ const target = neighbor
336
+ ? selectionButtons.current.get(neighbor.id)
337
+ : addButton.current;
338
+ target?.focus();
339
+ }
315
340
  onDelete(idx);
316
- e.stopPropagation();
317
341
  }}
318
- />
342
+ >
343
+ <Trash2Icon className="w-3 h-3" />
344
+ </button>
319
345
  </div>
320
346
  );
321
347
  })}
@@ -323,6 +349,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
323
349
  <div className="flex flex-row shrink-0">
324
350
  <AddTransformDropdown onAdd={onAdd}>
325
351
  <Button
352
+ ref={addButton}
326
353
  data-testid="marimo-plugin-data-frames-add-transform"
327
354
  variant="text"
328
355
  className="w-full rounded-none m-0 hover:text-accent-foreground"
@@ -1,228 +0,0 @@
1
- /* Copyright 2026 Marimo. All rights reserved. */
2
- // @vitest-environment jsdom
3
-
4
- import { act, renderHook } from "@testing-library/react";
5
- import { createStore, Provider } from "jotai";
6
- import type { PropsWithChildren } from "react";
7
- import { beforeEach, describe, expect, it, vi } from "vitest";
8
- import type { NotebookTransport } from "@marimo-team/client";
9
- import { ConnectionState } from "../types";
10
- import { Deferred } from "@/utils/Deferred";
11
- import type { NotebookBackend } from "../../host/backend";
12
- import { createEditorBackend } from "../backend";
13
- import { useNotebookClient } from "../useNotebookClient";
14
- import { fixtureMessages } from "../../host/__tests__/fixtures";
15
- import { agentAttachmentsAtom } from "../../attachments/state";
16
- import { notebookClientAtom } from "../state";
17
-
18
- vi.mock("../backend", () => ({ createEditorBackend: vi.fn() }));
19
-
20
- /** A backend that follows `transport` and cannot edit. */
21
- function backend(transport: NotebookTransport): NotebookBackend {
22
- return { transport, notebook: () => ({ edit: vi.fn() }) };
23
- }
24
-
25
- /** A transport whose connections the test drives. */
26
- function makeTransport() {
27
- const opened: {
28
- signal: AbortSignal;
29
- open(): void;
30
- receive(message: unknown): void;
31
- }[] = [];
32
- const transport: NotebookTransport = ({ signal }, handlers) =>
33
- new Promise((resolve) => {
34
- opened.push({ signal, open: handlers.open, receive: handlers.message });
35
- signal.addEventListener("abort", () => resolve(), { once: true });
36
- });
37
- return {
38
- transport,
39
- opened,
40
- /** Open the latest connection and deliver a host-framed notification. */
41
- notify: () =>
42
- opened.at(-1)?.receive({
43
- event: "marimo",
44
- data: { marimo: { op: "reconnected" } },
45
- }),
46
- };
47
- }
48
-
49
- function setup(
50
- strict = false,
51
- readiness: {
52
- waitUntilReady?: () => Promise<void>;
53
- checkHealth?: () => Promise<boolean>;
54
- } = {},
55
- ) {
56
- const store = createStore();
57
- const options = {
58
- static: false,
59
- transportType: "websocket" as const,
60
- url: () => "ws://localhost/ws",
61
- headers: () => ({}),
62
- notebookUrl: (id: string) => new URL(`http://localhost/notebooks/${id}`),
63
- waitUntilReady: async () => undefined,
64
- checkHealth: async () => true,
65
- onNotification: vi.fn(),
66
- ...readiness,
67
- };
68
- const wrapper = ({ children }: PropsWithChildren) => (
69
- <Provider store={store}>{children}</Provider>
70
- );
71
- const hook = renderHook((props) => useNotebookClient(props), {
72
- initialProps: options,
73
- reactStrictMode: strict,
74
- wrapper,
75
- });
76
- return { ...hook, options, store };
77
- }
78
-
79
- beforeEach(() => {
80
- vi.mocked(createEditorBackend).mockReset();
81
- });
82
-
83
- describe("useNotebookClient", () => {
84
- it.each([true, false])(
85
- "rechecks health after failed startup (healthy: %s)",
86
- async (healthy) => {
87
- const fake = makeTransport();
88
- vi.mocked(createEditorBackend).mockImplementation(({ url }) =>
89
- backend(async (attempt, handlers) => {
90
- if (typeof url === "function") {
91
- await url();
92
- }
93
- if (!attempt.signal.aborted) {
94
- return fake.transport(attempt, handlers);
95
- }
96
- }),
97
- );
98
- const waitUntilReady = vi
99
- .fn()
100
- .mockRejectedValue(new Error("Startup gave up"));
101
- const checkHealth = vi.fn().mockResolvedValue(healthy);
102
- const { result, store, unmount } = setup(false, {
103
- waitUntilReady,
104
- checkHealth,
105
- });
106
- await act(async () => Promise.resolve());
107
- expect(store.get(result.current.connectionAtom).state).toBe(
108
- ConnectionState.CLOSED,
109
- );
110
-
111
- await act(async () => result.current.reconnect());
112
- expect({
113
- healthChecks: checkHealth.mock.calls.length,
114
- initialWaits: waitUntilReady.mock.calls.length,
115
- connections: fake.opened.length,
116
- state: store.get(result.current.connectionAtom).state,
117
- }).toEqual({
118
- healthChecks: 1,
119
- initialWaits: 1,
120
- connections: healthy ? 1 : 0,
121
- state: healthy ? ConnectionState.CONNECTING : ConnectionState.CLOSED,
122
- });
123
- act(() => fake.opened.at(-1)?.open());
124
- expect(store.get(result.current.connectionAtom).state).toBe(
125
- healthy ? ConnectionState.OPEN : ConnectionState.CLOSED,
126
- );
127
- unmount();
128
- },
129
- );
130
-
131
- it("waits for initial readiness without reviving an unmounted connection", async () => {
132
- const ready = new Deferred<void>();
133
- const resolvedUrl = vi.fn();
134
- vi.mocked(createEditorBackend).mockImplementation(({ url }) =>
135
- backend(async () => {
136
- if (typeof url === "function") {
137
- await url();
138
- resolvedUrl();
139
- }
140
- }),
141
- );
142
- const { result, store, unmount } = setup(false, {
143
- waitUntilReady: () => ready.promise,
144
- });
145
- await act(async () => Promise.resolve());
146
- expect(resolvedUrl).not.toHaveBeenCalled();
147
- expect(store.get(result.current.connectionAtom)).toEqual({
148
- state: ConnectionState.CONNECTING,
149
- });
150
- unmount();
151
- await act(async () => ready.resolve());
152
- expect(store.get(result.current.connectionAtom)).toEqual({
153
- state: ConnectionState.NOT_STARTED,
154
- });
155
- expect(store.get(notebookClientAtom)).toBeNull();
156
- });
157
-
158
- it("exposes host presence to the UI without waiting for kernel-ready", async () => {
159
- const fake = makeTransport();
160
- vi.mocked(createEditorBackend).mockReturnValue(backend(fake.transport));
161
- const { result, store, unmount } = setup();
162
- await act(async () => Promise.resolve());
163
- expect(store.get(notebookClientAtom)).toBe(result.current);
164
- const [connection] = fake.opened;
165
- act(() => {
166
- connection.open();
167
- fixtureMessages("notebook-events.sse").forEach(connection.receive);
168
- });
169
- expect(store.get(agentAttachmentsAtom)).toEqual([
170
- {
171
- id: "a2",
172
- notebook_id: "n1",
173
- kind: "agent",
174
- name: "Notebook CLI",
175
- since: "2026-10-01T09:20:03Z",
176
- },
177
- ]);
178
- unmount();
179
- expect(store.get(notebookClientAtom)).toBeNull();
180
- expect(store.get(agentAttachmentsAtom)).toEqual([]);
181
- });
182
-
183
- it("uses current callbacks and URL providers without reconnecting on a rerender", async () => {
184
- const fake = makeTransport();
185
- vi.mocked(createEditorBackend).mockReturnValue(backend(fake.transport));
186
- const { rerender, options, unmount } = setup();
187
- await act(async () => Promise.resolve());
188
- const onNotification = vi.fn();
189
- rerender({ ...options, onNotification, url: () => "ws://redirected/ws" });
190
- act(() => fake.notify());
191
- expect(options.onNotification).not.toHaveBeenCalled();
192
- expect(onNotification).toHaveBeenCalledExactlyOnceWith({
193
- data: { op: "reconnected" },
194
- });
195
- expect(createEditorBackend).toHaveBeenCalledOnce();
196
- const [{ url }] = vi.mocked(createEditorBackend).mock.calls[0];
197
- expect(String(await (url as () => Promise<string>)())).toBe(
198
- "ws://redirected/ws",
199
- );
200
- unmount();
201
- });
202
-
203
- it("closes every stream under Strict Mode and only follows the active lifetime", async () => {
204
- const fakes: ReturnType<typeof makeTransport>[] = [];
205
- vi.mocked(createEditorBackend).mockImplementation(() => {
206
- const next = makeTransport();
207
- fakes.push(next);
208
- return backend(next.transport);
209
- });
210
- const { result, store, options, unmount } = setup(true);
211
- await act(async () => Promise.resolve());
212
- expect(fakes).toHaveLength(2);
213
- expect(fakes[0].opened.every((attempt) => attempt.signal.aborted)).toBe(
214
- true,
215
- );
216
- const [active] = fakes[1].opened;
217
- act(() => active.open());
218
- expect(store.get(result.current.connectionAtom)).toEqual({
219
- state: ConnectionState.OPEN,
220
- });
221
- act(() => fakes.forEach((fake) => fake.notify()));
222
- expect(options.onNotification).toHaveBeenCalledOnce();
223
- unmount();
224
- expect(active.signal.aborted).toBe(true);
225
- await result.current.reconnect();
226
- expect(fakes[1].opened).toHaveLength(1);
227
- });
228
- });
@@ -1,89 +0,0 @@
1
- /* Copyright 2026 Marimo. All rights reserved. */
2
-
3
- import { StreamStopped } from "@marimo-team/client";
4
- import { useStore } from "jotai";
5
- import { useEffect, useMemo } from "react";
6
- import useEvent from "react-use-event-hook";
7
- import type {
8
- NotificationMessage,
9
- NotificationPayload,
10
- } from "../kernel/messages";
11
- import {
12
- createNotebookClient,
13
- type NotebookClient,
14
- } from "../host/notebook-client";
15
- import { notebookClientAtom } from "./state";
16
- import { createEditorBackend, type EditorBackendOptions } from "./backend";
17
-
18
- type Options = Omit<EditorBackendOptions, "url"> & {
19
- /** The session route, read before each connection. */
20
- url: () => string;
21
- /** Wait until the host can accept a connection. */
22
- waitUntilReady: () => Promise<void>;
23
- checkHealth: () => Promise<boolean>;
24
- onNotification: (notification: NotificationPayload) => void;
25
- };
26
-
27
- export function useNotebookClient(options: Options): NotebookClient {
28
- const store = useStore();
29
- const { static: isStatic, transportType } = options;
30
- const controller = useMemo(() => createNotebookClient(store), [store]);
31
- const url = useEvent(options.url);
32
- const headers = useEvent(options.headers);
33
- const notebookUrl = useEvent(options.notebookUrl);
34
- const waitUntilReady = useEvent(options.waitUntilReady);
35
- const checkHealth = useEvent(options.checkHealth);
36
- const onNotification = useEvent(options.onNotification);
37
-
38
- useEffect(() => {
39
- const lifetime = new AbortController();
40
- let needsInitialReadiness = true;
41
- store.set(notebookClientAtom, controller.client);
42
- controller.connect({
43
- backend: createEditorBackend({
44
- static: isStatic,
45
- transportType,
46
- url: async () => {
47
- if (needsInitialReadiness) {
48
- // Explicit retries check current health, even if startup failed.
49
- needsInitialReadiness = false;
50
- try {
51
- await waitUntilReady();
52
- } catch (error) {
53
- throw new StreamStopped("Healthy connection never made", {
54
- cause: error,
55
- });
56
- }
57
- }
58
- return url();
59
- },
60
- headers,
61
- notebookUrl,
62
- }),
63
- signal: lifetime.signal,
64
- checkHealth,
65
- // The host validates the envelope, not the native notification contents.
66
- onNotification: (notification) =>
67
- onNotification({ data: notification as NotificationMessage }),
68
- });
69
- return () => {
70
- lifetime.abort();
71
- if (store.get(notebookClientAtom) === controller.client) {
72
- store.set(notebookClientAtom, null);
73
- }
74
- };
75
- }, [
76
- controller,
77
- store,
78
- isStatic,
79
- transportType,
80
- url,
81
- headers,
82
- notebookUrl,
83
- waitUntilReady,
84
- checkHealth,
85
- onNotification,
86
- ]);
87
-
88
- return controller.client;
89
- }