@marimo-team/islands 0.23.15-dev4 → 0.23.15-dev40

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 (92) hide show
  1. package/dist/{ConnectedDataExplorerComponent-CU4fZJzG.js → ConnectedDataExplorerComponent-7p7rt9iw.js} +4 -4
  2. package/dist/{ErrorBoundary-DE6tzZf-.js → ErrorBoundary-B_CAG7_e.js} +1 -1
  3. package/dist/{ImageComparisonComponent-CHrI72em.js → ImageComparisonComponent-1i5IBhon.js} +173 -129
  4. package/dist/assets/__vite-browser-external-CjNAy01L.js +1 -0
  5. package/dist/assets/worker-B3XPCb6y.js +98 -0
  6. package/dist/{chat-ui-DinOvbWu.js → chat-ui-C0z13gJB.js} +3111 -3111
  7. package/dist/{code-visibility-Dk8cVOny.js → code-visibility-CSsHnVZu.js} +946 -864
  8. package/dist/{constants-T20xxyNf.js → debounce-BOD3DbfP.js} +1 -24
  9. package/dist/{formats-Dzx4J_z1.js → formats-CitsMtUm.js} +1 -1
  10. package/dist/{glide-data-editor-CjTu7ukN.js → glide-data-editor-CwZz71BD.js} +3 -3
  11. package/dist/{html-to-image-_wGfk8V-.js → html-to-image-CEo5pRYw.js} +2291 -2250
  12. package/dist/{input-DtsN7xm-.js → input-BGPrFH3g.js} +2 -2
  13. package/dist/main.js +1297 -1201
  14. package/dist/{mermaid-BYqXy_NE.js → mermaid-UdmxG2PZ.js} +2 -2
  15. package/dist/{process-output-Mh4UrjwM.js → process-output-BOvvilRG.js} +1 -1
  16. package/dist/{reveal-component-B5DXLyO7.js → reveal-component-DGjJVNMQ.js} +618 -605
  17. package/dist/{spec-Cz-Bj1JI.js → spec-DSs9v0xx.js} +1 -1
  18. package/dist/style.css +1 -1
  19. package/dist/{toDate-CWNNlFEX.js → toDate-BRJgtOGm.js} +14 -5
  20. package/dist/{useAsyncData-KfHB8wQR.js → useAsyncData-BSOyAbac.js} +1 -1
  21. package/dist/{useDeepCompareMemoize-nJMtxhm4.js → useDeepCompareMemoize-BTLeWzuR.js} +1 -1
  22. package/dist/{useLifecycle-DegSo0lV.js → useLifecycle-C6wHjkhW.js} +1 -1
  23. package/dist/{useTheme-6eZ3GOTS.js → useTheme-Df_vGflw.js} +62 -27
  24. package/dist/{vega-component-DzyyM9fc.js → vega-component-BAvmvTjO.js} +6 -6
  25. package/package.json +1 -1
  26. package/src/components/chat/__tests__/chat-utils.test.ts +244 -1
  27. package/src/components/chat/__tests__/message-queue.test.tsx +121 -0
  28. package/src/components/chat/chat-panel.tsx +196 -67
  29. package/src/components/chat/chat-utils.ts +111 -2
  30. package/src/components/editor/SortableCell.tsx +6 -2
  31. package/src/components/editor/__tests__/output-persistence.test.tsx +241 -0
  32. package/src/components/editor/actions/__tests__/pair-with-agent-commands.test.ts +153 -0
  33. package/src/components/editor/actions/pair-with-agent-commands.ts +109 -0
  34. package/src/components/editor/actions/pair-with-agent-modal.tsx +20 -64
  35. package/src/components/editor/ai/add-cell-with-ai.tsx +14 -5
  36. package/src/components/editor/ai/ai-completion-editor.tsx +4 -1
  37. package/src/components/editor/cell/cell-context-menu.tsx +7 -0
  38. package/src/components/editor/chrome/panels/packages-panel.tsx +11 -1
  39. package/src/components/editor/columns/__tests__/cell-column.test.tsx +105 -0
  40. package/src/components/editor/columns/cell-column.tsx +34 -4
  41. package/src/components/editor/columns/sortable-column.tsx +24 -4
  42. package/src/components/editor/file-tree/download.ts +46 -0
  43. package/src/components/editor/file-tree/file-explorer.tsx +3 -21
  44. package/src/components/editor/file-tree/file-viewer.tsx +4 -27
  45. package/src/components/editor/navigation/__tests__/navigation.test.ts +33 -0
  46. package/src/components/editor/navigation/navigation.ts +8 -1
  47. package/src/components/editor/notebook-cell.tsx +203 -106
  48. package/src/components/editor/renderers/__tests__/cells-renderer.test.tsx +66 -0
  49. package/src/components/editor/renderers/cell-array.tsx +26 -13
  50. package/src/components/editor/renderers/cells-renderer.tsx +8 -2
  51. package/src/components/editor/renderers/slides-layout/__tests__/plugin.test.ts +29 -0
  52. package/src/components/editor/renderers/slides-layout/types.ts +4 -0
  53. package/src/components/editor/renderers/vertical-layout/vertical-layout.tsx +19 -19
  54. package/src/components/slides/reveal-component.tsx +37 -4
  55. package/src/components/slides/slide-form.tsx +72 -0
  56. package/src/components/ui/toast.tsx +11 -3
  57. package/src/components/ui/toaster.tsx +65 -9
  58. package/src/core/cells/__tests__/utils.test.ts +59 -0
  59. package/src/core/cells/utils.ts +51 -0
  60. package/src/core/codemirror/completion/__tests__/signature-hint.test.ts +97 -3
  61. package/src/core/codemirror/completion/signature-hint.ts +78 -11
  62. package/src/core/constants.ts +6 -0
  63. package/src/core/islands/__tests__/bridge.test.ts +341 -46
  64. package/src/core/islands/__tests__/main.test.ts +176 -0
  65. package/src/core/islands/__tests__/parse.test.ts +105 -0
  66. package/src/core/islands/bootstrap.ts +8 -3
  67. package/src/core/islands/bridge.ts +116 -23
  68. package/src/core/islands/components/__tests__/web-components.test.tsx +214 -0
  69. package/src/core/islands/components/web-components.tsx +76 -15
  70. package/src/core/islands/constants.ts +1 -0
  71. package/src/core/islands/main.ts +69 -3
  72. package/src/core/islands/parse.ts +70 -23
  73. package/src/core/islands/worker/__tests__/controller.test.ts +173 -0
  74. package/src/core/islands/worker/worker.tsx +145 -57
  75. package/src/core/runtime/__tests__/runtime.test.ts +64 -0
  76. package/src/core/runtime/runtime.ts +30 -10
  77. package/src/core/wasm/worker/bootstrap.ts +113 -20
  78. package/src/css/app/Cell.css +10 -0
  79. package/src/hooks/__tests__/useHotkey.test.tsx +88 -0
  80. package/src/hooks/useHotkey.ts +29 -4
  81. package/src/plugins/impl/anywidget/__tests__/host.test.ts +6 -9
  82. package/src/plugins/impl/anywidget/__tests__/widget-binding.test.ts +22 -61
  83. package/src/plugins/impl/anywidget/model-proxy.ts +0 -13
  84. package/src/plugins/impl/anywidget/widget-binding.ts +4 -34
  85. package/src/plugins/impl/image-comparison/ImageComparisonComponent.tsx +53 -2
  86. package/src/plugins/impl/image-comparison/__tests__/ImageComparisonComponent.test.tsx +71 -0
  87. package/src/plugins/impl/matplotlib/__tests__/matplotlib-renderer.test.ts +71 -3
  88. package/src/plugins/impl/matplotlib/matplotlib-renderer.ts +1 -0
  89. package/src/theme/__tests__/useTheme.test.ts +68 -0
  90. package/src/theme/useTheme.ts +16 -1
  91. package/dist/assets/__vite-browser-external-BQCLNwri.js +0 -1
  92. package/dist/assets/worker-DAWRHcPq.js +0 -73
