@marimo-team/islands 0.25.3-dev266 → 0.25.3-dev267

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 (106) hide show
  1. package/dist/{ConnectedDataExplorerComponent-C_hj7Poi.js → ConnectedDataExplorerComponent-C4yCN38h.js} +1 -1
  2. package/dist/{ErrorBoundary-CvxlC3F1.js → ErrorBoundary-CewXPyL5.js} +64 -69
  3. package/dist/{abnfDiagram-VCTEODGH-DzrZZXRD.js → abnfDiagram-VCTEODGH-o6Ay6Q63.js} +2 -2
  4. package/dist/{any-language-editor-CFeWfC3o.js → any-language-editor-DnG26QXh.js} +1 -1
  5. package/dist/{architectureDiagram-5GKGNRK7-iF_snyFL.js → architectureDiagram-5GKGNRK7-BhiwwlVg.js} +1 -1
  6. package/dist/{chat-ui-DFxNgzXF.js → chat-ui-CbgR5ivy.js} +1964 -1740
  7. package/dist/{chunk-IMKFNOWR-B7xytD4B.js → chunk-IMKFNOWR-WMSMf4Nj.js} +1 -1
  8. package/dist/{chunk-SHT3W25Y-I8ZFP5L1.js → chunk-SHT3W25Y-BRfADrzD.js} +1 -1
  9. package/dist/{chunk-SVP7TREG-Di-uAPdT.js → chunk-SVP7TREG-CuK5QFre.js} +1 -1
  10. package/dist/{chunk-TICWLB2K-BFpUy0ud.js → chunk-TICWLB2K-Bcun_jhJ.js} +1 -1
  11. package/dist/{classDiagram-ZZMXUADV-Cqm4JGpx.js → classDiagram-ZZMXUADV-CgWDbUDT.js} +2 -2
  12. package/dist/{classDiagram-v2-VYDZK3BY-DFHRJpN3.js → classDiagram-v2-VYDZK3BY-DYmc9SFU.js} +2 -2
  13. package/dist/{code-visibility-SRM1JiD_.js → code-visibility-BKBl9Rh6.js} +9 -9
  14. package/dist/{common-yiRhZ6ZZ.js → common-N3Jd_6m9.js} +5944 -5626
  15. package/dist/{cynefinDiagram-5FMLGOSQ-cuGOeTBC.js → cynefinDiagram-5FMLGOSQ-PPKWipjT.js} +1 -1
  16. package/dist/{dagre-GXQ25YYZ-DJ76aC4k.js → dagre-GXQ25YYZ-niRBZh--.js} +1 -1
  17. package/dist/{data-grid-overlay-editor-DLTzcQ4O.js → data-grid-overlay-editor-Cp59CrqZ.js} +1 -1
  18. package/dist/{diagram-S7CK7UJ4-DLLc0sw7.js → diagram-S7CK7UJ4-DPiP9rot.js} +1 -1
  19. package/dist/{diagram-UQ7AKVKN-Tz_d6qmk.js → diagram-UQ7AKVKN-B0WZuaxV.js} +1 -1
  20. package/dist/{diagram-VX7I27RA-D7SzPrDN.js → diagram-VX7I27RA-Rn42WBuN.js} +1 -1
  21. package/dist/{diagram-Z3DM3KII-qr37XV58.js → diagram-Z3DM3KII-BB93--ry.js} +1 -1
  22. package/dist/{dropdown-menu-CkJ7ErY6.js → dropdown-menu-BpImEfn3.js} +1211 -1205
  23. package/dist/{ebnfDiagram-PWID7BFC-BKOQb4J0.js → ebnfDiagram-PWID7BFC-V4t5h6mZ.js} +2 -2
  24. package/dist/{erDiagram-RLTQ6QDP-CM15Vq68.js → erDiagram-RLTQ6QDP-C1MsAKXd.js} +1 -1
  25. package/dist/{error-banner-DbRFOd2O.js → error-banner-DPc9eIw5.js} +3 -3
  26. package/dist/{error-entries-CMoaUX9V.js → error-entries-CJGdD2ky.js} +49 -42
  27. package/dist/{feedback-button-DBUrE4OL.js → feedback-button-Qc1f9iPq.js} +70 -70
  28. package/dist/{flowDiagram-HODETNUW-Dt3N5yFT.js → flowDiagram-HODETNUW-DHjeJP96.js} +2 -2
  29. package/dist/{glide-data-editor-Cfv4HeOh.js → glide-data-editor-hCq3K0sd.js} +2042 -2041
  30. package/dist/{glide-utils-Ba9WD2IN.js → glide-utils-DC3sk6EX.js} +28 -34
  31. package/dist/{globals-dIbBIR5-.js → globals-B2_uMYDY.js} +1 -1
  32. package/dist/{html-to-image-D_5CMreP.js → html-to-image-S7spRQCi.js} +5526 -5828
  33. package/dist/{infoDiagram-27XIBGKW-C7axJMCl.js → infoDiagram-27XIBGKW-Dovv6iKx.js} +1 -1
  34. package/dist/{ishikawaDiagram-5VMMS53U-CdC9Eur7.js → ishikawaDiagram-5VMMS53U-D2nyygsc.js} +1 -1
  35. package/dist/{kanban-definition-UXKFOSKX-BHnMIgD7.js → kanban-definition-UXKFOSKX-KyVx0uTY.js} +1 -1
  36. package/dist/main.js +1288 -1287
  37. package/dist/{mermaid-DFYN_PXd.js → mermaid-B-jKSwGt.js} +33 -33
  38. package/dist/{mindmap-definition-YA3MSWOX-BagHoCGU.js → mindmap-definition-YA3MSWOX-Bz9tKQFl.js} +1 -1
  39. package/dist/{number-overlay-editor-CTiJp5Mj.js → number-overlay-editor-CfNYvbdz.js} +1 -1
  40. package/dist/{pegDiagram-XKGWAZYB-8IIZczip.js → pegDiagram-XKGWAZYB-C_qpq6Tv.js} +2 -2
  41. package/dist/pencil-DcT8AE0h.js +10 -0
  42. package/dist/{pieDiagram-E7YTZNPT-CE6dKBIr.js → pieDiagram-E7YTZNPT-Bp9ucgI4.js} +1 -1
  43. package/dist/{process-output-CGFNVC60.js → process-output-f34bkXTm.js} +2 -2
  44. package/dist/{railroadDiagram-O6MQD6OU-CkexPq3b.js → railroadDiagram-O6MQD6OU-tq-VEH6j.js} +2 -2
  45. package/dist/{requirementDiagram-BXWQKSXE-B0-OkuAe.js → requirementDiagram-BXWQKSXE-DW-vHadb.js} +1 -1
  46. package/dist/{reveal-component-DH09ygHo.js → reveal-component-CUxv-kkP.js} +9 -9
  47. package/dist/{sequenceDiagram-WJ2MYXX4-Dxysg8DX.js → sequenceDiagram-WJ2MYXX4-Dv9U9qRP.js} +1 -1
  48. package/dist/{stateDiagram-D77RDMKH-DTEN3-OJ.js → stateDiagram-D77RDMKH-DTmPQ0EB.js} +2 -2
  49. package/dist/{stateDiagram-v2-MP3YSRHH-vktk3Xc5.js → stateDiagram-v2-MP3YSRHH-CkCif0Iw.js} +2 -2
  50. package/dist/style.css +1 -1
  51. package/dist/{swimlanes-42K2YHIH-Cjveh9OM.js → swimlanes-42K2YHIH-21kCq6l8.js} +1 -1
  52. package/dist/{swimlanesDiagram-VR7AAH4N-Bdr7Ev_2.js → swimlanesDiagram-VR7AAH4N-BpRJP6KU.js} +2 -2
  53. package/dist/{timeline-definition-24CTP7MA-KIuDcefn.js → timeline-definition-24CTP7MA-Ckgh4lS4.js} +1 -1
  54. package/dist/{toast-CY1_Qv41.js → toast-DpczUryP.js} +1 -1
  55. package/dist/{vega-component-C-lqjV0q.js → vega-component-CF9kI6C4.js} +1 -1
  56. package/dist/{vennDiagram-4TSXK5OY-DrIOUHfb.js → vennDiagram-4TSXK5OY-CDei2Bt3.js} +1 -1
  57. package/dist/{wardleyDiagram-VM6X3IG4-CJNjf4OH.js → wardleyDiagram-VM6X3IG4-CXavT601.js} +1 -1
  58. package/dist/{xychartDiagram-S5SC5T6Z-DEshFho-.js → xychartDiagram-S5SC5T6Z-CvJhcZri.js} +1 -1
  59. package/package.json +1 -1
  60. package/src/components/chat/__tests__/chat-display.test.tsx +157 -86
  61. package/src/components/chat/__tests__/chat-utils.test.ts +42 -0
  62. package/src/components/chat/__tests__/turn-timing.test.ts +84 -0
  63. package/src/components/chat/__tests__/use-assistant-turn.test.ts +95 -0
  64. package/src/components/chat/__tests__/user-message.test.tsx +27 -0
  65. package/src/components/chat/acp/agent-panel.tsx +2 -0
  66. package/src/components/chat/agent-activity.tsx +132 -0
  67. package/src/components/chat/chat-display.tsx +125 -96
  68. package/src/components/chat/chat-panel.tsx +123 -106
  69. package/src/components/chat/chat-utils.ts +12 -24
  70. package/src/components/chat/tool-call/__tests__/tool-labels.test.ts +76 -0
  71. package/src/components/chat/tool-call/__tests__/tool-step.test.tsx +57 -0
  72. package/src/components/chat/tool-call/shared.ts +6 -0
  73. package/src/components/chat/tool-call/tool-call-view.tsx +22 -76
  74. package/src/components/chat/tool-call/tool-labels.ts +172 -0
  75. package/src/components/chat/tool-call/tool-step.tsx +145 -0
  76. package/src/components/chat/turn-timing.ts +73 -0
  77. package/src/components/chat/use-assistant-turn.ts +65 -0
  78. package/src/components/chat/use-chat-generation.ts +5 -4
  79. package/src/components/chat/user-message.tsx +145 -0
  80. package/src/components/editor/actions/__tests__/useCellActionButton.test.tsx +18 -1
  81. package/src/components/editor/actions/useCellActionButton.tsx +3 -0
  82. package/src/components/editor/ai/completion-handlers.tsx +0 -37
  83. package/src/components/editor/cell/StagedAICell.tsx +51 -144
  84. package/src/components/editor/chrome/wrapper/__tests__/pending-ai-cells.test.tsx +193 -32
  85. package/src/components/editor/chrome/wrapper/app-chrome.tsx +2 -2
  86. package/src/components/editor/chrome/wrapper/pending-ai-cells.tsx +145 -81
  87. package/src/components/editor/chrome/wrapper/pending-ai-changes-badge.tsx +25 -0
  88. package/src/components/editor/chrome/wrapper/sidebar.tsx +6 -0
  89. package/src/components/editor/links/cell-link.tsx +4 -4
  90. package/src/components/editor/notebook-cell.tsx +3 -11
  91. package/src/components/ui/toast.tsx +1 -1
  92. package/src/core/ai/__tests__/staged-cells.test.ts +74 -126
  93. package/src/core/ai/staged-cells.ts +48 -38
  94. package/src/core/cells/scrollCellIntoView.ts +6 -0
  95. package/src/core/codemirror/placeholder/__tests__/extensions.test.ts +59 -0
  96. package/src/core/codemirror/placeholder/extensions.ts +23 -0
  97. package/src/core/session/__tests__/useNotebookSession.hook.test.tsx +34 -0
  98. package/src/core/session/useNotebookSession.tsx +7 -2
  99. package/src/css/app/Cell.css +7 -36
  100. package/src/plugins/impl/chat/chat-ui.tsx +2 -3
  101. package/src/plugins/impl/common/error-banner.tsx +5 -3
  102. package/src/components/chat/reasoning-accordion.tsx +0 -61
  103. package/src/components/chat/tool-call/tool-activity-group.tsx +0 -49
  104. package/src/components/chat/tool-call/tool-error-card.tsx +0 -81
  105. package/src/components/chat/tool-call/tool-history-row.tsx +0 -147
  106. package/src/components/icons/loading-ellipsis.tsx +0 -46
