@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
@@ -86,20 +86,53 @@ import {
86
86
  import { renderUIMessage } from "./chat-display";
87
87
  import { ChatHistoryPopover } from "./chat-history-popover";
88
88
  import {
89
+ type ChatMessagePart,
89
90
  convertToFileUIPart,
90
91
  generateChatTitle,
91
92
  handleToolCall,
92
93
  hasPendingToolCalls,
94
+ hasUnresolvedToolCalls,
93
95
  isLastMessageReasoning,
94
96
  PROVIDERS_THAT_SUPPORT_ATTACHMENTS,
97
+ type QueuedUserMessage,
98
+ shouldFlushQueue,
95
99
  useFileState,
100
+ useMessageQueue,
96
101
  } from "./chat-utils";
97
102
  import { getCodes } from "@/core/codemirror/copilot/getCodes";
98
103
  import { focusInputAndMoveToEnd } from "@/core/codemirror/utils";
104
+ import ScrollToBottomButton from "./acp/scroll-to-bottom-button";
99
105
 
100
106
  // Default mode for the AI
101
107
  const DEFAULT_MODE = "manual";
102
108
 
109
+ const QueuedMessageDisplay = memo(
110
+ ({ message }: { message: QueuedUserMessage }) => {
111
+ const textParts = message.parts.filter(
112
+ (p): p is TextUIPart => p.type === "text",
113
+ );
114
+ const content = textParts.map((p) => p.text).join("\n");
115
+ const fileParts = message.parts.filter(
116
+ (p): p is FileUIPart => p.type === "file",
117
+ );
118
+
119
+ return (
120
+ <div className="flex justify-end">
121
+ <div className="w-[95%] bg-background border border-dashed p-2 rounded-sm opacity-70">
122
+ {fileParts.map((filePart, idx) => (
123
+ <AttachmentRenderer attachment={filePart} key={idx} />
124
+ ))}
125
+ <div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
126
+ <span className="wrap-break-word">{content}</span>
127
+ <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" />
128
+ </div>
129
+ </div>
130
+ </div>
131
+ );
132
+ },
133
+ );
134
+ QueuedMessageDisplay.displayName = "QueuedMessageDisplay";
135
+
103
136
  interface ChatHeaderProps {
104
137
  onNewChat: () => void;
105
138
  activeChatId: ChatId | undefined;
@@ -487,12 +520,19 @@ const ChatPanelBody = () => {
487
520
  const [pendingPrompt, setPendingPrompt] = useAtom(pendingAiPromptAtom);
488
521
  const [input, setInput] = useState("");
489
522
  const [newThreadInput, setNewThreadInput] = useState("");
523
+ const [isScrolledToBottom, setIsScrolledToBottom] = useState(true);
490
524
  const { files, addFiles, clearFiles, removeFile } = useFileState();
525
+ const {
526
+ messages: queuedMessages,
527
+ enqueue: enqueueUserMessage,
528
+ flushNext: flushNextQueuedMessage,
529
+ clear: clearQueuedMessages,
530
+ hasQueuedRef,
531
+ } = useMessageQueue();
491
532
  const newThreadInputRef = useRef<ReactCodeMirrorRef>(null);
492
533
  const newMessageInputRef = useRef<ReactCodeMirrorRef>(null);
493
534
  const scrollContainerRef = useRef<HTMLDivElement>(null);
494
535
  const fileInputRef = useRef<HTMLInputElement>(null);
495
- const messagesEndRef = useRef<HTMLDivElement>(null);
496
536
  const runtimeManager = useRuntimeManager();
497
537
  const { invokeAiTool, sendRun } = useRequestClient();
498
538
  const { openModal, closeModal } = useImperativeModal();
@@ -560,7 +600,7 @@ const ChatPanelBody = () => {
560
600
  };
561
601
  },
562
602
  }),
