@marimo-team/islands 0.23.15-dev33 → 0.23.15-dev5

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 (85) hide show
  1. package/dist/{ConnectedDataExplorerComponent-7p7rt9iw.js → ConnectedDataExplorerComponent-CU4fZJzG.js} +4 -4
  2. package/dist/{ErrorBoundary-B_CAG7_e.js → ErrorBoundary-DE6tzZf-.js} +1 -1
  3. package/dist/{ImageComparisonComponent-1i5IBhon.js → ImageComparisonComponent-CHrI72em.js} +129 -173
  4. package/dist/assets/__vite-browser-external-BQCLNwri.js +1 -0
  5. package/dist/assets/worker-DAWRHcPq.js +73 -0
  6. package/dist/{chat-ui-C0wkSwxB.js → chat-ui-DinOvbWu.js} +3111 -3111
  7. package/dist/{code-visibility-BI94mK4u.js → code-visibility-DBuB5MVY.js} +864 -946
  8. package/dist/{debounce-BOD3DbfP.js → constants-T20xxyNf.js} +24 -1
  9. package/dist/{formats-BX3uDQbB.js → formats-Dzx4J_z1.js} +1 -1
  10. package/dist/{glide-data-editor-CwZz71BD.js → glide-data-editor-CjTu7ukN.js} +3 -3
  11. package/dist/{html-to-image-D8Q5SLSJ.js → html-to-image-_wGfk8V-.js} +2241 -2277
  12. package/dist/{input-BGPrFH3g.js → input-DtsN7xm-.js} +2 -2
  13. package/dist/main.js +1201 -1297
  14. package/dist/{mermaid-UdmxG2PZ.js → mermaid-BYqXy_NE.js} +2 -2
  15. package/dist/{process-output-C_A12w9J.js → process-output-Mh4UrjwM.js} +1 -1
  16. package/dist/{reveal-component-ELidGmPF.js → reveal-component-DfOZrkd6.js} +605 -618
  17. package/dist/{spec-DSs9v0xx.js → spec-Cz-Bj1JI.js} +1 -1
  18. package/dist/style.css +1 -1
  19. package/dist/{toDate-CKpRx4TS.js → toDate-CWNNlFEX.js} +4 -3
  20. package/dist/{useAsyncData-BSOyAbac.js → useAsyncData-KfHB8wQR.js} +1 -1
  21. package/dist/{useDeepCompareMemoize-BTLeWzuR.js → useDeepCompareMemoize-nJMtxhm4.js} +1 -1
  22. package/dist/{useLifecycle-C6wHjkhW.js → useLifecycle-DegSo0lV.js} +1 -1
  23. package/dist/{useTheme-Df_vGflw.js → useTheme-6eZ3GOTS.js} +27 -62
  24. package/dist/{vega-component-DNHEV0u0.js → vega-component-DzyyM9fc.js} +6 -6
  25. package/package.json +1 -1
  26. package/src/components/chat/__tests__/chat-utils.test.ts +1 -244
  27. package/src/components/chat/chat-panel.tsx +67 -196
  28. package/src/components/chat/chat-utils.ts +2 -111
  29. package/src/components/editor/SortableCell.tsx +2 -6
  30. package/src/components/editor/actions/pair-with-agent-modal.tsx +64 -20
  31. package/src/components/editor/ai/add-cell-with-ai.tsx +5 -14
  32. package/src/components/editor/ai/ai-completion-editor.tsx +1 -4
  33. package/src/components/editor/cell/cell-context-menu.tsx +0 -7
  34. package/src/components/editor/chrome/panels/packages-panel.tsx +1 -11
  35. package/src/components/editor/columns/cell-column.tsx +4 -34
  36. package/src/components/editor/columns/sortable-column.tsx +4 -24
  37. package/src/components/editor/navigation/__tests__/navigation.test.ts +0 -33
  38. package/src/components/editor/navigation/navigation.ts +1 -8
  39. package/src/components/editor/notebook-cell.tsx +106 -203
  40. package/src/components/editor/renderers/cell-array.tsx +13 -26
  41. package/src/components/editor/renderers/cells-renderer.tsx +2 -8
  42. package/src/components/editor/renderers/slides-layout/__tests__/plugin.test.ts +0 -29
  43. package/src/components/editor/renderers/slides-layout/types.ts +0 -4
  44. package/src/components/editor/renderers/vertical-layout/vertical-layout.tsx +19 -19
  45. package/src/components/slides/reveal-component.tsx +4 -37
  46. package/src/components/slides/slide-form.tsx +0 -72
  47. package/src/core/cells/__tests__/utils.test.ts +0 -59
  48. package/src/core/cells/utils.ts +0 -51
  49. package/src/core/codemirror/completion/__tests__/signature-hint.test.ts +3 -97
  50. package/src/core/codemirror/completion/signature-hint.ts +11 -78
  51. package/src/core/constants.ts +0 -6
  52. package/src/core/islands/__tests__/bridge.test.ts +46 -341
  53. package/src/core/islands/__tests__/parse.test.ts +0 -105
  54. package/src/core/islands/bootstrap.ts +3 -8
  55. package/src/core/islands/bridge.ts +23 -116
  56. package/src/core/islands/components/web-components.tsx +15 -76
  57. package/src/core/islands/constants.ts +0 -1
  58. package/src/core/islands/main.ts +3 -69
  59. package/src/core/islands/parse.ts +23 -70
  60. package/src/core/islands/worker/worker.tsx +57 -145
  61. package/src/core/wasm/worker/bootstrap.ts +20 -113
  62. package/src/css/app/Cell.css +0 -10
  63. package/src/hooks/useHotkey.ts +4 -29
  64. package/src/plugins/impl/anywidget/__tests__/host.test.ts +9 -6
  65. package/src/plugins/impl/anywidget/__tests__/widget-binding.test.ts +61 -22
  66. package/src/plugins/impl/anywidget/model-proxy.ts +13 -0
  67. package/src/plugins/impl/anywidget/widget-binding.ts +34 -4
  68. package/src/plugins/impl/image-comparison/ImageComparisonComponent.tsx +2 -53
  69. package/src/plugins/impl/matplotlib/__tests__/matplotlib-renderer.test.ts +3 -71
  70. package/src/plugins/impl/matplotlib/matplotlib-renderer.ts +0 -1
  71. package/src/theme/useTheme.ts +1 -16
  72. package/dist/assets/__vite-browser-external-CjNAy01L.js +0 -1
  73. package/dist/assets/worker-B3XPCb6y.js +0 -98
  74. package/src/components/chat/__tests__/message-queue.test.tsx +0 -121
  75. package/src/components/editor/__tests__/output-persistence.test.tsx +0 -241
  76. package/src/components/editor/actions/__tests__/pair-with-agent-commands.test.ts +0 -153
  77. package/src/components/editor/actions/pair-with-agent-commands.ts +0 -109
  78. package/src/components/editor/columns/__tests__/cell-column.test.tsx +0 -105
  79. package/src/components/editor/renderers/__tests__/cells-renderer.test.tsx +0 -66
  80. package/src/core/islands/__tests__/main.test.ts +0 -176
  81. package/src/core/islands/components/__tests__/web-components.test.tsx +0 -214
  82. package/src/core/islands/worker/__tests__/controller.test.ts +0 -173
  83. package/src/hooks/__tests__/useHotkey.test.tsx +0 -88
  84. package/src/plugins/impl/image-comparison/__tests__/ImageComparisonComponent.test.tsx +0 -71
  85. package/src/theme/__tests__/useTheme.test.ts +0 -68