@@ -20,7 +20,6 @@ import {
20
20
  import { memo, useEffect, useMemo, useRef, useState } from "react";
21
21
  import useEvent from "react-use-event-hook";
22
22
  import { Button } from "@/components/ui/button";
23
- import { LoadingEllipsis } from "@/components/icons/loading-ellipsis";
24
23
  import { replaceMessagesInChat } from "@/core/ai/chat-utils";
25
24
  import { useAIConfigActions } from "@/core/ai/config";
26
25
  import { AI_SDK_UI_THROTTLE_MS } from "@/core/ai/constants";
@@ -43,7 +42,6 @@ import { useRequestClient } from "@/core/network/requests";
43
42
  import { useRuntimeManager } from "@/core/runtime/config";
44
43
  import { isWasm } from "@/core/wasm/utils";
45
44
  import { ErrorBanner } from "@/plugins/impl/common/error-banner";
46
- import { cn } from "@/utils/cn";
47
45
  import { Logger } from "@/utils/Logger";
48
46
  import { AIModelDropdown } from "../ai/ai-model-dropdown";
49
47
  import { useOpenSettingsToTab } from "../app-config/state";
@@ -52,6 +50,7 @@ import {
52
50
  usePairWithAgentModal,
53
51
  } from "../editor/actions/pair-with-agent";
54
52
  import { PromptInput } from "../editor/ai/prompt-input";
53
+ import { PendingAICells } from "../editor/chrome/wrapper/pending-ai-cells";
55
54
  import {
56
55
  addContextCompletion,
57
56
  isContextAttachment,
@@ -76,7 +75,8 @@ import {
76
75
  getAbortReasonFromChunk,
77
76
  resolveChatAbortReason,
78
77
  } from "./chat-abort";
79
- import { renderUIMessage } from "./chat-display";
78
+ import { AgentActivity } from "./agent-activity";
79
+ import { getAnswerText, renderUIMessage } from "./chat-display";
80
80
  import { ChatHistoryPopover } from "./chat-history-popover";
81
81
  import { CapabilitiesPopover } from "./capabilities-popover";
82
82
  import { NotebookAccessSelector } from "./notebook-access-selector";
@@ -87,7 +87,6 @@ import {
87
87
  handleToolCall,
88
88
  hasPendingToolCalls,
89
89
  hasUnresolvedToolCalls,
90
- isLastMessageReasoning,
91
90
  PROVIDERS_THAT_SUPPORT_ATTACHMENTS,
92
91
  type QueuedUserMessage,
93
92
  shouldFlushQueue,
@@ -98,7 +97,9 @@ import { getCodes } from "@/core/codemirror/copilot/getCodes";
98
97
  import { focusInputAndMoveToEnd } from "@/core/codemirror/utils";
99
98
  import ScrollToBottomButton from "./acp/scroll-to-bottom-button";
100
99
  import { useChatControllerId } from "./use-chat-controller-id";
101
- import { useChatGeneration } from "./use-chat-generation";
100
+ import { isChatBusy, useChatGeneration } from "./use-chat-generation";
101
+ import { useAssistantTurn } from "./use-assistant-turn";
102
+ import { UserMessage } from "./user-message";
102
103
 
103
104
  // Default mode for the AI
104
105
  const DEFAULT_MODE = "ask";
@@ -190,9 +191,9 @@ interface ChatMessageProps {
190
191
  message: UIMessage;
191
192
  index: number;
192
193
  onEdit: (index: number, newValue: string) => void;
193
- isStreamingReasoning: boolean;
194
194
  isLast: boolean;
195
195
  isActive: boolean;
196
+ turnStartedAt: number | null;
196
197
  addToolApprovalResponse?: ChatAddToolApproveResponseFunction;
197
198
  }
198
199
 
@@ -201,78 +202,40 @@ const ChatMessageDisplay: React.FC<ChatMessageProps> = memo(
201
202
  message,
202
203
  index,
203
204
  onEdit,
204
- isStreamingReasoning,
205
205
  isLast,
206
206
  isActive,
207
+ turnStartedAt,
207
208
  addToolApprovalResponse,
208
209
  }) => {
209
- const renderUserMessage = (message: UIMessage) => {
210
- const textParts = message.parts?.filter(
211
- (p): p is TextUIPart => p.type === "text",
212
- );
213
- const content = textParts?.map((p) => p.text).join("\n");
214
- const fileParts = message.parts?.filter(
215
- (p): p is FileUIPart => p.type === "file",
216
- );
217
-
210
+ if (message.role === "user") {
218
211
  return (
219
- <div className="w-[95%] bg-background border p-1 rounded-[10px]">
220
- {fileParts?.map((filePart, idx) => (
221
- <AttachmentRenderer attachment={filePart} key={idx} />
222
- ))}
223
- <PromptInput
224
- key={message.id}
225
- value={content}
226
- placeholder="Type your message. Use @ to reference variables and cells."
227
- onChange={() => {
228
- // noop
229
- }}
230
- onSubmit={(_e, newValue) => {
231
- if (!newValue.trim()) {
232
- return;
233
- }
234
- onEdit(index, newValue);
235
- }}
236
- onClose={() => {
237
- // noop
238
- }}
212
+ <div className="group flex justify-end">
213
+ <UserMessage
214
+ message={message}
215
+ canEdit={!isActive}
216
+ onEdit={(newValue) => onEdit(index, newValue)}
239
217
  />
240
218
  </div>
241
219
  );
242
- };
243
-
244
- const renderOtherMessage = (message: UIMessage) => {
245
- const textParts = message.parts.filter(
246
- (p): p is TextUIPart => p.type === "text",
247
- );
248
- const content = textParts.map((p) => p.text).join("\n");
220
+ }
249
221
 
250
- return (
222
+ return (
223
+ <div className="group relative flex justify-start">
251
224
  <div className="w-[95%] wrap-break-word">
252
225
  <div className="absolute right-1 top-1 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity">
253
- <CopyClipboardIcon className="h-3 w-3" value={content || ""} />
226
+ <CopyClipboardIcon
227
+ className="h-3 w-3"
228
+ value={() => getAnswerText(message)}
229
+ />
254
230
  </div>
255
231
  {renderUIMessage({
256
232
  message,
257
- isStreamingReasoning,
258
233
  isLast,
259
234
  isActive,
235
+ turnStartedAt,
260
236
  addToolApprovalResponse,
261
237
  })}
262
238
  </div>
263
- );
264
- };
265
-
266
- return (
267
- <div
268
- className={cn(
269
- "flex group relative",
270
- message.role === "user" ? "justify-end" : "justify-start",
271
- )}
272
- >
273
- {message.role === "user"
274
- ? renderUserMessage(message)
275
- : renderOtherMessage(message)}
276
239
  </div>
277
240
  );
278
241
  },
@@ -483,6 +446,8 @@ const ChatPanelBody = () => {
483
446
  lastAbortReasonRef.current = null;
484
447
  setAbortNotice(null);
485
448
  });
449
+ const turn = useAssistantTurn();
450
+ const resetTurn = turn.reset;
486
451
  const runtimeManager = useRuntimeManager();
487
452
  const { invokeAiTool } = useRequestClient();
488
453
 
@@ -505,6 +470,7 @@ const ChatPanelBody = () => {
505
470
  error,
506
471
  status,
507
472
  regenerate,
473
+ setMessages,
508
474
  stop,
509
475
  addToolOutput,
510
476
  addToolApprovalResponse,
@@ -512,7 +478,8 @@ const ChatPanelBody = () => {
512
478
  } = useChat({
513
479
  id: chatControllerId,
514
480
  throttle: AI_SDK_UI_THROTTLE_MS,
515
- sendAutomaticallyWhen: ({ messages }) => hasPendingToolCalls(messages),
481
+ sendAutomaticallyWhen: ({ messages }) =>
482
+ !turn.hasEnded && hasPendingToolCalls(messages),
516
483
  messages: activeChat?.messages || [], // initial messages
517
484
  transport: new StreamingChunkTransport(
518
485
  {
@@ -559,7 +526,26 @@ const ChatPanelBody = () => {
559
526
  }
560
527
  },
561
528
  ),
562
- onFinish: ({ messages, isError, isAbort, finishReason }) => {
529
+ onFinish: ({
530
+ messages: finishedMessages,
531
+ isError,
532
+ isAbort,
533
+ finishReason,
534
+ }) => {
535
+ // useChat routes every chat's callbacks to the latest one. Its
536
+ // setMessages isn't a React setter: the updater runs synchronously.
537
+ const finished: { messages?: typeof finishedMessages } = {};
538
+ setMessages((current) => {
539
+ if (current.at(-1)?.id !== finishedMessages.at(-1)?.id) {
540
+ return current;
541
+ }
542
+ finished.messages = turn.finish(finishedMessages, { isAbort, isError });
543
+ return finished.messages;
544
+ });
545
+ const { messages } = finished;
546
+ if (!messages) {
547
+ return;
548
+ }
563
549
  setChatState((prev) => {
564
550
  return replaceMessagesInChat({
565
551
  chatState: prev,
@@ -604,6 +590,7 @@ const ChatPanelBody = () => {
604
590
 
605
591
  const sendUserMessage = useEvent((parts: ChatMessagePart[]) => {
606
592
  clearAbortState();
593
+ turn.start();
607
594
  sendMessage({ role: "user", parts });
608
595
  });
609
596
 
@@ -628,11 +615,43 @@ const ChatPanelBody = () => {
628
615
  },
629
616
  );
630
617
 
631
- const isLoading = useChatGeneration(status);
618
+ const isLoading = isChatBusy(status);
619
+ const isTurnActive = turn.isActive({
620
+ isLoading,
621
+ hasError: error != null,
622
+ messages,
623
+ });
624
+ useChatGeneration(isTurnActive);
625
+
626
+ const stopTurn = useEvent(() => {
627
+ if (isLoading) {
628
+ turn.stop();
629
+ void stop();
630
+ return;
631
+ }
632
+ // Between requests there is no stream to abort, so `onFinish` won't run.
633
+ const stopped = turn.finish(messages, { isAbort: true, isError: false });
634
+ setMessages(stopped);
635
+ setChatState((prev) =>
636
+ replaceMessagesInChat({
637
+ chatState: prev,
638
+ chatId: prev.activeChatId,
639
+ messages: stopped,
640
+ }),
641
+ );
642
+ tryFlushQueuedMessages(stopped, { isError: false, isAbort: true });
643
+ });
644
+
645
+ const respondToApproval = useEvent(
646
+ (response: Parameters<ChatAddToolApproveResponseFunction>[0]) => {
647
+ turn.resume(messages.at(-1));
648
+ return addToolApprovalResponse(response);
649
+ },
650
+ );
632
651
  // useEvent reads the latest loading state, even when called after an await
633
652
  // (e.g. resolving @-context) that outlives the original render's closure.
634
653
  const submitOrQueue = useEvent((parts: ChatMessagePart[]) => {
635
- if (isLoading || hasQueuedRef.current) {
654
+ if (isTurnActive || hasQueuedRef.current) {
636
655
  enqueueUserMessage(parts);
637
656
  } else {
638
657
  sendUserMessage(parts);
@@ -665,10 +684,6 @@ const ChatPanelBody = () => {
665
684
  });
666
685
  });
667
686
 
668
- // Check if we're currently streaming reasoning in the latest message
669
- const isStreamingReasoning =
670
- isLoading && messages.length > 0 && isLastMessageReasoning(messages);
671
-
672
687
  // Pin to the bottom while the user is already there.
673
688
  useEffect(() => {
674
689
  setIsScrolledToBottom(true);
@@ -676,6 +691,11 @@ const ChatPanelBody = () => {
676
691
  clearAbortState();
677
692
  }, [activeChatId, clearQueuedMessages, clearAbortState]);
678
693
 
694
+ // Creating a chat keeps the draft's controller, so its turn survives.
695
+ useEffect(() => {
696
+ resetTurn();
697
+ }, [chatControllerId, resetTurn]);
698
+
679
699
  useEffect(() => {
680
700
  if (!isScrolledToBottom) {
681
701
  return;
@@ -730,6 +750,7 @@ const ChatPanelBody = () => {
730
750
  const { contextPart, attachments } =
731
751
  await resolveChatContext(initialMessage);
732
752
 
753
+ turn.start();
733
754
  sendMessage({
734
755
  role: "user",
735
756
  parts: [
@@ -787,6 +808,7 @@ const ChatPanelBody = () => {
787
808
  const { contextPart, attachments } = await resolveChatContext(newValue);
788
809
 
789
810
  const messageId = editedMessage.id;
811
+ turn.start();
790
812
  sendMessage({
791
813
  messageId: messageId, // replace the message
792
814
  role: "user",
@@ -825,6 +847,7 @@ const ChatPanelBody = () => {
825
847
 
826
848
  const handleReload = () => {
827
849
  clearAbortState();
850
+ turn.start();
828
851
  regenerate();
829
852
  };
830
853
 
@@ -889,8 +912,8 @@ const ChatPanelBody = () => {
889
912
  inputRef={newThreadInputRef}
890
913
  setInput={setNewThreadInput}
891
914
  onSubmit={handleNewThreadSubmit}
892
- isLoading={isLoading}
893
- onStop={stop}
915
+ isLoading={isTurnActive}
916
+ onStop={stopTurn}
894
917
  fileInputRef={fileInputRef}
895
918
  onAddFiles={addFiles}
896
919
  onClose={handleOnCloseThread}
@@ -901,8 +924,8 @@ const ChatPanelBody = () => {
901
924
  setInput={setInput}
902
925
  onSubmit={handleChatInputSubmit}
903
926
  inputRef={newMessageInputRef}
904
- isLoading={isLoading}
905
- onStop={stop}
927
+ isLoading={isTurnActive}
928
+ onStop={stopTurn}
906
929
  onClose={() => newMessageInputRef.current?.editor?.blur()}
907
930
  fileInputRef={fileInputRef}
908
931
  onAddFiles={addFiles}
@@ -946,18 +969,35 @@ const ChatPanelBody = () => {
946
969
  ref={scrollContainerRef}
947
970
  onScroll={handleScroll}
948
971
  >
949
- {messages.map((message, idx) => (
950
- <ChatMessageDisplay
951
- key={message.id}
952
- message={message}
953
- index={idx}
954
- onEdit={handleMessageEdit}
955
- isStreamingReasoning={isStreamingReasoning}
956
- isLast={idx === messages.length - 1}
957
- isActive={isLoading}
958
- addToolApprovalResponse={addToolApprovalResponse}
972
+ <TooltipProvider>
973
+ {messages.map((message, idx) => {
974
+ const isLast = idx === messages.length - 1;
975
+ return (
976
+ <ChatMessageDisplay
977
+ key={message.id}
978
+ message={message}
979
+ index={idx}
980
+ onEdit={handleMessageEdit}
981
+ isLast={isLast}
982
+ isActive={isTurnActive}
983
+ turnStartedAt={isLast ? turn.startedAt : null}
984
+ addToolApprovalResponse={respondToApproval}
985
+ />
986
+ );
987
+ })}
988
+ </TooltipProvider>
989
+
990
+ {isTurnActive && messages.at(-1)?.role === "user" && (
991
+ <AgentActivity
992
+ running={true}
993
+ thinking={true}
994
+ timing={
995
+ turn.startedAt == null
996
+ ? undefined
997
+ : { startedAt: turn.startedAt }
998
+ }
959
999
  />
960
- ))}
1000
+ )}
961
1001
 
962
1002
  {queuedMessages.map((message) => (
963
1003
  <QueuedMessageDisplay key={message.id} message={message} />
@@ -990,30 +1030,7 @@ const ChatPanelBody = () => {
990
1030
  />
991
1031
  </div>
992
1032
 
993
- {isLoading && (
994
- <div className="flex shrink-0 items-center gap-2 px-3 pt-2 text-xs text-muted-foreground">
995
- <div className="flex items-center gap-1.5">
996
- <div aria-hidden={true}>
997
- <LoadingEllipsis
998
- size={3}
999
- gap="gap-0.5"
1000
- className="motion-reduce:[&_svg]:animate-none"
1001
- />
1002
- </div>
1003
- <output>Working</output>
1004
- </div>
1005
- <span aria-hidden={true} className="h-3 w-px bg-border" />
1006
- <Button
1007
- variant="text"
1008
- size="xs"
1009
- className="chat-control"
1010
- onClick={stop}
1011
- >
1012
- Stop generation
1013
- </Button>
1014
- </div>
1015
- )}
1016
-
1033
+ {!isTurnActive && <PendingAICells />}
1017
1034
  <div className="shrink-0 p-3">{composer}</div>
1018
1035
  </div>
1019
1036
  );
@@ -57,30 +57,6 @@ export async function convertToFileUIPart(
57
57
  return fileUIParts;
58
58
  }
59
59
 
60
- export function isLastMessageReasoning(messages: UIMessage[]): boolean {
61
- if (messages.length === 0) {
62
- return false;
63
- }
64
-
65
- const lastMessage = messages.at(-1);
66
- if (!lastMessage) {
67
- return false;
68
- }
69
-
70
- if (lastMessage.role !== "assistant" || !lastMessage.parts) {
71
- return false;
72
- }
73
-
74
- const parts = lastMessage.parts;
75
- if (parts.length === 0) {
76
- return false;
77
- }
78
-
79
- // Check if the last part is reasoning
80
- const lastPart = parts[parts.length - 1];
81
- return lastPart.type === "reasoning";
82
- }
83
-
84
60
  function stringifyTextParts(parts: UIMessage["parts"]): string {
85
61
  return parts
86
62
  .map((part) => (part.type === "text" ? part.text : ""))
@@ -198,6 +174,18 @@ export function hasPendingToolCalls(messages: UIMessage[]): boolean {
198
174
  );
199
175
  }
200
176
 
177
+ export function willAutoContinue(messages: UIMessage[]): boolean {
178
+ const lastMessage = messages.at(-1);
179
+ const lastPart =
180
+ lastMessage?.role === "assistant" ? lastMessage.parts.at(-1) : undefined;
181
+ return (
182
+ hasPendingToolCalls(messages) ||
183
+ (lastPart != null &&
184
+ isToolUIPart(lastPart) &&
185
+ lastPart.state === "input-available")
186
+ );
187
+ }
188
+
201
189
  /**
202
190
  * True when the assistant is still waiting on tool execution or user approval.
203
191
  * Unlike `hasPendingToolCalls` (ready to auto-resume), these states must block
@@ -0,0 +1,76 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import { describeToolCall } from "../tool-labels";
5
+
6
+ describe("describeToolCall", () => {
7
+ it("names notebook edits by operation and shows the first line of code", () => {
8
+ const input = {
9
+ edit: {
10
+ type: "add_cell",
11
+ code: "\n def fib(n):\n return n",
12
+ position: {},
13
+ },
14
+ };
15
+ expect(
16
+ describeToolCall("tool-edit_notebook_tool", input, "done"),
17
+ ).toMatchObject({
18
+ label: "Added cell",
19
+ detail: "def fib(n):",
20
+ });
21
+ expect(
22
+ describeToolCall("tool-edit_notebook_tool", input, "running").label,
23
+ ).toBe("Adding cell");
24
+ });
25
+
26
+ it("falls back to a generic edit label while the input is still streaming", () => {
27
+ expect(
28
+ describeToolCall("tool-edit_notebook_tool", { edit: {} }, "running"),
29
+ ).toEqual(expect.objectContaining({ label: "Editing notebook" }));
30
+ expect(
31
+ describeToolCall("tool-edit_notebook_tool", { edit: {} }, "running"),
32
+ ).not.toHaveProperty("detail");
33
+ });
34
+
35
+ it("ignores inherited object keys in tool names and edit types", () => {
36
+ expect(describeToolCall("tool-constructor", {}, "done")).toMatchObject({
37
+ label: "Called",
38
+ detail: "constructor",
39
+ });
40
+ expect(
41
+ describeToolCall(
42
+ "edit_notebook_tool",
43
+ { edit: { type: "toString" } },
44
+ "done",
45
+ ).label,
46
+ ).toBe("Edited notebook");
47
+ });
48
+
49
+ it("summarizes code-mode runs by their first meaningful line", () => {
50
+ const code =
51
+ "import marimo._code_mode as cm\n# add cells\ncm.add_cell('x = 1')";
52
+ expect(
53
+ describeToolCall("tool-execute_code", { code }, "done"),
54
+ ).toMatchObject({
55
+ label: "Ran code",
56
+ detail: "cm.add_cell('x = 1')",
57
+ });
58
+ expect(
59
+ describeToolCall("execute_code", { code: "import numpy as np" }, "done")
60
+ .detail,
61
+ ).toBe("import numpy as np");
62
+ });
63
+
64
+ it("uses plain language for known tools", () => {
65
+ expect(describeToolCall("lint_notebook", {}, "done").label).toBe(
66
+ "Linted notebook",
67
+ );
68
+ });
69
+
70
+ it("shows the raw name for unknown tools such as MCP tools", () => {
71
+ expect(describeToolCall("tool-search_docs", {}, "running")).toMatchObject({
72
+ label: "Calling",
73
+ detail: "search_docs",
74
+ });
75
+ });
76
+ });
@@ -0,0 +1,57 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { fireEvent, render, screen } from "@testing-library/react";
4
+ import { describe, expect, it } from "vitest";
5
+ import { ToolStep } from "../tool-step";
6
+
7
+ const edit = { edit: { type: "add_cell", code: "x = 1", position: {} } };
8
+
9
+ describe("ToolStep", () => {
10
+ it("describes the call and reveals its input and output on demand", () => {
11
+ render(
12
+ <ToolStep
13
+ toolName="tool-edit_notebook_tool"
14
+ state="output-available"
15
+ input={edit}
16
+ result={{ status: "success" }}
17
+ />,
18
+ );
19
+ const row = screen.getByRole("button", { name: /Added cell/ });
20
+ expect(row.getAttribute("aria-expanded")).toBe("false");
21
+ expect(screen.queryByText("Tool Request")).toBeNull();
22
+
23
+ fireEvent.click(row);
24
+ expect(row.getAttribute("aria-expanded")).toBe("true");
25
+ expect(screen.getByText("Tool Request")).toBeTruthy();
26
+ expect(screen.getByText("Tool Result")).toBeTruthy();
27
+ });
28
+
29
+ it("shows the error text of a failed call", () => {
30
+ render(
31
+ <ToolStep
32
+ toolName="lint_notebook"
33
+ state="output-error"
34
+ input={{}}
35
+ errorText="Linter crashed"
36
+ />,
37
+ );
38
+ fireEvent.click(
39
+ screen.getByRole("button", { name: /Linted notebook.*Failed/ }),
40
+ );
41
+ expect(screen.getByText("Linter crashed")).toBeTruthy();
42
+ });
43
+
44
+ it("marks calls cut off by the end of the turn as stopped", () => {
45
+ render(
46
+ <ToolStep
47
+ toolName="tool-edit_notebook_tool"
48
+ state="input-available"
49
+ input={edit}
50
+ isActive={false}
51
+ />,
52
+ );
53
+ expect(
54
+ screen.getByRole("button", { name: /Adding cell.*Stopped/ }),
55
+ ).toBeTruthy();
56
+ });
57
+ });
@@ -8,6 +8,12 @@ export type ToolState = ToolUIPart["state"];
8
8
  // rather than exporting a named type, so we derive ours from there.
9
9
  export type ToolApproval = NonNullable<ToolUIPart["approval"]>;
10
10
 
11
+ export const PENDING_TOOL_STATES: ReadonlySet<ToolState> = new Set([
12
+ "input-streaming",
13
+ "input-available",
14
+ "approval-responded",
15
+ ]);
16
+
11
17
  export function formatToolName(toolName: string): string {
12
18
  return toolName.replace("tool-", "");
13
19
  }