563
- onFinish: ({ messages }) => {
603
+ onFinish: ({ messages, isError, isAbort }) => {
564
604
  setChatState((prev) => {
565
605
  return replaceMessagesInChat({
566
606
  chatState: prev,
@@ -568,6 +608,7 @@ const ChatPanelBody = () => {
568
608
  messages: messages,
569
609
  });
570
610
  });
611
+ tryFlushQueuedMessages(messages, { isError, isAbort });
571
612
  },
572
613
  onToolCall: async ({ toolCall }) => {
573
614
  await handleToolCall({
@@ -586,23 +627,103 @@ const ChatPanelBody = () => {
586
627
  },
587
628
  });
588
629
 
630
+ const sendUserMessage = useEvent((parts: ChatMessagePart[]) => {
631
+ sendMessage({ role: "user", parts });
632
+ });
633
+
634
+ const tryFlushQueuedMessages = useEvent(
635
+ (
636
+ chatMessages: typeof messages,
637
+ opts: { isError: boolean; isAbort: boolean },
638
+ ) => {
639
+ if (!hasQueuedRef.current) {
640
+ return;
641
+ }
642
+ if (
643
+ shouldFlushQueue({
644
+ isError: opts.isError,
645
+ isAbort: opts.isAbort,
646
+ hasPendingToolCalls: hasPendingToolCalls(chatMessages),
647
+ hasUnresolvedToolCalls: hasUnresolvedToolCalls(chatMessages),
648
+ })
649
+ ) {
650
+ flushNextQueuedMessage(sendUserMessage);
651
+ }
652
+ },
653
+ );
654
+
589
655
  const isLoading = status === "submitted" || status === "streaming";
656
+ // Read via a ref so the queue-vs-send decision stays correct even when it is
657
+ // made after an `await` (e.g. resolving @-context), by which point the render
658
+ // closure's `isLoading` may be stale.
659
+ const isLoadingRef = useRef(isLoading);
660
+ isLoadingRef.current = isLoading;
661
+
662
+ const submitOrQueue = useEvent((parts: ChatMessagePart[]) => {
663
+ if (isLoadingRef.current || hasQueuedRef.current) {
664
+ enqueueUserMessage(parts);
665
+ } else {
666
+ sendUserMessage(parts);
667
+ }
668
+ });
669
+
670
+ const handleScroll = useEvent(() => {
671
+ const container = scrollContainerRef.current;
672
+ if (!container) {
673
+ return;
674
+ }
675
+
676
+ const { scrollTop, scrollHeight, clientHeight } = container;
677
+ const hasOverflow = scrollHeight > clientHeight;
678
+ const isAtBottom = hasOverflow
679
+ ? Math.abs(scrollHeight - clientHeight - scrollTop) < 5
680
+ : true;
681
+ setIsScrolledToBottom(isAtBottom);
682
+ });
683
+
684
+ const scrollToBottom = useEvent((smooth = false) => {
685
+ const container = scrollContainerRef.current;
686
+ if (!container) {
687
+ return;
688
+ }
689
+
690
+ container.scrollTo({
691
+ top: container.scrollHeight,
692
+ behavior: smooth ? "smooth" : "auto",
693
+ });
694
+ });
590
695
 
591
696
  // Check if we're currently streaming reasoning in the latest message
592
697
  const isStreamingReasoning =
593
698
  isLoading && messages.length > 0 && isLastMessageReasoning(messages);
594
699
 
595
- // Scroll to the latest chat message at the bottom
700
+ // Pin to the bottom while the user is already there.
596
701
  useEffect(() => {
597
- const scrollToBottom = () => {
598
- if (scrollContainerRef.current) {
599
- const container = scrollContainerRef.current;
600
- container.scrollTop = container.scrollHeight;
601
- }
602
- };
702
+ setIsScrolledToBottom(true);
703
+ clearQueuedMessages();
704
+ }, [activeChatId, clearQueuedMessages]);
603
705
 
604
- requestAnimationFrame(scrollToBottom);
605
- }, [activeChatId]);
706
+ useEffect(() => {
707
+ if (!isScrolledToBottom) {
708
+ return;
709
+ }
710
+ const frame = requestAnimationFrame(() => {
711
+ scrollToBottom();
712
+ });
713
+ return () => cancelAnimationFrame(frame);
714
+ }, [messages, queuedMessages, isLoading, isScrolledToBottom, scrollToBottom]);
715
+
716
+ // Retry when tool parts resolve after the stream ends (e.g. assistant text
717
+ // trails a still-running tool call, so `onFinish` alone cannot flush).
718
+ useEffect(() => {
719
+ if (isLoading) {
720
+ return;
721
+ }
722
+ tryFlushQueuedMessages(messages, {
723
+ isError: error != null,
724
+ isAbort: false,
725
+ });
726
+ }, [messages, isLoading, error, tryFlushQueuedMessages]);
606
727
 
607
728
  const startNewChatState = useEvent((initialMessage: string) => {
608
729
  const now = Date.now();
@@ -654,6 +775,7 @@ const ChatPanelBody = () => {
654
775
  setActiveChat(null);
655
776
  setInput("");
656
777
  setNewThreadInput("");
778
+ clearQueuedMessages();
657
779
  clearFiles();
658
780
  });
659
781
 
@@ -699,15 +821,12 @@ const ChatPanelBody = () => {
699
821
  const { contextPart, attachments } = await resolveChatContext(newValue);
700
822
 
701
823
  e?.preventDefault();
702
- sendMessage({
703
- role: "user",
704
- parts: [
705
- { type: "text", text: newValue },
706
- ...(contextPart ? [contextPart] : []),
707
- ...(fileParts ?? []),
708
- ...attachments,
709
- ],
710
- });
824
+ submitOrQueue([
825
+ { type: "text", text: newValue },
826
+ ...(contextPart ? [contextPart] : []),
827
+ ...(fileParts ?? []),
828
+ ...attachments,
829
+ ]);
711
830
  setInput("");
712
831
  clearFiles();
713
832
  },
@@ -737,14 +856,11 @@ const ChatPanelBody = () => {
737
856
  setInput(newThreadInput);
738
857
  }
739
858
  const { contextPart, attachments } = await resolveChatContext(prompt);
740
- sendMessage({
741
- role: "user",
742
- parts: [
743
- { type: "text", text: prompt },
744
- ...(contextPart ? [contextPart] : []),
745
- ...attachments,
746
- ],
747
- });
859
+ submitOrQueue([
860
+ { type: "text", text: prompt },
861
+ ...(contextPart ? [contextPart] : []),
862
+ ...attachments,
863
+ ]);
748
864
  });