@@ -86,53 +86,20 @@ import {
86
86
  import { renderUIMessage } from "./chat-display";
87
87
  import { ChatHistoryPopover } from "./chat-history-popover";
88
88
  import {
89
- type ChatMessagePart,
90
89
  convertToFileUIPart,
91
90
  generateChatTitle,
92
91
  handleToolCall,
93
92
  hasPendingToolCalls,
94
- hasUnresolvedToolCalls,
95
93
  isLastMessageReasoning,
96
94
  PROVIDERS_THAT_SUPPORT_ATTACHMENTS,
97
- type QueuedUserMessage,
98
- shouldFlushQueue,
99
95
  useFileState,
100
- useMessageQueue,
101
96
  } from "./chat-utils";
102
97
  import { getCodes } from "@/core/codemirror/copilot/getCodes";
103
98
  import { focusInputAndMoveToEnd } from "@/core/codemirror/utils";
104
- import ScrollToBottomButton from "./acp/scroll-to-bottom-button";
105
99
 
106
100
  // Default mode for the AI
107
101
  const DEFAULT_MODE = "manual";
108
102
 
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
-
136
103
  interface ChatHeaderProps {
137
104
  onNewChat: () => void;
138
105
  activeChatId: ChatId | undefined;
@@ -520,19 +487,12 @@ const ChatPanelBody = () => {
520
487
  const [pendingPrompt, setPendingPrompt] = useAtom(pendingAiPromptAtom);
521
488
  const [input, setInput] = useState("");
522
489
  const [newThreadInput, setNewThreadInput] = useState("");
523
- const [isScrolledToBottom, setIsScrolledToBottom] = useState(true);
524
490
  const { files, addFiles, clearFiles, removeFile } = useFileState();
525
- const {
526
- messages: queuedMessages,
527
- enqueue: enqueueUserMessage,
528
- flushNext: flushNextQueuedMessage,
529
- clear: clearQueuedMessages,
530
- hasQueuedRef,
531
- } = useMessageQueue();
532
491
  const newThreadInputRef = useRef<ReactCodeMirrorRef>(null);
533
492
  const newMessageInputRef = useRef<ReactCodeMirrorRef>(null);
534
493
  const scrollContainerRef = useRef<HTMLDivElement>(null);
535
494
  const fileInputRef = useRef<HTMLInputElement>(null);
495
+ const messagesEndRef = useRef<HTMLDivElement>(null);
536
496
  const runtimeManager = useRuntimeManager();
537
497
  const { invokeAiTool, sendRun } = useRequestClient();
538
498
  const { openModal, closeModal } = useImperativeModal();
@@ -600,7 +560,7 @@ const ChatPanelBody = () => {
600
560
  };
601
561
  },
602
562
  }),
