@marimo-team/islands 0.24.3-dev0 → 0.24.3-dev10

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 (33) hide show
  1. package/dist/{ConnectedDataExplorerComponent-qIKbwYin.js → ConnectedDataExplorerComponent-C9OF_75w.js} +1 -1
  2. package/dist/{any-language-editor-CpTnK43-.js → any-language-editor-DmQ7dyCx.js} +1 -1
  3. package/dist/{chat-ui-DttQ-5Ut.js → chat-ui-BSwr409J.js} +1300 -1300
  4. package/dist/{common-BcmCsjI0.js → common-SEaoDOh7.js} +6484 -6276
  5. package/dist/{error-banner-WDhUzJyg.js → error-banner-CsXbEEjo.js} +160 -160
  6. package/dist/{formats-CJMVUfb3.js → formats-CslNOKy0.js} +1 -1
  7. package/dist/{html-to-image-BTYnN4ev.js → html-to-image-BtFMe_Nq.js} +3 -3
  8. package/dist/main.js +234 -229
  9. package/dist/{mermaid-BsHdQeQf.js → mermaid-Bi23Ibnw.js} +1 -1
  10. package/dist/{process-output-CsALk6tp.js → process-output-CPy0YKUe.js} +2 -2
  11. package/dist/{reveal-component-CMGZCw99.js → reveal-component-Cun9aLyZ.js} +2 -2
  12. package/dist/{toDate-D-Rx3ux5.js → toDate-CpXBeDq0.js} +3 -2
  13. package/dist/{vega-component-D-IXRnlx.js → vega-component-ovBXgp91.js} +3 -3
  14. package/package.json +1 -1
  15. package/src/__mocks__/requests.ts +7 -2
  16. package/src/components/data-table/column-visibility-dropdown.tsx +9 -3
  17. package/src/components/datasources/components.tsx +4 -2
  18. package/src/components/editor/chrome/panels/__tests__/packages-panel.test.tsx +143 -0
  19. package/src/components/editor/chrome/panels/packages-panel.tsx +145 -51
  20. package/src/components/editor/package-alert.tsx +25 -1
  21. package/src/components/home/components.tsx +17 -0
  22. package/src/components/pages/gallery-page.tsx +2 -4
  23. package/src/components/pages/home-page.tsx +2 -4
  24. package/src/components/terminal/terminal.tsx +4 -4
  25. package/src/core/__tests__/mode.test.ts +8 -1
  26. package/src/core/islands/__tests__/parse.test.ts +140 -7
  27. package/src/core/islands/parse.ts +20 -0
  28. package/src/core/packages/__tests__/useInstallPackage.test.tsx +47 -1
  29. package/src/core/packages/toast-components.tsx +26 -0
  30. package/src/core/packages/useInstallPackage.ts +7 -2
  31. package/src/core/runtime/__tests__/runtime.test.ts +11 -0
  32. package/src/core/runtime/runtime.ts +7 -2
  33. package/src/core/wasm/bridge.ts +1 -0
@@ -10,6 +10,7 @@ import {
10
10
  PackageCheckIcon,
11
11
  PackageXIcon,
12
12
  PlusIcon,
13
+ RotateCwIcon,
13
14
  XIcon,
14
15
  } from "lucide-react";
15
16
  import type React from "react";