749
865
 
750
866
  const isNewThread = messages.length === 0;
@@ -824,49 +940,62 @@ const ChatPanelBody = () => {
824
940
  />
825
941
  </TooltipProvider>
826
942
 
827
- <div
828
- className="flex-1 px-3 bg-(--slate-1) gap-4 py-3 flex flex-col overflow-y-auto"
829
- ref={scrollContainerRef}
830
- >
831
- {isNewThread && (
832
- <div className="flex flex-col gap-2">
833
- <div className="rounded-md border bg-background">
834
- {filesPills}
835
- {chatInput}
943
+ <div className="flex-1 flex flex-col overflow-hidden relative min-h-0">
944
+ <div
945
+ className="flex-1 px-3 bg-(--slate-1) gap-4 py-3 flex flex-col overflow-y-auto"
946
+ ref={scrollContainerRef}
947
+ onScroll={handleScroll}
948
+ >
949
+ {isNewThread && (
950
+ <div className="flex flex-col gap-2">
951
+ <div className="rounded-md border bg-background">
952
+ {filesPills}
953
+ {chatInput}
954
+ </div>
955
+ <PairWithAgentCallout onPairWithAgent={handlePairWithAgent} />
836
956
  </div>
837
- <PairWithAgentCallout onPairWithAgent={handlePairWithAgent} />
838
- </div>
839
- )}
840
-
841
- {messages.map((message, idx) => (
842
- <ChatMessageDisplay
843
- key={message.id}
844
- message={message}
845
- index={idx}
846
- onEdit={handleMessageEdit}
847
- isStreamingReasoning={isStreamingReasoning}
848
- isLast={idx === messages.length - 1}
849
- isActive={isLoading}
850
- addToolApprovalResponse={addToolApprovalResponse}
851
- />
852
- ))}
957
+ )}
958
+
959
+ {messages.map((message, idx) => (
960
+ <ChatMessageDisplay
961
+ key={message.id}
962
+ message={message}
963
+ index={idx}
964
+ onEdit={handleMessageEdit}
965
+ isStreamingReasoning={isStreamingReasoning}
966
+ isLast={idx === messages.length - 1}
967
+ isActive={isLoading}
968
+ addToolApprovalResponse={addToolApprovalResponse}
969
+ />
970
+ ))}
853
971
 
854
- {isLoading && (
855
- <div className="flex justify-center py-4">
856
- <Loader2 className="h-4 w-4 animate-spin" />
857
- </div>
858
- )}
972
+ {queuedMessages.map((message) => (
973
+ <QueuedMessageDisplay key={message.id} message={message} />
974
+ ))}
859
975
 
860
- {error && (
861
- <div className="flex items-center justify-center space-x-2 mb-4">
862
- <ErrorBanner error={error || new Error("Unknown error")} />
863
- <Button variant="outline" size="sm" onClick={handleReload}>
864
- Retry
865
- </Button>
866
- </div>
867
- )}
976
+ {isLoading && (
977
+ <div className="flex justify-center py-4">
978
+ <Loader2 className="h-4 w-4 animate-spin" />
979
+ </div>
980
+ )}
981
+
982
+ {error && (
983
+ <div className="flex items-center justify-center space-x-2 mb-4">
984
+ <ErrorBanner error={error || new Error("Unknown error")} />
985
+ <Button variant="outline" size="sm" onClick={handleReload}>
986
+ Retry
987
+ </Button>
988
+ </div>
989
+ )}
990
+ </div>
868
991
 
869
- <div ref={messagesEndRef} />
992
+ <ScrollToBottomButton
993
+ isVisible={
994
+ !isScrolledToBottom &&
995
+ (messages.length > 0 || queuedMessages.length > 0)
996
+ }
997
+ onScrollToBottom={() => scrollToBottom(true)}
998
+ />
870
999
  </div>
871
1000
 
872
1001
  {isLoading && (
@@ -7,9 +7,10 @@ import {
7
7
  isToolUIPart,
8
8
  lastAssistantMessageIsCompleteWithApprovalResponses,
9
9
  lastAssistantMessageIsCompleteWithToolCalls,
10
+ type ToolUIPart,
10
11
  type UIMessage,
11
12
  } from "ai";
12
- import { useState } from "react";
13
+ import { useRef, useState } from "react";
13
14
  import useEvent from "react-use-event-hook";
14
15
  import type { ProviderId } from "@/core/ai/ids/ids";
15
16
  import type { ToolNotebookContext } from "@/core/ai/tools/base";
@@ -20,6 +21,7 @@ import type {
20
21
  } from "@/core/network/types";
21
22
  import { blobToString } from "@/utils/fileToBase64";
22
23
  import { Logger } from "@/utils/Logger";
24
+ import { generateUUID } from "@/utils/uuid";
23
25
  import { getAICompletionBodyWithAttachments } from "../editor/ai/completion-utils";
24
26
  import { toast } from "../ui/use-toast";
25
27
 
@@ -186,10 +188,44 @@ export function hasPendingToolCalls(messages: UIMessage[]): boolean {
186
188
  }
187
189
  return (
188
190
  lastAssistantMessageIsCompleteWithToolCalls({ messages }) ||
189
- lastAssistantMessageIsCompleteWithApprovalResponses({ messages })
191
+ lastAssistantMessageIsCompleteWithApprovalResponses({ messages }) ||
192
+ (lastPart.state === "output-denied" && !lastPart.providerExecuted)
190
193
  );
191
194
  }
192
195
 
196
+ /**
197
+ * True when the assistant is still waiting on tool execution or user approval.
198
+ * Unlike `hasPendingToolCalls` (ready to auto-resume), these states must block
199
+ * releasing queued user messages.
200
+ */
201
+ export function hasUnresolvedToolCalls(messages: UIMessage[]): boolean {
202
+ const lastMessage = messages.at(-1);
203
+ if (!lastMessage || lastMessage.role !== "assistant") {
204
+ return false;
205
+ }
206
+ return lastMessage.parts.some(
207
+ (part) => isToolUIPart(part) && isUnresolvedToolState(part.state),
208
+ );
209
+ }
210
+
211
+ function isUnresolvedToolState(state: ToolUIPart["state"]): boolean {
212
+ switch (state) {
213
+ case "approval-requested":
214
+ case "input-streaming":
215
+ case "input-available":
216
+ return true;
217
+ case "approval-responded":
218
+ case "output-available":
219
+ case "output-error":
220
+ case "output-denied":
221
+ return false;
222
+ default: {
223
+ const _exhaustive: never = state;
224
+ return _exhaustive;
225
+ }
226
+ }
227
+ }
228
+
193
229
  export function useFileState() {
194
230
  const [files, setFiles] = useState<File[]>([]);
195
231
 
@@ -217,3 +253,76 @@ export function useFileState() {
217
253
 
218
254
  return { files, addFiles, clearFiles, removeFile };
219
255
  }
256
+
257
+ export type ChatMessagePart = UIMessage["parts"][number];
258
+
259
+ export interface QueuedUserMessage {
260
+ id: string;
261
+ parts: ChatMessagePart[];
262
+ }
263
+
264
+ /**
265
+ * Decide whether the message queue should release its next message after a
266
+ * chat request settles. `onFinish` fires on every completion — including
267
+ * aborts, errors, and each intermediate tool-call round — so the queue must
268
+ * only advance once the assistant has genuinely finished its turn.
269
+ */
270
+ export function shouldFlushQueue(opts: {
271
+ isError: boolean;
272
+ isAbort: boolean;
273
+ hasPendingToolCalls: boolean;
274
+ hasUnresolvedToolCalls: boolean;
275
+ }): boolean {
276
+ if (opts.isError) {
277
+ return false;
278
+ }
279
+ // User stopped the stream; release queued input even if a tool part is still
280
+ // mid-flight (e.g. `input-streaming`).
281
+ if (opts.isAbort) {
282
+ return true;
283
+ }
284
+ return !opts.hasPendingToolCalls && !opts.hasUnresolvedToolCalls;
285
+ }
286
+
287
+ /**
288
+ * Queue of user messages typed while the assistant is still responding.
289
+ * Messages are enqueued in order and released one at a time.
290
+ */
291
+ export function useMessageQueue() {
292
+ const [messages, setMessages] = useState<QueuedUserMessage[]>([]);
293
+ // Mirror the queue in a ref so `flushNext` reads the latest value even when
294
+ // invoked from a callback (e.g. `onFinish`) captured on an earlier render.
295
+ const messagesRef = useRef<QueuedUserMessage[]>([]);
296
+ const hasQueuedRef = useRef(false);
297
+ messagesRef.current = messages;
298
+ hasQueuedRef.current = messages.length > 0;
299
+
300
+ const enqueue = useEvent((parts: ChatMessagePart[]) => {
301
+ setMessages((prev) => {
302
+ const next = [...prev, { id: generateUUID(), parts }];
303
+ messagesRef.current = next;
304
+ hasQueuedRef.current = true;
305
+ return next;
306
+ });
307
+ });
308
+
309
+ const flushNext = useEvent((send: (parts: ChatMessagePart[]) => void) => {
310
+ const queue = messagesRef.current;
311
+ if (queue.length === 0) {
312
+ return;
313
+ }
314
+ const [next, ...rest] = queue;
315
+ messagesRef.current = rest;
316
+ hasQueuedRef.current = rest.length > 0;
317
+ setMessages(rest);
318
+ send(next.parts);
319
+ });
320
+
321
+ const clear = useEvent(() => {
322
+ messagesRef.current = [];
323
+ hasQueuedRef.current = false;
324
+ setMessages([]);
325
+ });
326
+
327
+ return { messages, enqueue, flushNext, clear, hasQueuedRef };
328
+ }
@@ -15,6 +15,10 @@ interface Props extends React.HTMLAttributes<HTMLDivElement> {
15
15
  children: React.ReactNode;
16
16
  cellId: CellId;
17
17
  canMoveX?: boolean;
18
+ /**
19
+ * If true, dragging is disabled (e.g. while presenting).
20
+ */
21
+ disabled?: boolean;
18
22
  }
19
23
 
20
24
  /**
@@ -42,7 +46,7 @@ function isTransformNoop(transform: Transform | null) {
42
46
 
43
47
  export const SortableCell = React.forwardRef(
44
48
  (
45
- { cellId, canMoveX, ...props }: Props,
49
+ { cellId, canMoveX, disabled, ...props }: Props,
46
50
  ref: React.ForwardedRef<HTMLDivElement>,
47
51
  ) => {
48
52
  // This hook re-renders every time _any_ cell is dragged,
@@ -54,7 +58,7 @@ export const SortableCell = React.forwardRef(
54
58
  transform,
55
59
  transition,
56
60
  isDragging,
57
- } = useSortable({ id: cellId.toString() });
61
+ } = useSortable({ id: cellId.toString(), disabled });
58
62
 
59
63
  // Perf:
60
64
  // If the transform is a noop, keep it as null