603
- onFinish: ({ messages, isError, isAbort }) => {
563
+ onFinish: ({ messages }) => {
604
564
  setChatState((prev) => {
605
565
  return replaceMessagesInChat({
606
566
  chatState: prev,
@@ -608,7 +568,6 @@ const ChatPanelBody = () => {
608
568
  messages: messages,
609
569
  });
610
570
  });
611
- tryFlushQueuedMessages(messages, { isError, isAbort });
612
571
  },
613
572
  onToolCall: async ({ toolCall }) => {
614
573
  await handleToolCall({
@@ -627,103 +586,23 @@ const ChatPanelBody = () => {
627
586
  },
628
587
  });
629
588
 
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
-
655
589
  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
- });
695
590
 
696
591
  // Check if we're currently streaming reasoning in the latest message
697
592
  const isStreamingReasoning =
698
593
  isLoading && messages.length > 0 && isLastMessageReasoning(messages);
699
594
 
700
- // Pin to the bottom while the user is already there.
701
- useEffect(() => {
702
- setIsScrolledToBottom(true);
703
- clearQueuedMessages();
704
- }, [activeChatId, clearQueuedMessages]);
705
-
595
+ // Scroll to the latest chat message at the bottom
706
596
  useEffect(() => {
707
- if (!isScrolledToBottom) {
708
- return;
709
- }
710
- const frame = requestAnimationFrame(() => {
711
- scrollToBottom();
712
- });
713
- return () => cancelAnimationFrame(frame);
714
- }, [messages, queuedMessages, isLoading, isScrolledToBottom, scrollToBottom]);
597
+ const scrollToBottom = () => {
598
+ if (scrollContainerRef.current) {
599
+ const container = scrollContainerRef.current;
600
+ container.scrollTop = container.scrollHeight;
601
+ }
602
+ };
715
603
 
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]);
604
+ requestAnimationFrame(scrollToBottom);
605
+ }, [activeChatId]);
727
606
 