@@ -5,8 +5,7 @@ import { t as __commonJSMin } from "./chunk-BNovOVIE.js";
5
5
  import { _ as Logger } from "./button-BacYv-bE.js";
6
6
  import { t as require_compiler_runtime } from "./compiler-runtime-CEbnTgxf.js";
7
7
  import { u as createLucideIcon } from "./dist--2Bqjvs0.js";
8
- import { r as KnownQueryParams } from "./constants-T20xxyNf.js";
9
- import { S as atom, g as isIslands, h as waitFor, p as store } from "./useTheme-6eZ3GOTS.js";
8
+ import { T as atom, a as KnownQueryParams, g as store, v as waitFor, y as isIslands } from "./useTheme-Df_vGflw.js";
10
9
  import { t as invariant } from "./invariant-wRzNXIsJ.js";
11
10
  var CircleQuestionMark = createLucideIcon("circle-question-mark", [
12
11
  ["circle", {
@@ -516,18 +515,28 @@ var RuntimeManager = class {
516
515
  return isWasm() || isIslands() || isStaticNotebook();
517
516
  }
518
517
  formatHttpURL({ path: e = "", searchParams: b, restrictToKnownQueryParams: x = true }) {
519
- let S = this.httpURL, w = new URLSearchParams(window.location.search);
518
+ let S = this.httpURL, C = new URLSearchParams(window.location.search);
520
519
  if (b) for (let [e2, x2] of b.entries()) S.searchParams.set(e2, x2);
521
- for (let [e2, b2] of w.entries()) x && !Object.values(KnownQueryParams).includes(e2) || S.searchParams.set(e2, b2);
520
+ for (let [e2, b2] of C.entries()) x && !Object.values(KnownQueryParams).includes(e2) || S.searchParams.set(e2, b2);
522
521
  return S.pathname = `${S.pathname.replace(/\/$/, "")}/${e.replace(/^\//, "")}`, S.hash = "", S;
523
522
  }
523
+ appendCrossOriginAuth(e) {
524
+ return !this.isSameOrigin && this.config.authToken && e.searchParams.set(KnownQueryParams.accessToken, this.config.authToken), e;
525
+ }
526
+ formatNavigableHttpURL(e, b) {
527
+ let x = this.formatHttpURL({
528
+ path: e,
529
+ searchParams: b
530
+ });
531
+ return x.searchParams.delete(KnownQueryParams.accessToken), this.appendCrossOriginAuth(x);
532
+ }
524
533
  formatWsURL(e, b) {
525
534
  let x = this.formatHttpURL({
526
535
  path: e,
527
536
  searchParams: b,
528
537
  restrictToKnownQueryParams: false
529
538
  });
530
- return !this.isSameOrigin && this.config.authToken && x.searchParams.set(KnownQueryParams.accessToken, this.config.authToken), asWsUrl(x.toString());
539
+ return asWsUrl(this.appendCrossOriginAuth(x).toString());
531
540
  }
532
541
  getSessionSearchParams(e) {
533
542
  let b = new URL(this.config.url), x = new URLSearchParams(b.search);
@@ -1,7 +1,7 @@
1
1
  import { s as __toESM } from "./chunk-BNovOVIE.js";
2
2
  import { t as require_react } from "./react-DA-nE2FX.js";
3
3
  import { t as require_compiler_runtime } from "./compiler-runtime-CEbnTgxf.js";
4
- import { D as useEvent_default } from "./useTheme-6eZ3GOTS.js";
4
+ import { A as useEvent_default } from "./useTheme-Df_vGflw.js";
5
5
  import { t as invariant } from "./invariant-wRzNXIsJ.js";
6
6
  var import_compiler_runtime = require_compiler_runtime(), import_react = /* @__PURE__ */ __toESM(require_react(), 1), Result = {
7
7
  error(e, s) {
@@ -1,6 +1,6 @@
1
1
  import { s as __toESM } from "./chunk-BNovOVIE.js";
2
2
  import { t as require_react } from "./react-DA-nE2FX.js";
3
- import { E as dequal } from "./useTheme-6eZ3GOTS.js";
3
+ import { k as dequal } from "./useTheme-Df_vGflw.js";
4
4
  var import_react = /* @__PURE__ */ __toESM(require_react(), 1);
5
5
  function useDeepCompareMemoize(e) {
6
6
  let i = import_react.useRef(e);
@@ -4,7 +4,7 @@ import { t as require_react } from "./react-DA-nE2FX.js";
4
4
  import { t as require_compiler_runtime } from "./compiler-runtime-CEbnTgxf.js";
5
5
  import { u as createLucideIcon } from "./dist--2Bqjvs0.js";
6
6
  import { t as require_jsx_runtime } from "./jsx-runtime-DebpN0FN.js";
7
- import { S as atom, b as useSetAtom } from "./useTheme-6eZ3GOTS.js";
7
+ import { C as useSetAtom, T as atom } from "./useTheme-Df_vGflw.js";
8
8
  var Calendar = createLucideIcon("calendar", [
9
9
  ["path", {
10
10
  d: "M8 2v4",
@@ -736,7 +736,39 @@ atom((e) => e(appConfigAtom).width), atom((e) => {
736
736
  var _a;
737
737
  return ((_a = e(resolvedMarimoConfigAtom).server) == null ? void 0 : _a.disable_file_downloads) ?? false;
738
738
  });
739
- var import_compiler_runtime = require_compiler_runtime(), themeAtom = atom((e) => {
739
+ const Constants = {
740
+ githubPage: "https://github.com/marimo-team/marimo",
741
+ releasesPage: "https://github.com/marimo-team/marimo/releases",
742
+ issuesPage: "https://github.com/marimo-team/marimo/issues",
743
+ feedbackForm: "https://marimo.io/feedback",
744
+ discordLink: "https://marimo.io/discord?ref=notebook",
745
+ docsPage: "https://docs.marimo.io",
746
+ youtube: "https://www.youtube.com/@marimo-team",
747
+ molab: "https://molab.marimo.io"
748
+ }, KnownQueryParams = {
749
+ showCode: "show-code",
750
+ includeCode: "include-code",
751
+ sessionId: "session_id",
752
+ kiosk: "kiosk",
753
+ vscode: "vscode",
754
+ filePath: "file",
755
+ accessToken: "access_token",
756
+ viewAs: "view-as",
757
+ showChrome: "show-chrome",
758
+ theme: "theme"
759
+ }, CSSClasses = { outputArea: "output-area" };
760
+ var import_compiler_runtime = require_compiler_runtime();
761
+ const THEMES = [
762
+ "light",
763
+ "dark",
764
+ "system"
765
+ ];
766
+ function themeFromQueryParam() {
767
+ if (typeof window > "u") return;
768
+ let e = new URLSearchParams(window.location.search).get(KnownQueryParams.theme);
769
+ return THEMES.includes(e) ? e : void 0;
770
+ }
771
+ var themeAtom = atom((e) => {
740
772
  if (isIslands()) {
741
773
  if (document.body.classList.contains("dark-mode") || document.body.classList.contains("dark") || document.body.dataset.theme === "dark" || document.body.dataset.mode === "dark" || getVsCodeTheme() === "dark") return "dark";
742
774
  let e2 = window.getComputedStyle(document.body), A = e2.getPropertyValue("color-scheme").trim();
@@ -748,7 +780,7 @@ var import_compiler_runtime = require_compiler_runtime(), themeAtom = atom((e) =
748
780
  }
749
781
  return "light";
750
782
  }
751
- return e(resolvedMarimoConfigAtom).display.theme;
783
+ return themeFromQueryParam() ?? e(resolvedMarimoConfigAtom).display.theme;
752
784
  }), prefersDarkModeAtom = atom(false);
753
785
  function setupThemeListener() {
754
786
  if (typeof window > "u" || !window.matchMedia) return;
@@ -793,31 +825,34 @@ function useTheme() {
793
825
  return e[1] === j ? M = e[2] : (M = { theme: j }, e[1] = j, e[2] = M), M;
794
826
  }
795
827
  export {
796
- createStore as C,
797
- useEvent_default as D,
798
- dequal as E,
799
- atom as S,
800
- getBuildingBlocks as T,
801
- Provider as _,
802
- autoInstantiateAtom as a,
803
- useSetAtom as b,
804
- resolvedMarimoConfigAtom as c,
805
- AppConfigSchema as d,
806
- createDeepEqualAtom as f,
807
- isIslands as g,
808
- waitFor as h,
809
- aiModelConfiguredAtom as i,
810
- useResolvedMarimoConfig as l,
811
- useJotaiEffect as m,
828
+ useEvent_default as A,
829
+ useSetAtom as C,
830
+ buildStore as D,
831
+ createStore as E,
832
+ getBuildingBlocks as O,
833
+ useAtomValue as S,
834
+ atom as T,
835
+ useJotaiEffect as _,
836
+ KnownQueryParams as a,
837
+ Provider as b,
838
+ autoInstantiateAtom as c,
839
+ resolvedMarimoConfigAtom as d,
840
+ useResolvedMarimoConfig as f,
841
+ store as g,
842
+ createDeepEqualAtom as h,
843
+ Constants as i,
844
+ dequal as k,
845
+ getResolvedMarimoConfig as l,
846
+ AppConfigSchema as m,
812
847
  useTheme as n,
813
- getResolvedMarimoConfig as o,
814
- store as p,
815
- aiFeaturesEnabledAtom as r,
816
- localeAtom as s,
848
+ aiFeaturesEnabledAtom as o,
849
+ userConfigAtom as p,
850
+ CSSClasses as r,
851
+ aiModelConfiguredAtom as s,
817
852
  resolvedThemeAtom as t,
818
- userConfigAtom as u,
819
- useAtom as v,
820
- buildStore as w,
821
- useStore as x,
822
- useAtomValue as y
853
+ localeAtom as u,
854
+ waitFor as v,
855
+ useStore as w,
856
+ useAtom as x,
857
+ isIslands as y
823
858
  };
@@ -2,23 +2,23 @@ import { s as __toESM } from "./chunk-BNovOVIE.js";
2
2
  import { _ as Logger, c as Objects, g as cn, h as Events } from "./button-BacYv-bE.js";
3
3
  import { t as require_react } from "./react-DA-nE2FX.js";
4
4
  import { t as require_compiler_runtime } from "./compiler-runtime-CEbnTgxf.js";
5
- import { c as asRemoteURL, v as CircleQuestionMark } from "./toDate-CWNNlFEX.js";
5
+ import { c as asRemoteURL, v as CircleQuestionMark } from "./toDate-BRJgtOGm.js";
6
6
  import "./react-dom-BTJzcVJ9.js";
7
7
  import { t as require_jsx_runtime } from "./jsx-runtime-DebpN0FN.js";
8
8
  import "./zod-CijjQh4u.js";
9
9
  import { n as ErrorBanner } from "./error-banner-DFPfz_Qf.js";
10
10
  import { t as Tooltip } from "./tooltip-Czds6Qr8.js";
11
- import { i as debounce_default } from "./constants-T20xxyNf.js";
12
- import { D as useEvent_default, n as useTheme } from "./useTheme-6eZ3GOTS.js";
11
+ import { t as debounce_default } from "./debounce-BOD3DbfP.js";
12
+ import { A as useEvent_default, n as useTheme } from "./useTheme-Df_vGflw.js";
13
13
  import { s as uniq } from "./arrays-sEtDRoG4.js";
14
- import { a as isValid, i as AlertTitle, n as Alert, t as arrow } from "./formats-Dzx4J_z1.js";
14
+ import { a as isValid, i as AlertTitle, n as Alert, t as arrow } from "./formats-CitsMtUm.js";
15
15
  import { n as formats } from "./vega-loader.browser-CZ-J8Py3.js";
16
16
  import { a as getContainerWidth, n as vegaLoadData, s as tooltipHandler } from "./loader-Boph2xIS.js";
17
17
  import { t as j } from "./react-vega-B0sAlDTL.js";
18
18
  import "./defaultLocale-u-3osm0P.js";
19
19
  import "./defaultLocale-BoHTsDG6.js";
20
- import { t as useAsyncData } from "./useAsyncData-KfHB8wQR.js";
21
- import { t as useDeepCompareMemoize } from "./useDeepCompareMemoize-nJMtxhm4.js";
20
+ import { t as useAsyncData } from "./useAsyncData-BSOyAbac.js";
21
+ import { t as useDeepCompareMemoize } from "./useDeepCompareMemoize-BTLeWzuR.js";
22
22
  import { t as Semaphore } from "./semaphore-CNDGTzkX.js";
23
23
  var import_compiler_runtime = require_compiler_runtime(), import_react = /* @__PURE__ */ __toESM(require_react(), 1);
24
24
  function fixRelativeUrl(e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marimo-team/islands",
3
- "version": "0.23.15-dev4",
3
+ "version": "0.23.15-dev40",
4
4
  "main": "dist/main.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -2,7 +2,11 @@
2
2
 
3
3
  import type { UIMessage } from "ai";
4
4
  import { describe, expect, it } from "vitest";
5
- import { hasPendingToolCalls } from "../chat-utils";
5
+ import {
6
+ hasPendingToolCalls,
7
+ hasUnresolvedToolCalls,
8
+ shouldFlushQueue,
9
+ } from "../chat-utils";
6
10
 
7
11
  /**
8
12
  * `hasPendingToolCalls` powers `sendAutomaticallyWhen` in `mo.ui.chat`:
@@ -81,6 +85,23 @@ describe("hasPendingToolCalls", () => {
81
85
  ).toBe(true);
82
86
  });
83
87
 
88
+ it("returns true when the user denied a tool approval", () => {
89
+ expect(
90
+ hasPendingToolCalls([
91
+ userMessage("delete it"),
92
+ assistantToolMessage([
93
+ {
94
+ type: "tool-delete_file",
95
+ toolCallId: "call-1",
96
+ state: "output-denied",
97
+ input: { path: "secrets.env" },
98
+ approval: { id: "approval-1", approved: false },
99
+ } as unknown as UIMessage["parts"][number],
100
+ ]),
101
+ ]),
102
+ ).toBe(true);
103
+ });
104
+
84
105
  it("returns true when a tool reached a terminal output state", () => {
85
106
  expect(
86
107
  hasPendingToolCalls([
@@ -267,3 +288,225 @@ describe("hasPendingToolCalls", () => {
267
288
  ).toBe(true);
268
289
  });
269
290
  });
291
+
292
+ describe("hasUnresolvedToolCalls", () => {
293
+ it("returns false when there are no messages", () => {
294
+ expect(hasUnresolvedToolCalls([])).toBe(false);
295
+ });
296
+
297
+ it("returns true while approval is still requested", () => {
298
+ expect(
299
+ hasUnresolvedToolCalls([
300
+ userMessage("delete it"),
301
+ assistantToolMessage([
302
+ {
303
+ type: "tool-delete_file",
304
+ toolCallId: "call-1",
305
+ state: "approval-requested",
306
+ input: { path: "secrets.env" },
307
+ approval: { id: "approval-1" },
308
+ } as unknown as UIMessage["parts"][number],
309
+ ]),
310
+ ]),
311
+ ).toBe(true);
312
+ });
313
+
314
+ it("returns true while a tool is still running", () => {
315
+ expect(
316
+ hasUnresolvedToolCalls([
317
+ userMessage("run it"),
318
+ assistantToolMessage([
319
+ {
320
+ type: "tool-run_query",
321
+ toolCallId: "call-1",
322
+ state: "input-available",
323
+ input: { sql: "select 1" },
324
+ } as unknown as UIMessage["parts"][number],
325
+ ]),
326
+ ]),
327
+ ).toBe(true);
328
+ });
329
+
330
+ it("returns false once tools are ready to round-trip", () => {
331
+ expect(
332
+ hasUnresolvedToolCalls([
333
+ userMessage("run it"),
334
+ assistantToolMessage([
335
+ {
336
+ type: "tool-run_query",
337
+ toolCallId: "call-1",
338
+ state: "output-available",
339
+ input: { sql: "select 1" },
340
+ output: 1,
341
+ } as unknown as UIMessage["parts"][number],
342
+ ]),
343
+ ]),
344
+ ).toBe(false);
345
+ });
346
+
347
+ it("returns false once the user denied a tool approval", () => {
348
+ expect(
349
+ hasUnresolvedToolCalls([
350
+ userMessage("delete it"),
351
+ assistantToolMessage([
352
+ {
353
+ type: "tool-delete_file",
354
+ toolCallId: "call-1",
355
+ state: "output-denied",
356
+ input: { path: "secrets.env" },
357
+ approval: { id: "approval-1", approved: false },
358
+ } as unknown as UIMessage["parts"][number],
359
+ ]),
360
+ ]),
361
+ ).toBe(false);
362
+ });
363
+ });
364
+
365
+ describe("shouldFlushQueue", () => {
366
+ it("flushes when the turn completed without error or pending tools", () => {
367
+ expect(
368
+ shouldFlushQueue({
369
+ isError: false,
370
+ isAbort: false,
371
+ hasPendingToolCalls: false,
372
+ hasUnresolvedToolCalls: false,
373
+ }),
374
+ ).toBe(true);
375
+ });
376
+
377
+ it("does not flush on error", () => {
378
+ expect(
379
+ shouldFlushQueue({
380
+ isError: true,
381
+ isAbort: false,
382
+ hasPendingToolCalls: false,
383
+ hasUnresolvedToolCalls: false,
384
+ }),
385
+ ).toBe(false);
386
+ });
387
+
388
+ it("does not flush while a tool round-trip is still pending", () => {
389
+ expect(
390
+ shouldFlushQueue({
391
+ isError: false,
392
+ isAbort: false,
393
+ hasPendingToolCalls: true,
394
+ hasUnresolvedToolCalls: false,
395
+ }),
396
+ ).toBe(false);
397
+ });
398
+
399
+ it("does not flush while approval is still requested", () => {
400
+ expect(
401
+ shouldFlushQueue({
402
+ isError: false,
403
+ isAbort: false,
404
+ hasPendingToolCalls: false,
405
+ hasUnresolvedToolCalls: true,
406
+ }),
407
+ ).toBe(false);
408
+ });
409
+
410
+ it("flushes on abort even when a tool is still streaming", () => {
411
+ expect(
412
+ shouldFlushQueue({
413
+ isError: false,
414
+ isAbort: true,
415
+ hasPendingToolCalls: false,
416
+ hasUnresolvedToolCalls: true,
417
+ }),
418
+ ).toBe(true);
419
+ });
420
+
421
+ it("does not flush on abort when the run ended in error", () => {
422
+ expect(
423
+ shouldFlushQueue({
424
+ isError: true,
425
+ isAbort: true,
426
+ hasPendingToolCalls: false,
427
+ hasUnresolvedToolCalls: true,
428
+ }),
429
+ ).toBe(false);
430
+ });
431
+
432
+ it("flushes once trailing text is present and tools are resolved", () => {
433
+ const messages = [
434
+ userMessage("run it"),
435
+ assistantToolMessage([
436
+ {
437
+ type: "tool-run_query",
438
+ toolCallId: "call-1",
439
+ state: "output-available",
440
+ input: {},
441
+ output: 1,
442
+ } as unknown as UIMessage["parts"][number],
443
+ { type: "text", text: "Done." },
444
+ ]),
445
+ ];
446
+
447
+ expect(
448
+ shouldFlushQueue({
449
+ isError: false,
450
+ isAbort: false,
451
+ hasPendingToolCalls: hasPendingToolCalls(messages),
452
+ hasUnresolvedToolCalls: hasUnresolvedToolCalls(messages),
453
+ }),
454
+ ).toBe(true);
455
+ });
456
+
457
+ it("blocks flush while text trails a still-running tool, then allows it after resolution", () => {
458
+ const running = [
459
+ userMessage("run it"),
460
+ assistantToolMessage([
461
+ {
462
+ type: "tool-run_query",
463
+ toolCallId: "call-1",
464
+ state: "input-available",
465
+ input: {},
466
+ } as unknown as UIMessage["parts"][number],
467
+ { type: "text", text: "Running your query..." },
468
+ ]),
469
+ ];
470
+ const resolved = [
471
+ userMessage("run it"),
472
+ assistantToolMessage([
473
+ {
474
+ type: "tool-run_query",
475
+ toolCallId: "call-1",
476
+ state: "output-available",
477
+ input: {},
478
+ output: 1,
479
+ } as unknown as UIMessage["parts"][number],
480
+ { type: "text", text: "Running your query..." },
481
+ ]),
482
+ ];
483
+
484
+ expect(
485
+ shouldFlushQueue({
486
+ isError: false,
487
+ isAbort: false,
488
+ hasPendingToolCalls: hasPendingToolCalls(running),
489
+ hasUnresolvedToolCalls: hasUnresolvedToolCalls(running),
490
+ }),
491
+ ).toBe(false);
492
+ expect(
493
+ shouldFlushQueue({
494
+ isError: false,
495
+ isAbort: false,
496
+ hasPendingToolCalls: hasPendingToolCalls(resolved),
497
+ hasUnresolvedToolCalls: hasUnresolvedToolCalls(resolved),
498
+ }),
499
+ ).toBe(true);
500
+ });
501
+
502
+ it("does not flush on error even when tools are also pending", () => {
503
+ expect(
504
+ shouldFlushQueue({
505
+ isError: true,
506
+ isAbort: false,
507
+ hasPendingToolCalls: true,
508
+ hasUnresolvedToolCalls: true,
509
+ }),
510
+ ).toBe(false);
511
+ });
512
+ });
@@ -0,0 +1,121 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { act, renderHook } from "@testing-library/react";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import { type ChatMessagePart, useMessageQueue } from "../chat-utils";
6
+
7
+ function textPart(text: string): ChatMessagePart {
8
+ return { type: "text", text };
9
+ }
10
+
11
+ describe("useMessageQueue", () => {
12
+ it("initializes empty", () => {
13
+ const { result } = renderHook(() => useMessageQueue());
14
+ expect(result.current.messages).toEqual([]);
15
+ });
16
+
17
+ it("enqueues messages in order with unique ids", () => {
18
+ const { result } = renderHook(() => useMessageQueue());
19
+
20
+ act(() => result.current.enqueue([textPart("first")]));
21
+ act(() => result.current.enqueue([textPart("second")]));
22
+
23
+ const [a, b] = result.current.messages;
24
+ expect(a.parts).toEqual([textPart("first")]);
25
+ expect(b.parts).toEqual([textPart("second")]);
26
+ expect(a.id).toBeTypeOf("string");
27
+ expect(a.id).not.toBe(b.id);
28
+ });
29
+
30
+ it("flushNext sends the oldest message's parts and dequeues it", () => {
31
+ const { result } = renderHook(() => useMessageQueue());
32
+ const send = vi.fn();
33
+
34
+ act(() => result.current.enqueue([textPart("first")]));
35
+ act(() => result.current.enqueue([textPart("second")]));
36
+ act(() => result.current.flushNext(send));
37
+
38
+ expect(send).toHaveBeenCalledTimes(1);
39
+ expect(send).toHaveBeenCalledWith([textPart("first")]);
40
+ expect(result.current.messages).toEqual([
41
+ expect.objectContaining({ parts: [textPart("second")] }),
42
+ ]);
43
+ });
44
+
45
+ it("flushNext sees a message enqueued in the same act (before re-render)", () => {
46
+ const { result } = renderHook(() => useMessageQueue());
47
+ const send = vi.fn();
48
+
49
+ // Mirrors enqueue + onFinish in the same tick: the ref must be updated
50
+ // synchronously inside enqueue, not only on the next render.
51
+ act(() => {
52
+ result.current.enqueue([textPart("queued while finishing")]);
53
+ result.current.flushNext(send);
54
+ });
55
+
56
+ expect(send).toHaveBeenCalledTimes(1);
57
+ expect(send).toHaveBeenCalledWith([textPart("queued while finishing")]);
58
+ expect(result.current.messages).toEqual([]);
59
+ });
60
+
61
+ it("drains sequentially, even across synchronous flushNext calls", () => {
62
+ const { result } = renderHook(() => useMessageQueue());
63
+ const send = vi.fn();
64
+
65
+ act(() => result.current.enqueue([textPart("first")]));
66
+ act(() => result.current.enqueue([textPart("second")]));
67
+
68
+ // Two flushes in the same act: the ref must stay in sync so the second
69
+ // flush releases the next message rather than re-sending the first.
70
+ act(() => {
71
+ result.current.flushNext(send);
72
+ result.current.flushNext(send);
73
+ });
74
+
75
+ expect(send).toHaveBeenNthCalledWith(1, [textPart("first")]);
76
+ expect(send).toHaveBeenNthCalledWith(2, [textPart("second")]);
77
+ expect(result.current.messages).toEqual([]);
78
+ });
79
+
80
+ it("flushNext on an empty queue is a no-op", () => {
81
+ const { result } = renderHook(() => useMessageQueue());
82
+ const send = vi.fn();
83
+
84
+ act(() => result.current.flushNext(send));
85
+
86
+ expect(send).not.toHaveBeenCalled();
87
+ expect(result.current.messages).toEqual([]);
88
+ });
89
+
90
+ it("clear empties the queue", () => {
91
+ const { result } = renderHook(() => useMessageQueue());
92
+
93
+ act(() => result.current.enqueue([textPart("first")]));
94
+ act(() => result.current.enqueue([textPart("second")]));
95
+ act(() => result.current.clear());
96
+
97
+ expect(result.current.messages).toEqual([]);
98
+ expect(result.current.hasQueuedRef.current).toBe(false);
99
+ });
100
+
101
+ it("hasQueuedRef stays in sync when enqueue and flush run in the same act", () => {
102
+ const { result } = renderHook(() => useMessageQueue());
103
+ const send = vi.fn();
104
+
105
+ act(() => {
106
+ result.current.enqueue([textPart("first")]);
107
+ result.current.enqueue([textPart("second")]);
108
+ });
109
+ expect(result.current.hasQueuedRef.current).toBe(true);
110
+
111
+ act(() => {
112
+ result.current.flushNext(send);
113
+ });
114
+ expect(result.current.hasQueuedRef.current).toBe(true);
115
+
116
+ act(() => {
117
+ result.current.flushNext(send);
118
+ });
119
+ expect(result.current.hasQueuedRef.current).toBe(false);
120
+ });
121
+ });