@marimo-team/frontend 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.
- package/dist/assets/{CellStatus-6xDEj3Hs.js → CellStatus-DC6FFQOg.js} +1 -1
- package/dist/assets/{ConnectedDataExplorerComponent-DCcLAktu.js → ConnectedDataExplorerComponent-BV1mVpaE.js} +1 -1
- package/dist/assets/{ErrorBoundary-98Xo04DS.js → ErrorBoundary-Ce-Ujlwj.js} +2 -2
- package/dist/assets/{JsonOutput-V47aiTIK.js → JsonOutput-CfEBzaqW.js} +4 -4
- package/dist/assets/{MarimoErrorOutput-DeWZNBgL.js → MarimoErrorOutput-Bh6o9zle.js} +1 -1
- package/dist/assets/{RenderHTML-Bjvcs70-.js → RenderHTML-COGZsXQV.js} +1 -1
- package/dist/assets/{RunButton-CnvvXD9d.js → RunButton-DQ51plUv.js} +1 -1
- package/dist/assets/{add-cell-with-ai-BbuqzITi.js → add-cell-with-ai-D7gtGUsg.js} +1 -1
- package/dist/assets/{add-connection-dialog-DIC7owvZ.js → add-connection-dialog-CpMCc97S.js} +1 -1
- package/dist/assets/{agent-panel-D8hO2P1x.js → agent-panel-BSFYqJ9B.js} +2 -2
- package/dist/assets/{ai-model-dropdown-DgmiG_5j.js → ai-model-dropdown-D575Vid8.js} +3 -3
- package/dist/assets/{app-config-button-UZHv1Iim.js → app-config-button-Cfcg_5Bz.js} +1 -1
- package/dist/assets/{blob-BPknzzkY.js → blob-CszMu60N.js} +1 -1
- package/dist/assets/{cell-editor-CM-K1_wq.js → cell-editor-BLpOlLnM.js} +2 -2
- package/dist/assets/cell-link-Cj0gGaMV.js +1 -0
- package/dist/assets/{cells-BypTg035.js → cells-SvbLY10y.js} +1 -1
- package/dist/assets/{chat-display-BRLNOoTF.js → chat-display-D43_g3TX.js} +1 -1
- package/dist/assets/{chat-panel-D02Rk9we.js → chat-panel-B_HAJMwx.js} +1 -1
- package/dist/assets/{chat-ui-DFN_RwxF.js → chat-ui-BEcYCphu.js} +2 -2
- package/dist/assets/{column-preview-85ORMhfB.js → column-preview-CrzaJKk8.js} +1 -1
- package/dist/assets/{command-palette-CtyjjdsA.js → command-palette-Bm9yIVcD.js} +1 -1
- package/dist/assets/{common-J265II7i.js → common-BJyUxCzv.js} +1 -1
- package/dist/assets/{components-Bd3Dpi92.js → components-D1f_Cz4i.js} +1 -1
- package/dist/assets/{datasource-Dy4rzzSP.js → datasource-C8KSVfSD.js} +1 -1
- package/dist/assets/{dependency-graph-panel-ByGFFT4E.js → dependency-graph-panel-BhmyJbUl.js} +1 -1
- package/dist/assets/{documentation-panel-LxuJCY3M.js → documentation-panel-DcVG0Q0d.js} +1 -1
- package/dist/assets/{download-BsBJWH_j.js → download-Bx52VRE4.js} +1 -1
- package/dist/assets/{edit-page--d6X-OO9.js → edit-page-DUrwOHR-.js} +6 -6
- package/dist/assets/{error-entries-0wTSSuK9.js → error-entries-11OuHzUF.js} +1 -1
- package/dist/assets/{error-panel-ZNdbE3Fo.js → error-panel-XJy5S25F.js} +1 -1
- package/dist/assets/{feedback-button-C6asPmsh.js → feedback-button-B7jh_B4w.js} +1 -1
- package/dist/assets/feedback-button-C-eKtxXE.js +1 -0
- package/dist/assets/{file-explorer-panel-0fUP_ptL.js → file-explorer-panel-CnVxbF4A.js} +1 -1
- package/dist/assets/{file-name-input-B8lzI5iS.js → file-name-input-BJuqjQqH.js} +2 -2
- package/dist/assets/floating-outline-8yyZ-MOJ.js +1 -0
- package/dist/assets/{gallery-page-ClR8U-km.js → gallery-page-DJvGHePf.js} +1 -1
- package/dist/assets/{glide-data-editor-B6QQBQwS.js → glide-data-editor-Cyc8DHZx.js} +1 -1
- package/dist/assets/{home-page-CJnqDcT9.js → home-page-tRZJmSU6.js} +1 -1
- package/dist/assets/{hooks-CCek4i9f.js → hooks-CxGwybGw.js} +1 -1
- package/dist/assets/index-CoxbqhGH.js +30 -0
- package/dist/assets/index-u08m0hQE.css +2 -0
- package/dist/assets/{layout-66Urh4Zg.js → layout-DvXJxBiL.js} +5 -5
- package/dist/assets/{logs-panel-DFhkIcge.js → logs-panel-KtsY7TrR.js} +1 -1
- package/dist/assets/{markdown-renderer-mYYQYD2m.js → markdown-renderer-DUgV7_fm.js} +1 -1
- package/dist/assets/{name-cell-input-Crcu3i2z.js → name-cell-input-C3-Cj-Ek.js} +1 -1
- package/dist/assets/{outline-panel-BFIPK0H_.js → outline-panel-BzyY4JA6.js} +1 -1
- package/dist/assets/{packages-panel-B4KtjgkF.js → packages-panel-DxosbWko.js} +1 -1
- package/dist/assets/{panel-accordion-state-D6KtwSF8.js → panel-accordion-state-BRbTib2B.js} +1 -1
- package/dist/assets/panels-Da3071Q4.js +1 -0
- package/dist/assets/{process-output-DDvMd6Jh.js → process-output-CSwx7rvo.js} +1 -1
- package/dist/assets/{readonly-code-display-VDqYn2d0.js → readonly-code-display-B9y9423Y.js} +1 -1
- package/dist/assets/{readonly-python-code-CZUKLXJ4.js → readonly-python-code-Dgw6trlk.js} +1 -1
- package/dist/assets/{reveal-component-BMmDU1uN.js → reveal-component-B9A4wkG-.js} +1 -1
- package/dist/assets/{run-page-BmOPPh2Y.js → run-page-C1MQ5b_A.js} +1 -1
- package/dist/assets/{runtime-details-state-CipqynUD.js → runtime-details-state-D_hOQL9T.js} +1 -1
- package/dist/assets/scratchpad-panel-CBCsPmMW.js +1 -0
- package/dist/assets/secrets-panel-ConQpchl.js +1 -0
- package/dist/assets/session-panel-CiHCKPlx.js +1 -0
- package/dist/assets/snippets-panel-C0VDE_4i.js +1 -0
- package/dist/assets/{state-QfGh3m2G.js → state-DrraMmqz.js} +1 -1
- package/dist/assets/{state-BLGvzMB6.js → state-DvU2AiFx.js} +22 -22
- package/dist/assets/tracing-C5d51Nmi.js +1 -0
- package/dist/assets/{tracing-panel-CsHuzRGP.js → tracing-panel-B89R8dWE.js} +2 -2
- package/dist/assets/{tree-actions-BQw_utcj.js → tree-actions-Cph8FVzh.js} +1 -1
- package/dist/assets/{useAddCell-_b8InOZC.js → useAddCell-VPkB72pn.js} +1 -1
- package/dist/assets/{useCellActionButton-BbgMzBdk.js → useCellActionButton--zI3GFnt.js} +1 -1
- package/dist/assets/{useDeleteCell-DhoFbEkU.js → useDeleteCell-CjewKeSr.js} +1 -1
- package/dist/assets/{useDependencyPanelTab-COTULhoH.js → useDependencyPanelTab-Bt_ka-wl.js} +1 -1
- package/dist/assets/{useNotebookActions-xnA_l3nY.js → useNotebookActions-DV2S6KaG.js} +1 -1
- package/dist/assets/{useRunCells-VBCe6CLn.js → useRunCells-BupCZpzk.js} +1 -1
- package/dist/assets/{useSplitCell-AvSpVAmq.js → useSplitCell-CaLIR0TS.js} +1 -1
- package/dist/index.html +21 -21
- package/package.json +1 -1
- package/src/__tests__/module-entry.test.ts +32 -0
- package/src/components/ai/__tests__/ai-model-dropdown.test.tsx +63 -0
- package/src/components/ai/ai-model-dropdown.tsx +11 -6
- package/src/components/chat/acp/__tests__/session-tabs.test.tsx +43 -0
- package/src/components/chat/acp/blocks.tsx +1 -0
- package/src/components/chat/acp/session-tabs.tsx +8 -3
- package/src/components/data-table/TableTopBar.tsx +2 -0
- package/src/components/data-table/__tests__/data-table.test.tsx +1 -0
- package/src/components/data-table/__tests__/keyboard-selection.test.tsx +188 -0
- package/src/components/data-table/data-table.tsx +6 -0
- package/src/components/data-table/range-focus/atoms.ts +4 -6
- package/src/components/data-table/range-focus/use-cell-range-selection.ts +65 -38
- package/src/components/data-table/range-focus/use-scroll-into-view.ts +21 -2
- package/src/components/data-table/renderers.tsx +16 -7
- package/src/components/data-table/row-viewer-panel/__tests__/row-viewer.test.tsx +24 -1
- package/src/components/data-table/row-viewer-panel/row-viewer.tsx +2 -2
- package/src/components/datasources/datasources.tsx +1 -0
- package/src/components/editor/__tests__/package-alert.test.tsx +22 -1
- package/src/components/editor/cell/collapse.tsx +5 -3
- package/src/components/editor/chrome/panels/outline/__tests__/outline-keyboard.test.tsx +84 -0
- package/src/components/editor/chrome/panels/outline/floating-outline.tsx +39 -14
- package/src/components/editor/chrome/panels/outline/useActiveOutline.tsx +3 -2
- package/src/components/editor/chrome/panels/secrets-panel.tsx +1 -0
- package/src/components/editor/chrome/panels/snippets-panel.tsx +5 -1
- package/src/components/editor/chrome/wrapper/__tests__/attachment-status.test.tsx +4 -4
- package/src/components/editor/controls/__tests__/keyboard-shortcuts.test.tsx +61 -0
- package/src/components/editor/controls/keyboard-shortcuts.tsx +14 -5
- package/src/components/editor/errors/__tests__/send-error-report-button.test.tsx +4 -4
- package/src/components/editor/file-tree/__tests__/file-explorer-navigation.test.tsx +47 -0
- package/src/components/editor/file-tree/file-explorer.tsx +35 -3
- package/src/components/editor/file-tree/file-name-input.tsx +1 -0
- package/src/components/editor/header/status.tsx +28 -5
- package/src/components/editor/links/__tests__/cell-link.test.tsx +146 -0
- package/src/components/editor/links/cell-link.tsx +14 -5
- package/src/components/editor/navigation/focus-utils.ts +4 -4
- package/src/components/editor/notebook-cell.tsx +11 -2
- package/src/components/editor/output/MarimoTracebackOutput.tsx +17 -15
- package/src/components/editor/output/__tests__/traceback.test.tsx +27 -1
- package/src/components/editor/package-alert.tsx +8 -3
- package/src/components/scratchpad/scratchpad.test.tsx +95 -0
- package/src/components/scratchpad/scratchpad.tsx +14 -1
- package/src/components/tracing/tracing-keyboard.test.tsx +85 -0
- package/src/components/tracing/tracing.tsx +43 -39
- package/src/components/ui/__tests__/draggable-popover.test.tsx +24 -0
- package/src/components/ui/draggable-popover.tsx +24 -2
- package/src/core/attachments/state.ts +2 -2
- package/src/core/document/__tests__/codemirror.test.ts +3 -1
- package/src/core/document/__tests__/hooks.test.tsx +7 -7
- package/src/core/document/__tests__/model.test.ts +44 -11
- package/src/core/document/__tests__/scenario-fixture.ts +2 -1
- package/src/core/document/__tests__/test-notebook.ts +19 -9
- package/src/core/document/draft.ts +1 -1
- package/src/core/document/index.ts +3 -10
- package/src/core/document/model.ts +14 -11
- package/src/core/document/state.ts +1 -1
- package/src/core/document/sync.ts +24 -18
- package/src/core/host/AGENTS.md +3 -7
- package/src/core/host/__tests__/fixtures.ts +30 -0
- package/src/core/host/__tests__/{notebook-client.test.ts → replica.test.ts} +42 -74
- package/src/core/host/__tests__/wasm.test.ts +8 -0
- package/src/core/host/backend.ts +4 -0
- package/src/core/host/index.ts +14 -0
- package/src/core/host/{notebook-client.ts → replica.ts} +75 -86
- package/src/core/host/static.ts +2 -0
- package/src/core/host/wasm.ts +10 -1
- package/src/core/wasm/bridge.ts +1 -1
- package/src/core/websocket/__tests__/backend.test.ts +111 -22
- package/src/core/websocket/__tests__/useNotebookReplica.hook.test.tsx +141 -0
- package/src/core/websocket/__tests__/useNotebookSession.hook.test.tsx +4 -4
- package/src/core/websocket/backend.ts +43 -5
- package/src/core/websocket/connection-state.ts +1 -1
- package/src/core/websocket/state.ts +3 -3
- package/src/core/websocket/transports/__tests__/notebook.test.ts +11 -21
- package/src/core/websocket/types.ts +2 -2
- package/src/core/websocket/useNotebookReplica.ts +69 -0
- package/src/core/websocket/useNotebookSession.tsx +6 -6
- package/src/plugins/impl/chat/chat-ui.tsx +2 -1
- package/src/plugins/impl/data-explorer/components/column-summary.tsx +4 -2
- package/src/plugins/impl/data-frames/__tests__/sidebar.test.tsx +160 -0
- package/src/plugins/impl/data-frames/panel.tsx +43 -16
- package/dist/assets/cell-link-CAjuFcLK.js +0 -1
- package/dist/assets/feedback-button-Bt89tGG2.js +0 -1
- package/dist/assets/floating-outline-CRpmOv32.js +0 -1
- package/dist/assets/index-B5pWkt7a.js +0 -30
- package/dist/assets/index-BnhSnwvB.css +0 -2
- package/dist/assets/panels-DiwGKPzK.js +0 -1
- package/dist/assets/scratchpad-panel-Drr_kuwb.js +0 -1
- package/dist/assets/secrets-panel-D-W1PL76.js +0 -1
- package/dist/assets/session-panel-W7JOcog_.js +0 -1
- package/dist/assets/snippets-panel-cUPEdm66.js +0 -1
- package/dist/assets/tracing-BNFynaTu.js +0 -1
- package/src/core/websocket/__tests__/useNotebookClient.hook.test.tsx +0 -228
- package/src/core/websocket/useNotebookClient.ts +0 -89
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
3
|
import { NotebookEvents } from "@marimo-team/client";
|
|
4
|
-
import {
|
|
4
|
+
import { createStore } from "jotai/vanilla";
|
|
5
|
+
import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest";
|
|
6
|
+
import { Deferred } from "@/utils/Deferred";
|
|
7
|
+
import { ConnectionState } from "../../host/connection";
|
|
8
|
+
import { createNotebookReplica } from "../../host/replica";
|
|
5
9
|
import { createEditorBackend } from "../backend";
|
|
6
10
|
|
|
7
11
|
const wasm = vi.hoisted(() => ({ enabled: false, consumer: vi.fn() }));
|
|
@@ -16,16 +20,34 @@ afterEach(() => {
|
|
|
16
20
|
vi.unstubAllGlobals();
|
|
17
21
|
});
|
|
18
22
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
23
|
+
const options = {
|
|
24
|
+
static: false,
|
|
25
|
+
transportType: "websocket" as const,
|
|
26
|
+
url: () => "ws://localhost/ws",
|
|
27
|
+
headers: () => ({}),
|
|
28
|
+
notebookUrl: (id: string) =>
|
|
29
|
+
new URL(`http://localhost/api/marimo/v1/notebooks/${id}`),
|
|
30
|
+
waitUntilReady: async () => undefined,
|
|
31
|
+
checkHealth: async () => true,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Record each socket the transport opens. They never connect. */
|
|
35
|
+
function sockets() {
|
|
36
|
+
const opened: string[] = [];
|
|
37
|
+
vi.stubGlobal(
|
|
38
|
+
"WebSocket",
|
|
39
|
+
class extends EventTarget {
|
|
40
|
+
public constructor(url: URL) {
|
|
41
|
+
super();
|
|
42
|
+
opened.push(String(url));
|
|
43
|
+
}
|
|
44
|
+
public close() {}
|
|
45
|
+
},
|
|
46
|
+
);
|
|
47
|
+
return opened;
|
|
48
|
+
}
|
|
28
49
|
|
|
50
|
+
describe("createEditorBackend", () => {
|
|
29
51
|
it.each([
|
|
30
52
|
["a static notebook", { ...options, static: true }, "idle"],
|
|
31
53
|
["WASM", options, "wasm"],
|
|
@@ -34,18 +56,8 @@ describe("createEditorBackend", () => {
|
|
|
34
56
|
] as const)("follows %s", async (_name, chosen, expected) => {
|
|
35
57
|
wasm.enabled = expected === "wasm";
|
|
36
58
|
const fetch = vi.fn(() => new Promise<Response>(() => undefined));
|
|
37
|
-
const
|
|
59
|
+
const opened = sockets();
|
|
38
60
|
vi.stubGlobal("fetch", fetch);
|
|
39
|
-
vi.stubGlobal(
|
|
40
|
-
"WebSocket",
|
|
41
|
-
class extends EventTarget {
|
|
42
|
-
public constructor(url: URL) {
|
|
43
|
-
super();
|
|
44
|
-
socket(url);
|
|
45
|
-
}
|
|
46
|
-
public close() {}
|
|
47
|
-
},
|
|
48
|
-
);
|
|
49
61
|
const events = new NotebookEvents(createEditorBackend(chosen).transport);
|
|
50
62
|
await vi.waitFor(() =>
|
|
51
63
|
expect(
|
|
@@ -54,7 +66,7 @@ describe("createEditorBackend", () => {
|
|
|
54
66
|
events.status === "connected" && !wasm.consumer.mock.calls.length,
|
|
55
67
|
wasm: wasm.consumer.mock.calls.length === 1,
|
|
56
68
|
fetch: fetch.mock.calls.length === 1,
|
|
57
|
-
socket:
|
|
69
|
+
socket: opened.length === 1,
|
|
58
70
|
}[expected],
|
|
59
71
|
).toBe(true),
|
|
60
72
|
);
|
|
@@ -109,3 +121,80 @@ describe("createEditorBackend", () => {
|
|
|
109
121
|
expect(fetch).not.toHaveBeenCalled();
|
|
110
122
|
});
|
|
111
123
|
});
|
|
124
|
+
|
|
125
|
+
describe("the session backend's startup", () => {
|
|
126
|
+
it.each([true, false])(
|
|
127
|
+
"rechecks health after failed startup (healthy: %s)",
|
|
128
|
+
async (healthy) => {
|
|
129
|
+
const opened = sockets();
|
|
130
|
+
const waitUntilReady = vi
|
|
131
|
+
.fn()
|
|
132
|
+
.mockRejectedValue(new Error("Startup gave up"));
|
|
133
|
+
const checkHealth = vi.fn().mockResolvedValue(healthy);
|
|
134
|
+
const store = createStore();
|
|
135
|
+
const client = createNotebookReplica(store);
|
|
136
|
+
onTestFinished(
|
|
137
|
+
client.connect(
|
|
138
|
+
createEditorBackend({ ...options, waitUntilReady, checkHealth }),
|
|
139
|
+
{ onNotification: vi.fn() },
|
|
140
|
+
),
|
|
141
|
+
);
|
|
142
|
+
await vi.waitFor(() =>
|
|
143
|
+
expect(store.get(client.connectionAtom).state).toBe(
|
|
144
|
+
ConnectionState.CLOSED,
|
|
145
|
+
),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
await client.reconnect();
|
|
149
|
+
await vi.waitFor(() => expect(opened).toHaveLength(healthy ? 1 : 0));
|
|
150
|
+
expect({
|
|
151
|
+
healthChecks: checkHealth.mock.calls.length,
|
|
152
|
+
startupWaits: waitUntilReady.mock.calls.length,
|
|
153
|
+
state: store.get(client.connectionAtom).state,
|
|
154
|
+
}).toEqual({
|
|
155
|
+
healthChecks: 1,
|
|
156
|
+
startupWaits: 1,
|
|
157
|
+
state: healthy ? ConnectionState.CONNECTING : ConnectionState.CLOSED,
|
|
158
|
+
});
|
|
159
|
+
},
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
it("shares one startup wait across connections", async () => {
|
|
163
|
+
const opened = sockets();
|
|
164
|
+
const ready = new Deferred<void>();
|
|
165
|
+
const waitUntilReady = vi.fn(() => ready.promise);
|
|
166
|
+
const backend = createEditorBackend({ ...options, waitUntilReady });
|
|
167
|
+
const client = createNotebookReplica(createStore());
|
|
168
|
+
// Strict Mode connects, disconnects, and connects again.
|
|
169
|
+
client.connect(backend, { onNotification: vi.fn() })();
|
|
170
|
+
onTestFinished(client.connect(backend, { onNotification: vi.fn() }));
|
|
171
|
+
await Promise.resolve();
|
|
172
|
+
expect(opened).toEqual([]);
|
|
173
|
+
|
|
174
|
+
ready.resolve();
|
|
175
|
+
await vi.waitFor(() => expect(opened).toHaveLength(1));
|
|
176
|
+
expect(waitUntilReady).toHaveBeenCalledOnce();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("opens nothing for a connection that ended during startup", async () => {
|
|
180
|
+
const opened = sockets();
|
|
181
|
+
const ready = new Deferred<void>();
|
|
182
|
+
const store = createStore();
|
|
183
|
+
const client = createNotebookReplica(store);
|
|
184
|
+
const disconnect = client.connect(
|
|
185
|
+
createEditorBackend({ ...options, waitUntilReady: () => ready.promise }),
|
|
186
|
+
{ onNotification: vi.fn() },
|
|
187
|
+
);
|
|
188
|
+
expect(store.get(client.connectionAtom)).toEqual({
|
|
189
|
+
state: ConnectionState.CONNECTING,
|
|
190
|
+
});
|
|
191
|
+
disconnect();
|
|
192
|
+
ready.resolve();
|
|
193
|
+
await ready.promise;
|
|
194
|
+
await Promise.resolve();
|
|
195
|
+
expect(opened).toEqual([]);
|
|
196
|
+
expect(store.get(client.connectionAtom)).toEqual({
|
|
197
|
+
state: ConnectionState.NOT_STARTED,
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
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 { createEditorBackend } from "../backend";
|
|
11
|
+
import { useNotebookReplica } from "../useNotebookReplica";
|
|
12
|
+
import { fakeBackend, fixtureMessages } from "../../host/__tests__/fixtures";
|
|
13
|
+
import { agentAttachmentsAtom } from "../../attachments/state";
|
|
14
|
+
import { notebookReplicaAtom } from "../state";
|
|
15
|
+
|
|
16
|
+
vi.mock("../backend", () => ({ createEditorBackend: vi.fn() }));
|
|
17
|
+
|
|
18
|
+
/** A host-framed native notification. */
|
|
19
|
+
const reconnected = {
|
|
20
|
+
event: "marimo",
|
|
21
|
+
data: { marimo: { op: "reconnected" } },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** A transport whose connections the test drives. */
|
|
25
|
+
function makeTransport() {
|
|
26
|
+
const opened: {
|
|
27
|
+
signal: AbortSignal;
|
|
28
|
+
open(): void;
|
|
29
|
+
receive(message: unknown): void;
|
|
30
|
+
}[] = [];
|
|
31
|
+
const transport: NotebookTransport = ({ signal }, handlers) =>
|
|
32
|
+
new Promise((resolve) => {
|
|
33
|
+
opened.push({ signal, open: handlers.open, receive: handlers.message });
|
|
34
|
+
signal.addEventListener("abort", () => resolve(), { once: true });
|
|
35
|
+
});
|
|
36
|
+
return {
|
|
37
|
+
transport,
|
|
38
|
+
opened,
|
|
39
|
+
/** Open the latest connection and deliver a host-framed notification. */
|
|
40
|
+
notify: () => opened.at(-1)?.receive(reconnected),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function setup(strict = false) {
|
|
45
|
+
const store = createStore();
|
|
46
|
+
const options = {
|
|
47
|
+
static: false,
|
|
48
|
+
transportType: "websocket" as const,
|
|
49
|
+
url: () => "ws://localhost/ws",
|
|
50
|
+
headers: () => ({}),
|
|
51
|
+
notebookUrl: (id: string) => new URL(`http://localhost/notebooks/${id}`),
|
|
52
|
+
waitUntilReady: async () => undefined,
|
|
53
|
+
checkHealth: async () => true,
|
|
54
|
+
onNotification: vi.fn(),
|
|
55
|
+
};
|
|
56
|
+
const wrapper = ({ children }: PropsWithChildren) => (
|
|
57
|
+
<Provider store={store}>{children}</Provider>
|
|
58
|
+
);
|
|
59
|
+
const hook = renderHook((props) => useNotebookReplica(props), {
|
|
60
|
+
initialProps: options,
|
|
61
|
+
reactStrictMode: strict,
|
|
62
|
+
wrapper,
|
|
63
|
+
});
|
|
64
|
+
return { ...hook, options, store };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
vi.mocked(createEditorBackend).mockReset();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("useNotebookReplica", () => {
|
|
72
|
+
it("exposes host presence to the UI without waiting for kernel-ready", async () => {
|
|
73
|
+
const fake = makeTransport();
|
|
74
|
+
vi.mocked(createEditorBackend).mockReturnValue(
|
|
75
|
+
fakeBackend({ transport: fake.transport }),
|
|
76
|
+
);
|
|
77
|
+
const { result, store, unmount } = setup();
|
|
78
|
+
await act(async () => Promise.resolve());
|
|
79
|
+
expect(store.get(notebookReplicaAtom)).toBe(result.current);
|
|
80
|
+
const [connection] = fake.opened;
|
|
81
|
+
act(() => {
|
|
82
|
+
connection.open();
|
|
83
|
+
fixtureMessages("notebook-events.sse").forEach(connection.receive);
|
|
84
|
+
});
|
|
85
|
+
expect(store.get(agentAttachmentsAtom)).toEqual([
|
|
86
|
+
{
|
|
87
|
+
id: "a2",
|
|
88
|
+
notebook_id: "n1",
|
|
89
|
+
kind: "agent",
|
|
90
|
+
name: "Notebook CLI",
|
|
91
|
+
since: "2026-10-01T09:20:03Z",
|
|
92
|
+
},
|
|
93
|
+
]);
|
|
94
|
+
unmount();
|
|
95
|
+
expect(store.get(notebookReplicaAtom)).toBeNull();
|
|
96
|
+
expect(store.get(agentAttachmentsAtom)).toEqual([]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("uses current callbacks and URL providers without reconnecting on a rerender", async () => {
|
|
100
|
+
const fake = makeTransport();
|
|
101
|
+
vi.mocked(createEditorBackend).mockReturnValue(
|
|
102
|
+
fakeBackend({ transport: fake.transport }),
|
|
103
|
+
);
|
|
104
|
+
const { rerender, options, unmount } = setup();
|
|
105
|
+
await act(async () => Promise.resolve());
|
|
106
|
+
const onNotification = vi.fn();
|
|
107
|
+
rerender({ ...options, onNotification, url: () => "ws://redirected/ws" });
|
|
108
|
+
act(() => fake.notify());
|
|
109
|
+
expect(options.onNotification).not.toHaveBeenCalled();
|
|
110
|
+
expect(onNotification).toHaveBeenCalledExactlyOnceWith({
|
|
111
|
+
data: { op: "reconnected" },
|
|
112
|
+
});
|
|
113
|
+
const [{ url }] = vi.mocked(createEditorBackend).mock.calls[0];
|
|
114
|
+
expect(url()).toBe("ws://redirected/ws");
|
|
115
|
+
expect(fake.opened).toHaveLength(1);
|
|
116
|
+
unmount();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("follows only the active connection under Strict Mode", async () => {
|
|
120
|
+
const fake = makeTransport();
|
|
121
|
+
vi.mocked(createEditorBackend).mockReturnValue(
|
|
122
|
+
fakeBackend({ transport: fake.transport }),
|
|
123
|
+
);
|
|
124
|
+
const { result, store, options, unmount } = setup(true);
|
|
125
|
+
await act(async () => Promise.resolve());
|
|
126
|
+
const live = fake.opened.filter((attempt) => !attempt.signal.aborted);
|
|
127
|
+
expect(live).toHaveLength(1);
|
|
128
|
+
const [active] = live;
|
|
129
|
+
act(() => active.open());
|
|
130
|
+
expect(store.get(result.current.connectionAtom)).toEqual({
|
|
131
|
+
state: ConnectionState.OPEN,
|
|
132
|
+
});
|
|
133
|
+
act(() => fake.opened.forEach((attempt) => attempt.receive(reconnected)));
|
|
134
|
+
expect(options.onNotification).toHaveBeenCalledOnce();
|
|
135
|
+
unmount();
|
|
136
|
+
expect(active.signal.aborted).toBe(true);
|
|
137
|
+
const attempts = fake.opened.length;
|
|
138
|
+
await result.current.reconnect();
|
|
139
|
+
expect(fake.opened).toHaveLength(attempts);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -52,6 +52,7 @@ import { RuntimeManager } from "../../runtime/runtime";
|
|
|
52
52
|
import { WebSocketClosedReason, ConnectionState } from "../types";
|
|
53
53
|
import { classifyCloseEvent } from "../connection-state";
|
|
54
54
|
import { createEditorBackend } from "../backend";
|
|
55
|
+
import { fakeBackend } from "../../host/__tests__/fixtures";
|
|
55
56
|
import { useNotebookSession } from "../useNotebookSession";
|
|
56
57
|
import { type NotebookTransport, StreamStopped } from "@marimo-team/client";
|
|
57
58
|
|
|
@@ -85,10 +86,9 @@ type FakeTransport = ReturnType<typeof makeTransport>;
|
|
|
85
86
|
|
|
86
87
|
/** Use a fake transport for the next session the hook follows. */
|
|
87
88
|
function withTransport(fake: FakeTransport = makeTransport()) {
|
|
88
|
-
vi.mocked(createEditorBackend).
|
|
89
|
-
transport: fake.transport,
|
|
90
|
-
|
|
91
|
-
});
|
|
89
|
+
vi.mocked(createEditorBackend).mockImplementation(({ checkHealth }) =>
|
|
90
|
+
fakeBackend({ transport: fake.transport, checkHealth }),
|
|
91
|
+
);
|
|
92
92
|
return fake;
|
|
93
93
|
}
|
|
94
94
|
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
/** The backend the editor follows, chosen once for its mode. */
|
|
4
|
+
|
|
5
|
+
import { NotebookClient, StreamStopped } from "@marimo-team/client";
|
|
4
6
|
import type { NotebookId } from "@marimo-team/client/protocol";
|
|
5
|
-
import type { NotebookBackend } from "../host
|
|
7
|
+
import type { NotebookBackend } from "../host";
|
|
6
8
|
import { PyodideBridge } from "../wasm/bridge";
|
|
7
9
|
import { isWasm } from "../wasm/utils";
|
|
8
10
|
import {
|
|
@@ -18,11 +20,45 @@ export interface EditorBackendOptions {
|
|
|
18
20
|
static: boolean;
|
|
19
21
|
transportType: "websocket" | "sse";
|
|
20
22
|
/** The session route, read before each connection. */
|
|
21
|
-
url:
|
|
23
|
+
url: () => string;
|
|
22
24
|
/** The runtime's headers, read before each request. */
|
|
23
25
|
headers: () => Record<string, string>;
|
|
24
26
|
/** The host URL of a notebook the stream reported. */
|
|
25
27
|
notebookUrl: (notebookId: NotebookId) => URL;
|
|
28
|
+
/** Wait until the host can accept the first connection. */
|
|
29
|
+
waitUntilReady: () => Promise<void>;
|
|
30
|
+
/** Whether the host is reachable before an explicit reconnect. */
|
|
31
|
+
checkHealth: () => Promise<boolean>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Read the session route once the host has started.
|
|
36
|
+
*
|
|
37
|
+
* Every connection waits for the same startup. Once it settles, even by
|
|
38
|
+
* giving up, connections read the route directly. An explicit reconnect
|
|
39
|
+
* checks the host's health instead.
|
|
40
|
+
*/
|
|
41
|
+
function afterStartup({
|
|
42
|
+
url,
|
|
43
|
+
waitUntilReady,
|
|
44
|
+
}: Pick<EditorBackendOptions, "url" | "waitUntilReady">) {
|
|
45
|
+
let startup: Promise<void> | undefined;
|
|
46
|
+
let settled = false;
|
|
47
|
+
return async () => {
|
|
48
|
+
if (!settled) {
|
|
49
|
+
startup ??= waitUntilReady().finally(() => {
|
|
50
|
+
settled = true;
|
|
51
|
+
});
|
|
52
|
+
try {
|
|
53
|
+
await startup;
|
|
54
|
+
} catch (error) {
|
|
55
|
+
throw new StreamStopped("Healthy connection never made", {
|
|
56
|
+
cause: error,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return url();
|
|
61
|
+
};
|
|
26
62
|
}
|
|
27
63
|
|
|
28
64
|
/** Commands for a notebook the editor cannot change through its backend. */
|
|
@@ -55,14 +91,16 @@ export function createEditorBackend(
|
|
|
55
91
|
notebook: uneditable,
|
|
56
92
|
};
|
|
57
93
|
}
|
|
94
|
+
const url = afterStartup(options);
|
|
58
95
|
return {
|
|
59
96
|
transport:
|
|
60
97
|
options.transportType === "sse"
|
|
61
|
-
? sessionSse(
|
|
62
|
-
: websocket(
|
|
98
|
+
? sessionSse(url, options.headers)
|
|
99
|
+
: websocket(url),
|
|
63
100
|
notebook: (id) =>
|
|
64
101
|
new NotebookClient(options.notebookUrl(id), {
|
|
65
102
|
fetch: withHeaders(options.headers),
|
|
66
103
|
}),
|
|
104
|
+
checkHealth: options.checkHealth,
|
|
67
105
|
};
|
|
68
106
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
|
-
import type { CloseDecision } from "../host
|
|
3
|
+
import type { CloseDecision } from "../host";
|
|
4
4
|
import { Logger } from "@/utils/Logger";
|
|
5
5
|
import { ConnectionState, WebSocketClosedReason } from "./types";
|
|
6
6
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
3
|
import { atom } from "jotai";
|
|
4
|
-
import type {
|
|
4
|
+
import type { NotebookReplica } from "../host";
|
|
5
5
|
|
|
6
|
-
/** The
|
|
7
|
-
export const
|
|
6
|
+
/** The replica owned by the mounted notebook session. */
|
|
7
|
+
export const notebookReplicaAtom = atom<NotebookReplica | null>(null);
|
|
@@ -7,8 +7,8 @@ import {
|
|
|
7
7
|
} from "@marimo-team/client";
|
|
8
8
|
import { createStore } from "jotai/vanilla";
|
|
9
9
|
import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest";
|
|
10
|
-
import {
|
|
11
|
-
import { fixtureMessages } from "../../../host/__tests__/fixtures";
|
|
10
|
+
import { createNotebookReplica } from "../../../host/replica";
|
|
11
|
+
import { fakeBackend, fixtureMessages } from "../../../host/__tests__/fixtures";
|
|
12
12
|
import { ConnectionState } from "../../../host/connection";
|
|
13
13
|
import { Deferred } from "@/utils/Deferred";
|
|
14
14
|
import { pyodide, sessionSse, websocket } from "../notebook";
|
|
@@ -76,7 +76,7 @@ function network(transportType: "websocket" | "sse", url: StreamUrl) {
|
|
|
76
76
|
transportType === "websocket"
|
|
77
77
|
? websocket(url)
|
|
78
78
|
: sessionSse(url, () => ({}));
|
|
79
|
-
const backend = { transport
|
|
79
|
+
const backend = fakeBackend({ transport });
|
|
80
80
|
let index = 0;
|
|
81
81
|
return {
|
|
82
82
|
backend,
|
|
@@ -296,15 +296,10 @@ describe.each(["websocket", "sse"] as const)(
|
|
|
296
296
|
const url = vi.fn(() => target);
|
|
297
297
|
const connection = network(transportType, url);
|
|
298
298
|
const store = createStore();
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
backend: connection.backend,
|
|
304
|
-
signal: lifetime.signal,
|
|
305
|
-
checkHealth: async () => true,
|
|
306
|
-
onNotification: vi.fn(),
|
|
307
|
-
});
|
|
299
|
+
const client = createNotebookReplica(store);
|
|
300
|
+
onTestFinished(
|
|
301
|
+
client.connect(connection.backend, { onNotification: vi.fn() }),
|
|
302
|
+
);
|
|
308
303
|
const first = await connection.opened();
|
|
309
304
|
const snapshot = fixtureMessages("notebook-events.sse");
|
|
310
305
|
snapshot.forEach(first.receive);
|
|
@@ -363,15 +358,10 @@ describe.each(["websocket", "sse"] as const)(
|
|
|
363
358
|
let target = `${base}?session_id=s1&file=a.py&access_token=a`;
|
|
364
359
|
const connection = network(transportType, () => target);
|
|
365
360
|
const store = createStore();
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
backend: connection.backend,
|
|
371
|
-
signal: lifetime.signal,
|
|
372
|
-
checkHealth: async () => true,
|
|
373
|
-
onNotification: vi.fn(),
|
|
374
|
-
});
|
|
361
|
+
const client = createNotebookReplica(store);
|
|
362
|
+
onTestFinished(
|
|
363
|
+
client.connect(connection.backend, { onNotification: vi.fn() }),
|
|
364
|
+
);
|
|
375
365
|
const first = await connection.opened();
|
|
376
366
|
fixtureMessages("notebook-events.sse").forEach(first.receive);
|
|
377
367
|
await vi.waitFor(() =>
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
|
-
import type { ConnectionStatus as HostConnectionStatus } from "../host
|
|
3
|
+
import type { ConnectionStatus as HostConnectionStatus } from "../host";
|
|
4
4
|
import type { NotificationMessageData } from "../kernel/messages";
|
|
5
5
|
|
|
6
6
|
export {
|
|
7
7
|
ConnectionState,
|
|
8
8
|
ConnectionClosedReason as WebSocketClosedReason,
|
|
9
|
-
} from "../host
|
|
9
|
+
} from "../host";
|
|
10
10
|
|
|
11
11
|
export type ConnectionPhase =
|
|
12
12
|
| NotificationMessageData<"startup-progress">["phase"]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
/** The editor session's replica, following its backend while mounted. */
|
|
4
|
+
|
|
5
|
+
import { useStore } from "jotai";
|
|
6
|
+
import { useEffect, useMemo } from "react";
|
|
7
|
+
import useEvent from "react-use-event-hook";
|
|
8
|
+
import type {
|
|
9
|
+
NotificationMessage,
|
|
10
|
+
NotificationPayload,
|
|
11
|
+
} from "../kernel/messages";
|
|
12
|
+
import { createNotebookReplica, type NotebookReplica } from "../host";
|
|
13
|
+
import { notebookReplicaAtom } from "./state";
|
|
14
|
+
import { createEditorBackend, type EditorBackendOptions } from "./backend";
|
|
15
|
+
|
|
16
|
+
type Options = EditorBackendOptions & {
|
|
17
|
+
onNotification: (notification: NotificationPayload) => void;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function useNotebookReplica(options: Options): NotebookReplica {
|
|
21
|
+
const store = useStore();
|
|
22
|
+
const { static: isStatic, transportType } = options;
|
|
23
|
+
const replica = useMemo(() => createNotebookReplica(store), [store]);
|
|
24
|
+
const url = useEvent(options.url);
|
|
25
|
+
const headers = useEvent(options.headers);
|
|
26
|
+
const notebookUrl = useEvent(options.notebookUrl);
|
|
27
|
+
const waitUntilReady = useEvent(options.waitUntilReady);
|
|
28
|
+
const checkHealth = useEvent(options.checkHealth);
|
|
29
|
+
const onNotification = useEvent(options.onNotification);
|
|
30
|
+
// Opens nothing until the replica follows it.
|
|
31
|
+
const backend = useMemo(
|
|
32
|
+
() =>
|
|
33
|
+
createEditorBackend({
|
|
34
|
+
static: isStatic,
|
|
35
|
+
transportType,
|
|
36
|
+
url,
|
|
37
|
+
headers,
|
|
38
|
+
notebookUrl,
|
|
39
|
+
waitUntilReady,
|
|
40
|
+
checkHealth,
|
|
41
|
+
}),
|
|
42
|
+
[
|
|
43
|
+
isStatic,
|
|
44
|
+
transportType,
|
|
45
|
+
url,
|
|
46
|
+
headers,
|
|
47
|
+
notebookUrl,
|
|
48
|
+
waitUntilReady,
|
|
49
|
+
checkHealth,
|
|
50
|
+
],
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
store.set(notebookReplicaAtom, replica);
|
|
55
|
+
const disconnect = replica.connect(backend, {
|
|
56
|
+
// The host validates the envelope, not the native notification contents.
|
|
57
|
+
onNotification: (notification) =>
|
|
58
|
+
onNotification({ data: notification as NotificationMessage }),
|
|
59
|
+
});
|
|
60
|
+
return () => {
|
|
61
|
+
disconnect();
|
|
62
|
+
if (store.get(notebookReplicaAtom) === replica) {
|
|
63
|
+
store.set(notebookReplicaAtom, null);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
}, [replica, backend, store, onNotification]);
|
|
67
|
+
|
|
68
|
+
return replica;
|
|
69
|
+
}
|
|
@@ -12,7 +12,7 @@ import type {
|
|
|
12
12
|
NotificationMessageData,
|
|
13
13
|
NotificationPayload,
|
|
14
14
|
} from "@/core/kernel/messages";
|
|
15
|
-
import {
|
|
15
|
+
import { useNotebookReplica } from "@/core/websocket/useNotebookReplica";
|
|
16
16
|
import { renderHTML } from "@/plugins/core/RenderHTML";
|
|
17
17
|
import {
|
|
18
18
|
handleWidgetMessage,
|
|
@@ -408,7 +408,7 @@ export function useNotebookSession(opts: {
|
|
|
408
408
|
variant: "danger",
|
|
409
409
|
});
|
|
410
410
|
};
|
|
411
|
-
const
|
|
411
|
+
const replica = useNotebookReplica({
|
|
412
412
|
static: isStaticNotebook(),
|
|
413
413
|
transportType,
|
|
414
414
|
url: () =>
|
|
@@ -438,7 +438,7 @@ export function useNotebookSession(opts: {
|
|
|
438
438
|
// Subscribe before passive connection setup: static/WASM transports can emit synchronously.
|
|
439
439
|
useLayoutEffect(() => {
|
|
440
440
|
const updateConnection = () => {
|
|
441
|
-
const status = store.get(
|
|
441
|
+
const status = store.get(replica.connectionAtom);
|
|
442
442
|
if (status.state === ConnectionState.NOT_STARTED) {
|
|
443
443
|
return;
|
|
444
444
|
}
|
|
@@ -469,16 +469,16 @@ export function useNotebookSession(opts: {
|
|
|
469
469
|
return status;
|
|
470
470
|
});
|
|
471
471
|
};
|
|
472
|
-
const unsubscribe = store.sub(
|
|
472
|
+
const unsubscribe = store.sub(replica.connectionAtom, updateConnection);
|
|
473
473
|
updateConnection();
|
|
474
474
|
return unsubscribe;
|
|
475
475
|
}, [
|
|
476
|
-
|
|
476
|
+
replica,
|
|
477
477
|
store,
|
|
478
478
|
setConnection,
|
|
479
479
|
setKernelStartupError,
|
|
480
480
|
updateStartupProgress,
|
|
481
481
|
]);
|
|
482
482
|
|
|
483
|
-
return {
|
|
483
|
+
return { replica, connection, reconnect: replica.reconnect };
|
|
484
484
|
}
|
|
@@ -495,7 +495,7 @@ export const Chatbot: React.FC<Props> = (props) => {
|
|
|
495
495
|
: undefined,
|
|
496
496
|
})}
|
|
497
497
|
</div>
|
|
498
|
-
<div className="flex justify-end text-xs gap-2
|
|
498
|
+
<div className="flex justify-end text-xs gap-2 opacity-0 group-hover:opacity-100 focus-within:opacity-100">
|
|
499
499
|
<CopyClipboardIcon
|
|
500
500
|
value={textContent}
|
|
501
501
|
className="h-3 w-3"
|
|
@@ -503,6 +503,7 @@ export const Chatbot: React.FC<Props> = (props) => {
|
|
|
503
503
|
/>
|
|
504
504
|
<button
|
|
505
505
|
type="button"
|
|
506
|
+
aria-label="Delete message"
|
|
506
507
|
onClick={() => handleDelete(message.id)}
|
|
507
508
|
className="text-xs text-(--slate-9) hover:text-(--slate-11)"
|
|
508
509
|
>
|
|
@@ -60,7 +60,9 @@ export const ColumnSummary: React.FC<Props> = ({ schema }) => {
|
|
|
60
60
|
field: field,
|
|
61
61
|
});
|
|
62
62
|
return (
|
|
63
|
-
<
|
|
63
|
+
<button
|
|
64
|
+
type="button"
|
|
65
|
+
aria-pressed={selectedField === field}
|
|
64
66
|
key={field}
|
|
65
67
|
className={cn(
|
|
66
68
|
"hover:bg-muted self-start px-2 py-2 rounded flex flex-row gap-1 items-center cursor-pointer lg:justify-center text-sm truncate shrink-0 overflow-hidden",
|
|
@@ -81,7 +83,7 @@ export const ColumnSummary: React.FC<Props> = ({ schema }) => {
|
|
|
81
83
|
({cardinality})
|
|
82
84
|
</span>
|
|
83
85
|
)}
|
|
84
|
-
</
|
|
86
|
+
</button>
|
|
85
87
|
);
|
|
86
88
|
})}
|
|
87
89
|
{hasMore && (
|