728
607
  const startNewChatState = useEvent((initialMessage: string) => {
729
608
  const now = Date.now();
@@ -775,7 +654,6 @@ const ChatPanelBody = () => {
775
654
  setActiveChat(null);
776
655
  setInput("");
777
656
  setNewThreadInput("");
778
- clearQueuedMessages();
779
657
  clearFiles();
780
658
  });
781
659
 
@@ -821,12 +699,15 @@ const ChatPanelBody = () => {
821
699
  const { contextPart, attachments } = await resolveChatContext(newValue);
822
700
 
823
701
  e?.preventDefault();
824
- submitOrQueue([
825
- { type: "text", text: newValue },
826
- ...(contextPart ? [contextPart] : []),
827
- ...(fileParts ?? []),
828
- ...attachments,
829
- ]);
702
+ sendMessage({
703
+ role: "user",
704
+ parts: [
705
+ { type: "text", text: newValue },
706
+ ...(contextPart ? [contextPart] : []),
707
+ ...(fileParts ?? []),
708
+ ...attachments,
709
+ ],
710
+ });
830
711
  setInput("");
831
712
  clearFiles();
832
713
  },
@@ -856,11 +737,14 @@ const ChatPanelBody = () => {
856
737
  setInput(newThreadInput);
857
738
  }
858
739
  const { contextPart, attachments } = await resolveChatContext(prompt);
859
- submitOrQueue([
860
- { type: "text", text: prompt },
861
- ...(contextPart ? [contextPart] : []),
862
- ...attachments,
863
- ]);
740
+ sendMessage({
741
+ role: "user",
742
+ parts: [
743
+ { type: "text", text: prompt },
744
+ ...(contextPart ? [contextPart] : []),
745
+ ...attachments,
746
+ ],
747
+ });
864
748
  });
865
749
 
866
750
  const isNewThread = messages.length === 0;
@@ -940,62 +824,49 @@ const ChatPanelBody = () => {
940
824
  />
941
825
  </TooltipProvider>
942
826
 
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} />
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}
956
836
  </div>
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
- ))}
837
+ <PairWithAgentCallout onPairWithAgent={handlePairWithAgent} />
838
+ </div>
839
+ )}
971
840
 
972
- {queuedMessages.map((message) => (
973
- <QueuedMessageDisplay key={message.id} message={message} />
974
- ))}
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
+ ))}
975
853
 
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>
854
+ {isLoading && (
855
+ <div className="flex justify-center py-4">
856
+ <Loader2 className="h-4 w-4 animate-spin" />
857
+ </div>
858
+ )}
991
859
 
992
- <ScrollToBottomButton
993
- isVisible={
994
- !isScrolledToBottom &&
995
- (messages.length > 0 || queuedMessages.length > 0)
996
- }
997
- onScrollToBottom={() => scrollToBottom(true)}
998
- />
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
+ )}
868
+
869
+ <div ref={messagesEndRef} />
999
870
  </div>
1000
871
 