@@ -32,6 +33,7 @@ import {
32
33
  import { useResolvedMarimoConfig } from "@/core/config/config";
33
34
  import type { PackageInstallationStatus } from "@/core/kernel/messages";
34
35
  import { useRequestClient } from "@/core/network/requests";
36
+ import { RESTART_REQUIRED_DESCRIPTION } from "@/core/packages/toast-components";
35
37
  import { isWasm } from "@/core/wasm/utils";
36
38
  import { usePackageMetadata } from "@/hooks/usePackageMetadata";
37
39
  import { Banner } from "@/plugins/impl/common/error-banner";
@@ -55,6 +57,12 @@ import {
55
57
  import { ExternalLink } from "../ui/links";
56
58
  import { NativeSelect } from "../ui/native-select";
57
59
  import { Tooltip } from "../ui/tooltip";
60
+ import { useRestartKernel } from "./actions/useRestartKernel";
61
+
62
+ const RestartKernelButton = () => {
63
+ const restartKernel = useRestartKernel();
64
+ return <Button onClick={restartKernel}>Restart Kernel</Button>;
65
+ };
58
66
 
59
67
  function parsePackageSpecifier(spec: string): {
60
68
  name: string;
@@ -251,6 +259,10 @@ export const PackageAlert: React.FC = () => {
251
259
  )}
252
260
  >
253
261
  <p>{description}</p>
262
+ {status !== "installing" &&
263
+ Object.values(packageAlert.packages).includes(
264
+ "restart-required",
265
+ ) && <RestartKernelButton />}
254
266
  <ul className="list-disc ml-2 mt-1">
255
267
  {Object.entries(packageAlert.packages).map(([pkg, st], index) => (
256
268
  <li
@@ -290,7 +302,9 @@ function getInstallationStatusElements(packages: PackageInstallationStatus) {
290
302
  ? "installing"
291
303
  : statuses.has("failed")
292
304
  ? "failed"
293
- : "installed";
305
+ : statuses.has("restart-required")
306
+ ? "restart-required"
307
+ : "installed";
294
308
 
295
309
  if (status === "installing") {
296
310
  return {
@@ -300,6 +314,14 @@ function getInstallationStatusElements(packages: PackageInstallationStatus) {
300
314
  description: "Installing packages:",
301
315
  };
302
316
  }
317
+ if (status === "restart-required") {
318
+ return {
319
+ status,
320
+ title: "Changes saved — restart required",
321
+ titleIcon: <RotateCwIcon className="w-5 h-5 inline-block mr-2" />,
322
+ description: RESTART_REQUIRED_DESCRIPTION,
323
+ };
324
+ }
303
325
  if (status === "installed") {
304
326
  return {
305
327
  status: "installed",
@@ -330,6 +352,8 @@ const ProgressIcon = ({
330
352
  return <CheckIcon size="1rem" />;
331
353
  case "failed":
332
354
  return <XIcon size="1rem" />;
355
+ case "restart-required":
356
+ return <RotateCwIcon size="1rem" />;
333
357
  default:
334
358
  logNever(status);
335
359
  return null;
@@ -31,6 +31,7 @@ import {
31
31
  import { Constants } from "@/core/constants";
32
32
  import { useRequestClient } from "@/core/network/requests";
33
33
  import type { TutorialId } from "@/core/network/types";
34
+ import { Banner } from "@/plugins/impl/common/error-banner";
34
35
  import { openNotebook } from "@/utils/links";
35
36
  import { Objects } from "@/utils/objects";
36
37
  import { MarimoPlusIcon } from "../icons/marimo-icons";
@@ -186,6 +187,22 @@ export const ResourceLinks: React.FC = () => {
186
187
  );
187
188
  };
188
189
 
190
+ /**
191
+ * Shown when the workspace scan stopped early, so the file list the user is
192
+ * looking at is missing files and folders.
193
+ */
194
+ export const TruncatedWorkspaceBanner: React.FC<{
195
+ fileCount: number | undefined;
196
+ }> = ({ fileCount }) => {
197
+ return (
198
+ <Banner kind="warn" className="rounded p-4">
199
+ Showing {fileCount} files. Some files and folders were skipped because
200
+ your workspace is large or deeply nested. Start marimo from a subfolder to
201
+ see them.
202
+ </Banner>
203
+ );
204
+ };
205
+
189
206
  export const Header: React.FC<{
190
207
  Icon: React.FC<React.SVGProps<SVGSVGElement>>;
191
208
  control?: React.ReactNode;
@@ -4,6 +4,7 @@ import { SearchIcon } from "lucide-react";
4
4
  import type React from "react";
5
5
  import { Suspense, useMemo, useState } from "react";
6
6
  import { ErrorBoundary } from "@/components/editor/boundary/ErrorBoundary";
7
+ import { TruncatedWorkspaceBanner } from "@/components/home/components";
7
8
  import { Spinner } from "@/components/icons/spinner";
8
9
  import { Card, CardContent } from "@/components/ui/card";
9
10
  import { Input } from "@/components/ui/input";
@@ -119,10 +120,7 @@ const GalleryPage: React.FC = () => {
119
120
  <ErrorBoundary>
120
121
  <div className="flex flex-col gap-2">
121
122
  {workspace.hasMore && (
122
- <Banner kind="warn" className="rounded p-4">
123
- Showing first {workspace.fileCount} files. Your workspace has
124
- more files.
125
- </Banner>
123
+ <TruncatedWorkspaceBanner fileCount={workspace.fileCount} />
126
124
  )}
127
125
  {formattedFiles.length > SEARCH_THRESHOLD && (
128
126
  <Input
@@ -67,6 +67,7 @@ import {
67
67
  Header,
68
68
  OpenTutorialDropDown,
69
69
  ResourceLinks,
70
+ TruncatedWorkspaceBanner,
70
71
  } from "../home/components";
71
72
  import {
72
73
  expandedFoldersAtom,
@@ -196,10 +197,7 @@ const WorkspaceNotebooks: React.FC<{ onRefreshRecents: () => void }> = ({
196
197
  <WorkspaceContext value={workspaceContextValue}>
197
198
  <div className="flex flex-col gap-2">
198
199
  {workspace.hasMore && (
199
- <Banner kind="warn" className="rounded p-4">
200
- Showing first {workspace.fileCount} files. Your workspace has more
201
- files.
202
- </Banner>
200
+ <TruncatedWorkspaceBanner fileCount={workspace.fileCount} />
203
201
  )}
204
202
  <Header
205
203
  Icon={BookTextIcon}
@@ -254,7 +254,9 @@ const TerminalComponent: React.FC<TerminalComponentProps> = ({
254
254
  try {
255
255
  await waitForConnectionOpen();
256
256
 
257
- const socket = new WebSocket(runtimeManager.getTerminalWsURL());
257
+ // Size the PTY before the shell draws its first prompt.
258
+ fitAddon.fit();
259
+ const socket = new WebSocket(runtimeManager.getTerminalWsURL(terminal));
258
260
  const attachAddon = new AttachAddon(socket);
259
261
  terminal.loadAddon(attachAddon);
260
262
  wsRef.current = socket;
@@ -270,9 +272,7 @@ const TerminalComponent: React.FC<TerminalComponentProps> = ({
270
272
 
271
273
  const handleOpen = () => {
272
274
  updateReadyState();
273
- // Send initial dimensions: the mount-time fit() may have fired
274
- // before the WS was OPEN, dropping the resize message and leaving
275
- // the PTY at its default 0x0 winsize.
275
+ // The container may have resized while the connection was opening.
276
276
  fitAddon.fit();
277
277
  if (terminal.cols > 0 && terminal.rows > 0) {
278
278
  socket.send(
@@ -49,7 +49,14 @@ describe("runDuringPresentMode", () => {
49
49
  store.set(viewStateAtom, state);
50
50
 
51
51
  const result = runDuringPresentMode(() => Promise.reject(error));
52
- const rejection = expect(result).rejects.toBe(error);
52
+ const rejection = result.then(
53
+ () => {
54
+ throw new Error("Expected runDuringPresentMode to reject");
55
+ },
56
+ (caughtError: unknown) => {
57
+ expect(caughtError).toBe(error);
58
+ },
59
+ );
53
60
  await vi.runAllTimersAsync();
54
61
  await rejection;
55
62
 
@@ -19,6 +19,13 @@ import {
19
19
  } from "../parse";
20
20
  import { createMockIslandElement, createMockIslands } from "./test-utils.tsx";
21
21
 
22
+ function islandSnapshotPath(filename: string): string {
23
+ return new URL(
24
+ `../../../../../tests/_islands/snapshots/${filename}`,
25
+ import.meta.url,
26
+ ).pathname.replace(/^\/@fs/, "");
27
+ }
28
+
22
29
  function createPayloadCell(
23
30
  overrides: Partial<{
24
31
  cellId: string;
@@ -47,6 +54,7 @@ function appendPayload(
47
54
  payload: {
48
55
  schemaVersion: number;
49
56
  appId: string;
57
+ dependencies?: unknown;
50
58
  cells: ReturnType<typeof createPayloadCell>[];
51
59
  },
52
60
  ) {
@@ -58,6 +66,33 @@ function appendPayload(
58
66
  }
59
67
 
60
68
  describe("createMarimoFile", () => {
69
+ it("should serialize dependencies as top-level script metadata", () => {
70
+ const file = createMarimoFile({
71
+ dependencies: ['cowsay==6.1; sys_platform == "emscripten"'],
72
+ cells: [{ code: "import cowsay" }],
73
+ });
74
+ expect(file).toBe(
75
+ [
76
+ "# /// script",
77
+ '# dependencies = ["cowsay==6.1; sys_platform == \\"emscripten\\""]',
78
+ "# ///",
79
+ "import marimo",
80
+ "app = marimo.App()",
81
+ "@app.cell",
82
+ "def __():",
83
+ " import cowsay",
84
+ " return",
85
+ ].join("\n"),
86
+ );
87
+ });
88
+
89
+ it("should omit metadata for empty dependencies", () => {
90
+ const cells = [{ code: "import cowsay" }];
91
+ expect(createMarimoFile({ cells, dependencies: [] })).toBe(
92
+ createMarimoFile({ cells }),
93
+ );
94
+ });
95
+
61
96
  it("should return a string", () => {
62
97
  const app = {
63
98
  cells: [
@@ -736,13 +771,7 @@ describe("parseMarimoIslandApps", () => {
736
771
  });
737
772
 
738
773
  it("should parse Python-generated island payload snapshots", () => {
739
- const html = readFileSync(
740
- new URL(
741
- "../../../../../tests/_islands/snapshots/html-payload.txt",
742
- import.meta.url,
743
- ).pathname.replace(/^\/@fs/, ""),
744
- "utf8",
745
- );
774
+ const html = readFileSync(islandSnapshotPath("html-payload.txt"), "utf8");
746
775
  container.innerHTML = html;
747
776
 
748
777
  const result = parseMarimoIslandApps(container);
@@ -775,6 +804,108 @@ describe("parseMarimoIslandApps", () => {
775
804
  expect(islands[2].getAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX)).toBe("1");
776
805
  });
777
806
 
807
+ it("should restore metadata from a Python-generated dependency payload", () => {
808
+ container.innerHTML = readFileSync(
809
+ islandSnapshotPath("body-dependencies.txt"),
810
+ "utf8",
811
+ );
812
+
813
+ const probed = parseMarimoIslandApps(container, { materialize: false });
814
+ const apps = parseMarimoIslandApps(container);
815
+
816
+ expect(probed).toEqual(apps);
817
+ expect(apps).toEqual([
818
+ {
819
+ id: "main",
820
+ payloadBacked: true,
821
+ dependencies: [
822
+ 'cowsay==6.1; sys_platform == "emscripten"',
823
+ "rich[jupyter]>=13",
824
+ "desktop; sys_platform != 'emscripten'",
825
+ "older-python; python_version < '3.13'",
826
+ "newer-python; python_version >= '3.13'",
827
+ ],
828
+ cells: [{ cellId: "Hbol", code: "import cowsay", output: "", idx: 0 }],
829
+ },
830
+ ]);
831
+ // The Python Pyodide test reads this file with the real metadata parser.
832
+ expect(createMarimoFile(apps[0])).toBe(
833
+ readFileSync(islandSnapshotPath("notebook-dependencies.txt"), "utf8"),
834
+ );
835
+ });
836
+
837
+ it("should carry dependencies from payloads into each generated notebook", () => {
838
+ for (const [appId, dependencies] of [
839
+ ["app1", ["cowsay==6.1"]],
840
+ ["app2", ["rich[jupyter]>=13"]],
841
+ ] as const) {
842
+ container.appendChild(
843
+ createMockIslandElement({ appId, cellId: "cell-1" }),
844
+ );
845
+ appendPayload(container, {
846
+ schemaVersion: 1,
847
+ appId,
848
+ dependencies,
849
+ cells: [createPayloadCell()],
850
+ });
851
+ }
852
+
853
+ const apps = parseMarimoIslandApps(container);
854
+ expect(apps.map((app) => [app.id, app.dependencies])).toEqual([
855
+ ["app1", ["cowsay==6.1"]],
856
+ ["app2", ["rich[jupyter]>=13"]],
857
+ ]);
858
+ expect(createMarimoFile(apps[0])).toContain(
859
+ '# /// script\n# dependencies = ["cowsay==6.1"]\n# ///\n',
860
+ );
861
+ });
862
+
863
+ it.each([null, "cowsay", [123], ["cowsay", null]])(
864
+ "should ignore payloads with malformed dependencies: %j",
865
+ (dependencies) => {
866
+ const island = createMockIslandElement({
867
+ appId: "app1",
868
+ cellId: "cell-1",
869
+ code: "import cowsay",
870
+ innerHTML: "<div>dom</div>",
871
+ });
872
+ island.setAttribute(ISLAND_DATA_ATTRIBUTES.REACTIVE, "true");
873
+ container.appendChild(island);
874
+ appendPayload(container, {
875
+ schemaVersion: 1,
876
+ appId: "app1",
877
+ dependencies,
878
+ cells: [createPayloadCell()],
879
+ });
880
+
881
+ expect(parseMarimoIslandApps(container)).toEqual([
882
+ {
883
+ id: "app1",
884
+ cells: [{ code: "import cowsay", output: "<div>dom</div>", idx: 0 }],
885
+ },
886
+ ]);
887
+ },
888
+ );
889
+
890
+ it("should merge dependencies from payloads belonging to the same app", () => {
891
+ for (const [cellId, dependencies] of [
892
+ ["cell-1", ["cowsay"]],
893
+ ["cell-2", ["cowsay", "rich"]],
894
+ ] as const) {
895
+ container.appendChild(createMockIslandElement({ appId: "app1", cellId }));
896
+ appendPayload(container, {
897
+ schemaVersion: 1,
898
+ appId: "app1",
899
+ dependencies,
900
+ cells: [createPayloadCell({ cellId })],
901
+ });
902
+ }
903
+
904
+ expect(
905
+ parseMarimoIslandApps(container).map((app) => app.dependencies),
906
+ ).toEqual([["cowsay", "rich"]]);
907
+ });
908
+
778
909
  it("should fall back to DOM islands for unsupported payload versions", () => {
779
910
  const island = createMockIslandElement({
780
911
  appId: "app1",
@@ -990,6 +1121,7 @@ describe("parseMarimoIslandApps", () => {
990
1121
  outputHtml: "<div>payload only</div>",
991
1122
  }),
992
1123
  ],
1124
+ dependencies: ["cowsay"],
993
1125
  });
994
1126
 
995
1127
  const result = parseMarimoIslandApps(container);
@@ -1076,6 +1208,7 @@ describe("parseMarimoIslandApps", () => {
1076
1208
  reactive: false,
1077
1209
  }),
1078
1210
  ],
1211
+ dependencies: ["cowsay"],
1079
1212
  });
1080
1213
 
1081
1214
  const result = parseMarimoIslandApps(container);
@@ -30,6 +30,8 @@ export interface MarimoIslandApp {
30
30
  * Whether cells came from a supported JSON payload instead of DOM parsing.
31
31
  */
32
32
  payloadBacked?: boolean;
33
+ /** Explicit notebook dependencies to install before starting the app. */
34
+ dependencies?: string[];
33
35
  /**
34
36
  * Cells in the app.
35
37
  */
@@ -62,6 +64,7 @@ interface MarimoIslandCell {
62
64
  interface MarimoIslandPayload {
63
65
  schemaVersion: 1;
64
66
  appId: string;
67
+ dependencies?: string[];
65
68
  cells: MarimoIslandPayloadCell[];
66
69
  }
67
70
 
@@ -235,6 +238,12 @@ function parsePayloadBackedApps({
235
238
  embed?.setAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX, idx.toString());
236
239
  }
237
240
  }
241
+ const app = apps.get(payload.appId);
242
+ if (app && payload.dependencies?.length) {
243
+ app.dependencies = [
244
+ ...new Set([...(app.dependencies ?? []), ...payload.dependencies]),
245
+ ];
246
+ }
238
247
  }
239
248
 
240
249
  // A supported payload is the runtime source for its app. Extra same-app DOM
@@ -371,6 +380,7 @@ export function parseIslandElement(
371
380
 
372
381
  export function createMarimoFile(app: {
373
382
  cells: { code: string; disabled?: boolean }[];
383
+ dependencies?: string[];
374
384
  }): string {
375
385
  const lines = [
376
386
  "import marimo",
@@ -402,6 +412,13 @@ export function createMarimoFile(app: {
402
412
  .join("\n"),
403
413
  ];
404
414
 
415
+ if (app.dependencies?.length) {
416
+ lines.unshift(
417
+ "# /// script",
418
+ `# dependencies = ${JSON.stringify(app.dependencies)}`,
419
+ "# ///",
420
+ );
421
+ }
405
422
  return lines.join("\n");
406
423
  }
407
424
 
@@ -507,6 +524,9 @@ function isMarimoIslandPayload(
507
524
  return (
508
525
  payload.schemaVersion === 1 &&
509
526
  typeof payload.appId === "string" &&
527
+ (payload.dependencies === undefined ||
528
+ (Array.isArray(payload.dependencies) &&
529
+ payload.dependencies.every((dep) => typeof dep === "string"))) &&
510
530
  Array.isArray(payload.cells) &&
511
531
  payload.cells.every(isMarimoIslandPayloadCell)
512
532
  );
@@ -1,10 +1,22 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
- import { act, renderHook } from "@testing-library/react";
2
+ import {
3
+ act,
4
+ fireEvent,
5
+ render,
6
+ renderHook,
7
+ screen,
8
+ } from "@testing-library/react";
3
9
  import { beforeEach, describe, expect, it, vi } from "vitest";
4
10
  import { useInstallPackages } from "../useInstallPackage";
11
+ import { Toast, ToastProvider, ToastViewport } from "@/components/ui/toast";
5
12
 
6
13
  const toast = vi.fn();
7
14
  const addPackage = vi.fn();
15
+ const restartKernel = vi.fn();
16
+
17
+ vi.mock("@/components/editor/actions/useRestartKernel", () => ({
18
+ useRestartKernel: () => restartKernel,
19
+ }));
8
20
 
9
21
  vi.mock("@/components/ui/use-toast", () => ({
10
22
  toast: (...args: unknown[]) => toast(...args),
@@ -20,6 +32,7 @@ describe("useInstallPackages", () => {
20
32
  beforeEach(() => {
21
33
  toast.mockClear();
22
34
  addPackage.mockClear();
35
+ restartKernel.mockClear();
23
36
  });
24
37
 
25
38
  it("batches all packages into a single install call", async () => {
@@ -109,6 +122,39 @@ describe("useInstallPackages", () => {
109
122
  );
110
123
  });
111
124
 
125
+ it("reports saved changes requiring restart without claiming installation", async () => {
126
+ addPackage.mockResolvedValue({
127
+ success: false,
128
+ error: null,
129
+ restartRequired: true,
130
+ });
131
+ const { result } = renderHook(() => useInstallPackages());
132
+ await act(async () => {
133
+ await result.current.handleInstallPackages(["boltons", "six"]);
134
+ });
135
+ expect(toast).toHaveBeenCalledTimes(1);
136
+ expect(toast).toHaveBeenCalledWith(
137
+ expect.objectContaining({
138
+ title: "Changes saved — restart required",
139
+ description: expect.stringContaining(
140
+ "Restarting clears in-memory variables",
141
+ ),
142
+ duration: Infinity,
143
+ action: expect.anything(),
144
+ }),
145
+ );
146
+ expect(toast.mock.calls[0][0].variant).not.toBe("danger");
147
+ render(
148
+ <ToastProvider>
149
+ <Toast>{toast.mock.calls[0][0].action}</Toast>
150
+ <ToastViewport />
151
+ </ToastProvider>,
152
+ );
153
+ expect(restartKernel).not.toHaveBeenCalled();
154
+ fireEvent.click(screen.getByRole("button", { name: "Restart Kernel" }));
155
+ expect(restartKernel).toHaveBeenCalledTimes(1);
156
+ });
157
+
112
158
  it("calls onSuccess after a successful install", async () => {
113
159
  addPackage.mockResolvedValue({ success: true, error: null });
114
160
  const onSuccess = vi.fn();
@@ -1,8 +1,34 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
+ import { useRestartKernel } from "@/components/editor/actions/useRestartKernel";
3
4
  import { Kbd } from "@/components/ui/kbd";
5
+ import { ToastAction } from "@/components/ui/toast";
4
6
  import { toast } from "@/components/ui/use-toast";
5
7
 
8
+ export const RESTART_REQUIRED_DESCRIPTION =
9
+ "Your dependency changes are saved. Restart the kernel to use the updated environment. Restarting clears in-memory variables.";
10
+
11
+ const RestartKernelAction = () => {
12
+ const restartKernel = useRestartKernel();
13
+ return (
14
+ <ToastAction
15
+ altText="Restart the kernel to use the updated dependencies"
16
+ onClick={restartKernel}
17
+ >
18
+ Restart Kernel
19
+ </ToastAction>
20
+ );
21
+ };
22
+
23
+ export const showPackageRestartToast = () => {
24
+ toast({
25
+ title: "Changes saved — restart required",
26
+ description: RESTART_REQUIRED_DESCRIPTION,
27
+ duration: Infinity,
28
+ action: <RestartKernelAction />,
29
+ });
30
+ };
31
+
6
32
  export const showAddPackageToast = (
7
33
  packageName: string | string[],
8
34
  error?: string | null,
@@ -3,7 +3,10 @@
3
3
  import { useState } from "react";
4
4
  import { Logger } from "@/utils/Logger";
5
5
  import { useRequestClient } from "../network/requests";
6
- import { showAddPackageToast } from "./toast-components";
6
+ import {
7
+ showAddPackageToast,
8
+ showPackageRestartToast,
9
+ } from "./toast-components";
7
10
 
8
11
  export function useInstallPackages(): {
9
12
  handleInstallPackages: (
@@ -30,7 +33,9 @@ export function useInstallPackages(): {
30
33
  // response only carries an aggregate success/error. Report a single
31
34
  // toast covering all requested packages rather than implying a
32
35
  // per-package outcome we don't actually have.
33
- if (response.success) {
36
+ if (response.restartRequired) {
37
+ showPackageRestartToast();
38
+ } else if (response.success) {
34
39
  showAddPackageToast(packages);
35
40
  } else {
36
41
  showAddPackageToast(packages, response.error);
@@ -276,6 +276,17 @@ describe("RuntimeManager", () => {
276
276
  });
277
277
 
278
278
  describe("getTerminalWsURL", () => {
279
+ it("should include dimensions in the terminal handshake", () => {
280
+ const runtime = new RuntimeManager(mockConfig);
281
+ const url = runtime.getTerminalWsURL({ rows: 30, cols: 140 });
282
+
283
+ expect(Object.fromEntries(url.searchParams)).toEqual({
284
+ access_token: "test-token",
285
+ rows: "30",
286
+ cols: "140",
287
+ });
288
+ });
289
+
279
290
  it("should return terminal WebSocket URL", () => {
280
291
  const runtime = new RuntimeManager(mockConfig);
281
292
  const url = runtime.getTerminalWsURL();
@@ -183,8 +183,13 @@ export class RuntimeManager {
183
183
  /**
184
184
  * The WebSocket URL of the terminal.
185
185
  */
186
- getTerminalWsURL(): URL {
187
- return this.formatWsURL("/terminal/ws");
186
+ getTerminalWsURL(size?: { rows: number; cols: number }): URL {
187
+ const url = this.formatWsURL("/terminal/ws");
188
+ if (size) {
189
+ url.searchParams.set("rows", String(size.rows));
190
+ url.searchParams.set("cols", String(size.cols));
191
+ }
192
+ return url;
188
193
  }
189
194
 
190
195
  /**
@@ -649,6 +649,7 @@ export class PyodideBridge implements RunRequests, EditRequests {
649
649
  tags: [],
650
650
  version: null,
651
651
  },
652
+ context: { kind: "package-manager", name: "micropip" },
652
653
  };
653
654
  };
654
655