1001
872
  {isLoading && (
@@ -7,10 +7,9 @@ import {
7
7
  isToolUIPart,
8
8
  lastAssistantMessageIsCompleteWithApprovalResponses,
9
9
  lastAssistantMessageIsCompleteWithToolCalls,
10
- type ToolUIPart,
11
10
  type UIMessage,
12
11
  } from "ai";
13
- import { useRef, useState } from "react";
12
+ import { useState } from "react";
14
13
  import useEvent from "react-use-event-hook";
15
14
  import type { ProviderId } from "@/core/ai/ids/ids";
16
15
  import type { ToolNotebookContext } from "@/core/ai/tools/base";
@@ -21,7 +20,6 @@ import type {
21
20
  } from "@/core/network/types";
22
21
  import { blobToString } from "@/utils/fileToBase64";
23
22
  import { Logger } from "@/utils/Logger";
24
- import { generateUUID } from "@/utils/uuid";
25
23
  import { getAICompletionBodyWithAttachments } from "../editor/ai/completion-utils";
26
24
  import { toast } from "../ui/use-toast";
27
25
 
@@ -188,44 +186,10 @@ export function hasPendingToolCalls(messages: UIMessage[]): boolean {
188
186
  }
189
187
  return (
190
188
  lastAssistantMessageIsCompleteWithToolCalls({ messages }) ||
191
- lastAssistantMessageIsCompleteWithApprovalResponses({ messages }) ||
192
- (lastPart.state === "output-denied" && !lastPart.providerExecuted)
189
+ lastAssistantMessageIsCompleteWithApprovalResponses({ messages })
193
190
  );
194
191
  }
195
192
 
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
-
229
193
  export function useFileState() {
230
194
  const [files, setFiles] = useState<File[]>([]);
231
195
 
@@ -253,76 +217,3 @@ export function useFileState() {
253
217
 
254
218
  return { files, addFiles, clearFiles, removeFile };
255
219
  }
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,10 +15,6 @@ 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;
22
18
  }
23
19
 
24
20
  /**
@@ -46,7 +42,7 @@ function isTransformNoop(transform: Transform | null) {
46
42
 
47
43
  export const SortableCell = React.forwardRef(
48
44
  (
49
- { cellId, canMoveX, disabled, ...props }: Props,
45
+ { cellId, canMoveX, ...props }: Props,
50
46
  ref: React.ForwardedRef<HTMLDivElement>,
51
47
  ) => {
52
48
  // This hook re-renders every time _any_ cell is dragged,
@@ -58,7 +54,7 @@ export const SortableCell = React.forwardRef(
58
54
  transform,
59
55
  transition,
60
56
  isDragging,
61
- } = useSortable({ id: cellId.toString(), disabled });
57
+ } = useSortable({ id: cellId.toString() });
62
58
 
63
59
  // Perf:
64
60
  // If the transform is a noop, keep it as null
@@ -14,20 +14,67 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
14
14
  import { copyToClipboard } from "@/utils/copy";
15
15
  import { Events } from "@/utils/events";
16
16
  import { Tooltip } from "@/components/ui/tooltip";
17
+ import { assertNever } from "@/utils/assertNever";
17
18
  import { asRemoteURL, useRuntimeManager } from "@/core/runtime/config";
18
19
  import { API } from "@/core/network/api";
19
- import { getSessionId } from "@/core/kernel/session";
20
- import {
21
- AGENT_LABELS,
22
- AGENT_TABS,
23
- type AgentTab,
24
- type ConnectionInfo,
25
- getRawPrompt,
26
- getTerminalCommand,
27
- maskToken,
28
- SKILL_INSTALL,
29
- TERMINAL_TABS,
30
- } from "./pair-with-agent-commands";
20
+
21
+ type AgentTab = "claude" | "codex" | "opencode" | "prompt";
22
+
23
+ const TERMINAL_TABS = ["claude", "codex", "opencode"] as const;
24
+
25
+ function getMarimoCommand(): string {
26
+ return import.meta.env.DEV ? "uv run marimo" : "uvx marimo@latest";
27
+ }
28
+
29
+ function getTerminalCommand(
30
+ agent: Exclude<AgentTab, "prompt">,
31
+ url: string,
32
+ withToken: boolean,
33
+ ): string {
34
+ const tokenFlag = withToken ? " --with-token" : "";
35
+ const base = `${getMarimoCommand()} pair prompt --url '${url}'${tokenFlag}`;
36
+ switch (agent) {
37
+ case "claude":
38
+ return `claude "$(${base} --claude)"`;
39
+ case "codex":
40
+ return `codex "$(${base} --codex)"`;
41
+ case "opencode":
42
+ return `opencode --prompt "$(${base} --opencode)"`;
43
+ default:
44
+ assertNever(agent);
45
+ }
46
+ }
47
+
48
+ function getRawPrompt(url: string, token: string | null): string {
49
+ const tokenHint = token
50
+ ? `\n\nUse this auth token when calling \`execute-code.sh\`: \`execute-code.sh --url '${url}' --token '${token}'\`.`
51
+ : "";
52
+ return [
53
+ "Use the /marimo-pair skill to pair-program on a running marimo notebook.",
54
+ "",
55
+ `Connect to the notebook at: ${url}`,
56
+ "",
57
+ `Use \`execute-code.sh --url ${url}\` from the marimo-pair skill to execute code in the notebook.${tokenHint}`,
58
+ "",
59
+ "Once you are connected, send a fun toast (mo.status.toast(...)) to the user inside marimo letting them know you're ready to pair.",
60
+ ].join("\n");
61
+ }
62
+
63
+ function maskToken(token: string): string {
64
+ if (token.length <= 4) {
65
+ return "****";
66
+ }
67
+ return `${"*".repeat(Math.min(token.length - 4, 8))}${token.slice(-4)}`;
68
+ }
69
+
70
+ const SKILL_INSTALL = "npx skills add marimo-team/marimo-pair";
71
+
72
+ const AGENT_LABELS: Record<AgentTab, string> = {
73
+ claude: "Claude",
74
+ codex: "Codex",
75
+ opencode: "OpenCode",
76
+ prompt: "Prompt",
77
+ };
31
78
 
32
79
  function useAuthToken(): string | null {
33
80
  const [token, setToken] = useState<string | null>(null);
@@ -51,10 +98,7 @@ export const PairWithAgentModal: React.FC<{
51
98
  const runtimeManager = useRuntimeManager();
52
99
  const authToken = useAuthToken();
53
100
  const hasToken = Boolean(authToken);
54
- const connection: ConnectionInfo = {
55
- url: runtimeManager.httpURL.toString(),
56
- sessionId: getSessionId(),
57
- };
101
+ const remoteUrl = runtimeManager.httpURL.toString();
58
102
 
59
103
  return (
60
104
  <DialogContent className="sm:max-w-2xl">
@@ -81,7 +125,7 @@ export const PairWithAgentModal: React.FC<{
81
125
  onValueChange={(v) => setActiveTab(v as AgentTab)}
82
126
  >
83
127
  <TabsList className="w-full">
84
- {AGENT_TABS.map((tab) => (
128
+ {(["claude", "codex", "opencode", "prompt"] as const).map((tab) => (
85
129
  <TabsTrigger key={tab} value={tab} className="flex-1">
86
130
  {AGENT_LABELS[tab]}
87
131
  </TabsTrigger>
@@ -103,7 +147,7 @@ export const PairWithAgentModal: React.FC<{
103
147
  </Step>
104
148
  <Step index={2} title="Run in your terminal">
105
149
  <CommandBlock
106
- command={getTerminalCommand(tab, connection, hasToken)}
150
+ command={getTerminalCommand(tab, remoteUrl, hasToken)}
107
151
  />
108
152
  </Step>
109
153
  {hasToken && authToken && (
@@ -135,9 +179,9 @@ export const PairWithAgentModal: React.FC<{
135
179
  }
136
180
  >
137
181
  <CommandBlock
138
- command={getRawPrompt(connection, authToken)}
182
+ command={getRawPrompt(remoteUrl, authToken)}
139
183
  display={getRawPrompt(
140
- connection,
184
+ remoteUrl,
141
185
  authToken ? maskToken(authToken) : null,
142
186
  )}
143
187
  multiline={true}
@@ -231,13 +231,6 @@ export const AddCellWithAI: React.FC<{
231
231
 
232
232
  const handleDeclineCompletion = () => {
233
233
  deleteAllStagedCells();
234
- // Focus the input so the user can refine the prompt.
235
- inputRef.current?.view?.focus();
236
- };
237
-
238
- const handleClose = () => {
239
- deleteAllStagedCells();
240
- onClose();
241
234
  };
242
235
 
243
236
  const inputComponent = (
@@ -245,7 +238,10 @@ export const AddCellWithAI: React.FC<{
245
238
  <SparklesIcon className="size-4 text-(--blue-11) mr-2" />
246
239
  <PromptInput
247
240
  inputRef={inputRef}
248
- onClose={handleClose}
241
+ onClose={() => {
242
+ deleteAllStagedCells();
243
+ onClose();
244
+ }}
249
245
  value={input}
250
246
  onChange={(newValue) => {
251
247
  setInput(newValue);
@@ -258,12 +254,7 @@ export const AddCellWithAI: React.FC<{
258
254
  hasCompletion,
259
255
  })}
260
256
  />
261
- <Button
262
- variant="text"
263
- size="sm"
264
- className="mb-0 px-1"
265
- onClick={handleClose}
266
- >
257
+ <Button variant="text" size="sm" className="mb-0 px-1" onClick={onClose}>
267
258
  <XIcon className="size-4" />
268
259
  </Button>
269
260
  </div>