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

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 (184) hide show
  1. package/dist/{ConnectedDataExplorerComponent-C_hj7Poi.js → ConnectedDataExplorerComponent-eLHPh6uu.js} +9 -9
  2. package/dist/{ErrorBoundary-CvxlC3F1.js → ErrorBoundary-CQYAoD7X.js} +64 -69
  3. package/dist/{abnfDiagram-VCTEODGH-DzrZZXRD.js → abnfDiagram-VCTEODGH-hqd0IBtX.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-BzLAFcc0.js} +1 -1
  6. package/dist/{strings-BCKpLFNU.js → assertNever-DIU3JJbt.js} +14 -14
  7. package/dist/{chat-ui-DFxNgzXF.js → chat-ui-BAnLgpTe.js} +1964 -1740
  8. package/dist/{chunk-IMKFNOWR-B7xytD4B.js → chunk-IMKFNOWR-DOeVvuyE.js} +1 -1
  9. package/dist/{chunk-SHT3W25Y-I8ZFP5L1.js → chunk-SHT3W25Y-DZoFI8wR.js} +1 -1
  10. package/dist/{chunk-SVP7TREG-Di-uAPdT.js → chunk-SVP7TREG-CWu86PSW.js} +1 -1
  11. package/dist/{chunk-TICWLB2K-BFpUy0ud.js → chunk-TICWLB2K-BrM0GoP3.js} +1 -1
  12. package/dist/{classDiagram-ZZMXUADV-Cqm4JGpx.js → classDiagram-ZZMXUADV-DYi4V2bB.js} +2 -2
  13. package/dist/{classDiagram-v2-VYDZK3BY-DFHRJpN3.js → classDiagram-v2-VYDZK3BY-tN7FIk-m.js} +2 -2
  14. package/dist/{code-visibility-SRM1JiD_.js → code-visibility-DcZ_RwaH.js} +44 -47
  15. package/dist/{common-yiRhZ6ZZ.js → common-BGeQilvh.js} +5846 -5519
  16. package/dist/{config-CmeG_Pb6.js → config-BsPruWMR.js} +2 -2
  17. package/dist/{createReducer-C_syF8Td.js → createReducer-DdtT_7ji.js} +1 -1
  18. package/dist/{cynefinDiagram-5FMLGOSQ-cuGOeTBC.js → cynefinDiagram-5FMLGOSQ-D5nCnDc-.js} +1 -1
  19. package/dist/{dagre-GXQ25YYZ-DJ76aC4k.js → dagre-GXQ25YYZ-BHkd7f_i.js} +1 -1
  20. package/dist/{data-grid-overlay-editor-DLTzcQ4O.js → data-grid-overlay-editor-Dc4lW4A3.js} +1 -1
  21. package/dist/{diagram-S7CK7UJ4-DLLc0sw7.js → diagram-S7CK7UJ4-D1ALppT2.js} +1 -1
  22. package/dist/{diagram-UQ7AKVKN-Tz_d6qmk.js → diagram-UQ7AKVKN-DLv0b83L.js} +1 -1
  23. package/dist/{diagram-VX7I27RA-D7SzPrDN.js → diagram-VX7I27RA-7XJ7J6qg.js} +1 -1
  24. package/dist/{diagram-Z3DM3KII-qr37XV58.js → diagram-Z3DM3KII-7M7zbHp_.js} +1 -1
  25. package/dist/{dialog-BtprQCUw.js → dialog-DzCj1qQv.js} +1 -1
  26. package/dist/{dropdown-menu-CkJ7ErY6.js → dropdown-menu-CdXE15an.js} +1214 -1208
  27. package/dist/{ebnfDiagram-PWID7BFC-BKOQb4J0.js → ebnfDiagram-PWID7BFC-DHH1qlmV.js} +2 -2
  28. package/dist/{erDiagram-RLTQ6QDP-CM15Vq68.js → erDiagram-RLTQ6QDP-fI_0dGef.js} +1 -1
  29. package/dist/{error-banner-DbRFOd2O.js → error-banner-DPc9eIw5.js} +3 -3
  30. package/dist/{error-entries-CMoaUX9V.js → error-entries-CxN-Nq3V.js} +524 -456
  31. package/dist/{feedback-button-DBUrE4OL.js → feedback-button-BqaeQcvX.js} +77 -77
  32. package/dist/{flowDiagram-HODETNUW-Dt3N5yFT.js → flowDiagram-HODETNUW-Brg9MA6i.js} +2 -2
  33. package/dist/{formats-gGospKWJ.js → formats-DbSvAPY-.js} +1 -1
  34. package/dist/{glide-data-editor-Cfv4HeOh.js → glide-data-editor-CiijPXA7.js} +2042 -2041
  35. package/dist/{glide-utils-Ba9WD2IN.js → glide-utils-BSw3SSHF.js} +29 -35
  36. package/dist/{globals-dIbBIR5-.js → globals-CpmSWKI_.js} +2 -2
  37. package/dist/{html-to-image-D_5CMreP.js → html-to-image-8sywF4my.js} +5452 -5751
  38. package/dist/{infoDiagram-27XIBGKW-C7axJMCl.js → infoDiagram-27XIBGKW-E9e403YT.js} +1 -1
  39. package/dist/{ishikawaDiagram-5VMMS53U-CdC9Eur7.js → ishikawaDiagram-5VMMS53U-C-RlYDRo.js} +1 -1
  40. package/dist/{kanban-definition-UXKFOSKX-BHnMIgD7.js → kanban-definition-UXKFOSKX-CLFl_uqK.js} +1 -1
  41. package/dist/main.js +6294 -6324
  42. package/dist/{mermaid-DFYN_PXd.js → mermaid-rd09e1JL.js} +35 -35
  43. package/dist/{mindmap-definition-YA3MSWOX-BagHoCGU.js → mindmap-definition-YA3MSWOX-BQ8TWtyB.js} +1 -1
  44. package/dist/{number-overlay-editor-CTiJp5Mj.js → number-overlay-editor-Ft4NiAEN.js} +1 -1
  45. package/dist/{pegDiagram-XKGWAZYB-8IIZczip.js → pegDiagram-XKGWAZYB-CN8UHFPi.js} +2 -2
  46. package/dist/pencil-DcT8AE0h.js +10 -0
  47. package/dist/{pieDiagram-E7YTZNPT-CE6dKBIr.js → pieDiagram-E7YTZNPT-DGuJ0EBd.js} +1 -1
  48. package/dist/{process-output-CGFNVC60.js → process-output-Dku5POvm.js} +3 -3
  49. package/dist/{railroadDiagram-O6MQD6OU-CkexPq3b.js → railroadDiagram-O6MQD6OU-BPDA8wy8.js} +2 -2
  50. package/dist/{requirementDiagram-BXWQKSXE-B0-OkuAe.js → requirementDiagram-BXWQKSXE-ChzLGbGW.js} +1 -1
  51. package/dist/{reveal-component-DH09ygHo.js → reveal-component-D0cfwbbl.js} +1094 -1094
  52. package/dist/{select-C33FVPPN.js → select-OxgozOC8.js} +1 -1
  53. package/dist/{sequenceDiagram-WJ2MYXX4-Dxysg8DX.js → sequenceDiagram-WJ2MYXX4-DFX-bE6O.js} +1 -1
  54. package/dist/{spec-DH1lmF7Q.js → spec-oJn0RhpQ.js} +2 -2
  55. package/dist/{stateDiagram-D77RDMKH-DTEN3-OJ.js → stateDiagram-D77RDMKH-BR7cI3wK.js} +2 -2
  56. package/dist/{stateDiagram-v2-MP3YSRHH-vktk3Xc5.js → stateDiagram-v2-MP3YSRHH-oGqB5UDI.js} +2 -2
  57. package/dist/{static-state-BldGd99T.js → static-state-CuV6Pc3a.js} +1 -1
  58. package/dist/style.css +1 -1
  59. package/dist/{swimlanes-42K2YHIH-Cjveh9OM.js → swimlanes-42K2YHIH-DwSZl1yC.js} +1 -1
  60. package/dist/{swimlanesDiagram-VR7AAH4N-Bdr7Ev_2.js → swimlanesDiagram-VR7AAH4N-BHlcd-Ri.js} +2 -2
  61. package/dist/{timeline-definition-24CTP7MA-KIuDcefn.js → timeline-definition-24CTP7MA-DMmP-G2O.js} +1 -1
  62. package/dist/{toast-CY1_Qv41.js → toast-DbBHmxmB.js} +2 -2
  63. package/dist/{useAsyncData-DsZMvNXn.js → useAsyncData-BYIAoMqn.js} +1 -1
  64. package/dist/{useDateFormatter-Dk_xFci4.js → useDateFormatter-ButWkdcK.js} +1 -1
  65. package/dist/{useDeepCompareMemoize-KMBBux8C.js → useDeepCompareMemoize-Bz0c5GN6.js} +1 -1
  66. package/dist/{useTheme-BZ2GGLgm.js → useTheme-DPLobSFs.js} +7 -7
  67. package/dist/{utils-DXkj82sK.js → utils-EBklsC5R.js} +31 -31
  68. package/dist/{vega-component-C-lqjV0q.js → vega-component-rxIxAAh9.js} +7 -7
  69. package/dist/{vennDiagram-4TSXK5OY-DrIOUHfb.js → vennDiagram-4TSXK5OY-Db8GtH1G.js} +1 -1
  70. package/dist/{wardleyDiagram-VM6X3IG4-CJNjf4OH.js → wardleyDiagram-VM6X3IG4-DmIiC3FU.js} +1 -1
  71. package/dist/{xychartDiagram-S5SC5T6Z-DEshFho-.js → xychartDiagram-S5SC5T6Z-DS9OPrNq.js} +1 -1
  72. package/package.json +1 -1
  73. package/src/__mocks__/executions.ts +195 -0
  74. package/src/__mocks__/notebook.ts +0 -1
  75. package/src/__tests__/CellStatus.test.tsx +19 -20
  76. package/src/components/chat/__tests__/chat-display.test.tsx +157 -86
  77. package/src/components/chat/__tests__/chat-utils.test.ts +42 -0
  78. package/src/components/chat/__tests__/turn-timing.test.ts +84 -0
  79. package/src/components/chat/__tests__/use-assistant-turn.test.ts +95 -0
  80. package/src/components/chat/__tests__/user-message.test.tsx +27 -0
  81. package/src/components/chat/acp/agent-panel.tsx +2 -0
  82. package/src/components/chat/agent-activity.tsx +132 -0
  83. package/src/components/chat/chat-display.tsx +125 -96
  84. package/src/components/chat/chat-panel.tsx +123 -106
  85. package/src/components/chat/chat-utils.ts +12 -24
  86. package/src/components/chat/tool-call/__tests__/tool-labels.test.ts +76 -0
  87. package/src/components/chat/tool-call/__tests__/tool-step.test.tsx +57 -0
  88. package/src/components/chat/tool-call/shared.ts +6 -0
  89. package/src/components/chat/tool-call/tool-call-view.tsx +22 -76
  90. package/src/components/chat/tool-call/tool-labels.ts +172 -0
  91. package/src/components/chat/tool-call/tool-step.tsx +145 -0
  92. package/src/components/chat/turn-timing.ts +73 -0
  93. package/src/components/chat/use-assistant-turn.ts +65 -0
  94. package/src/components/chat/use-chat-generation.ts +5 -4
  95. package/src/components/chat/user-message.tsx +145 -0
  96. package/src/components/dependency-graph/minimap-content.tsx +3 -2
  97. package/src/components/editor/__tests__/data-attributes.test.tsx +0 -7
  98. package/src/components/editor/__tests__/output-persistence.test.tsx +2 -2
  99. package/src/components/editor/actions/__tests__/useCellActionButton.test.tsx +19 -1
  100. package/src/components/editor/actions/useCellActionButton.tsx +10 -3
  101. package/src/components/editor/ai/completion-handlers.tsx +0 -37
  102. package/src/components/editor/alerts/stdin-blocking-alert.tsx +3 -1
  103. package/src/components/editor/cell/CellStatus.tsx +23 -19
  104. package/src/components/editor/cell/RunButton.tsx +16 -6
  105. package/src/components/editor/cell/StagedAICell.tsx +51 -144
  106. package/src/components/editor/cell/__tests__/useRunCells.test.ts +45 -0
  107. package/src/components/editor/cell/cell-context-menu.tsx +4 -1
  108. package/src/components/editor/cell/code/cell-editor.tsx +7 -5
  109. package/src/components/editor/cell/collapse.tsx +4 -0
  110. package/src/components/editor/cell/useRunCells.ts +18 -3
  111. package/src/components/editor/chrome/wrapper/__tests__/pending-ai-cells.test.tsx +193 -32
  112. package/src/components/editor/chrome/wrapper/app-chrome.tsx +2 -2
  113. package/src/components/editor/chrome/wrapper/pending-ai-cells.tsx +145 -81
  114. package/src/components/editor/chrome/wrapper/pending-ai-changes-badge.tsx +25 -0
  115. package/src/components/editor/chrome/wrapper/sidebar.tsx +8 -4
  116. package/src/components/editor/header/status.tsx +4 -2
  117. package/src/components/editor/links/cell-link.tsx +4 -4
  118. package/src/components/editor/navigation/navigation.ts +3 -2
  119. package/src/components/editor/notebook-cell.tsx +85 -94
  120. package/src/components/editor/renderers/grid-layout/__tests__/grid-layout.test.tsx +75 -0
  121. package/src/components/editor/renderers/grid-layout/grid-layout.tsx +22 -16
  122. package/src/components/editor/renderers/slides-layout/__tests__/plugin.test.ts +0 -1
  123. package/src/components/editor/renderers/vertical-layout/vertical-layout.tsx +12 -33
  124. package/src/components/editor/stdin-blocking-alert.tsx +3 -1
  125. package/src/components/scratchpad/scratchpad.tsx +4 -1
  126. package/src/components/slides/minimap.tsx +4 -2
  127. package/src/components/slides/reveal-component.tsx +14 -10
  128. package/src/components/slides/slide-cell-view.tsx +28 -24
  129. package/src/components/slides/slide.tsx +3 -4
  130. package/src/components/ui/toast.tsx +1 -1
  131. package/src/core/ai/__tests__/staged-cells.test.ts +74 -126
  132. package/src/core/ai/staged-cells.ts +48 -38
  133. package/src/core/cells/__tests__/__snapshots__/cells.test.ts.snap +0 -389
  134. package/src/core/cells/__tests__/cell.test.ts +49 -154
  135. package/src/core/cells/__tests__/cells.test.ts +18 -127
  136. package/src/core/cells/__tests__/pending-delete-service.test.tsx +11 -20
  137. package/src/core/cells/__tests__/utils.test.ts +75 -247
  138. package/src/core/cells/actions.ts +5 -2
  139. package/src/core/cells/cell.ts +31 -133
  140. package/src/core/cells/cells.ts +14 -29
  141. package/src/core/cells/document.ts +3 -3
  142. package/src/core/cells/focus.ts +27 -6
  143. package/src/core/cells/layout.ts +1 -1
  144. package/src/core/cells/pending-delete-service.ts +5 -7
  145. package/src/core/cells/scrollCellIntoView.ts +6 -0
  146. package/src/core/cells/types.ts +13 -36
  147. package/src/core/cells/utils.ts +37 -68
  148. package/src/core/codemirror/placeholder/__tests__/extensions.test.ts +59 -0
  149. package/src/core/codemirror/placeholder/extensions.ts +23 -0
  150. package/src/core/edit-app.tsx +2 -1
  151. package/src/core/executions/__tests__/atoms.test.ts +99 -0
  152. package/src/core/executions/__tests__/select.test.ts +268 -0
  153. package/src/core/executions/atoms.ts +206 -0
  154. package/src/core/executions/hooks.ts +31 -0
  155. package/src/core/executions/index.ts +31 -0
  156. package/src/core/executions/select.ts +175 -0
  157. package/src/core/export/__tests__/hooks.test.ts +0 -5
  158. package/src/core/islands/bootstrap.ts +4 -1
  159. package/src/core/islands/components/output-wrapper.tsx +5 -5
  160. package/src/core/kernel/handlers.ts +2 -11
  161. package/src/core/kernel/state.ts +0 -15
  162. package/src/core/lifecycle/render-policy.ts +12 -26
  163. package/src/core/network/__tests__/requests-lazy.test.ts +5 -5
  164. package/src/core/network/requests-lazy.ts +3 -3
  165. package/src/core/notebook/operations.ts +1 -1
  166. package/src/core/run-app.tsx +2 -1
  167. package/src/core/runtime/__tests__/adapter.test.ts +3 -3
  168. package/src/core/runtime/adapter.ts +4 -4
  169. package/src/core/session/__tests__/useNotebookSession.hook.test.tsx +34 -0
  170. package/src/core/session/useNotebookSession.tsx +10 -5
  171. package/src/core/wasm/bridge.ts +1 -1
  172. package/src/css/app/Cell.css +7 -36
  173. package/src/plugins/core/registerReactComponent.tsx +4 -13
  174. package/src/plugins/impl/chat/chat-ui.tsx +2 -3
  175. package/src/plugins/impl/common/error-banner.tsx +5 -3
  176. package/src/stories/cell-status.stories.tsx +17 -10
  177. package/src/stories/cell.stories.tsx +0 -31
  178. package/src/stories/layout/vertical/one-column.stories.tsx +0 -9
  179. package/src/utils/time.ts +10 -0
  180. package/src/components/chat/reasoning-accordion.tsx +0 -61
  181. package/src/components/chat/tool-call/tool-activity-group.tsx +0 -49
  182. package/src/components/chat/tool-call/tool-error-card.tsx +0 -81
  183. package/src/components/chat/tool-call/tool-history-row.tsx +0 -147
  184. package/src/components/icons/loading-ellipsis.tsx +0 -46
@@ -3,7 +3,7 @@
3
3
  import { fireEvent, render, screen } from "@testing-library/react";
4
4
  import type { ToolUIPart, UIMessage } from "ai";
5
5
  import { describe, expect, it, vi } from "vitest";
6
- import { renderUIMessage } from "../chat-display";
6
+ import { getAnswerText, renderUIMessage } from "../chat-display";
7
7
 
8
8
  vi.mock("../tool-call/tool-call-view", () => ({
9
9
  ToolCallView: ({ toolName }: { toolName: string }) => <div>{toolName}</div>,
@@ -12,114 +12,185 @@ vi.mock("../../markdown/markdown-renderer", () => ({
12
12
  MarkdownRenderer: ({ content }: { content: string }) => <p>{content}</p>,
13
13
  }));
14
14
 
15
- const done = (name = "edit_notebook_tool"): ToolUIPart => ({
16
- type: `tool-${name}`,
17
- toolCallId: crypto.randomUUID(),
18
- state: "output-available",
19
- input: {},
20
- output: {},
15
+ const tool = (
16
+ name = "edit_notebook_tool",
17
+ state: ToolUIPart["state"] = "output-available",
18
+ ): ToolUIPart =>
19
+ ({
20
+ type: `tool-${name}`,
21
+ toolCallId: crypto.randomUUID(),
22
+ state,
23
+ input: {},
24
+ output: {},
25
+ approval: state === "approval-requested" ? { id: "approval" } : undefined,
26
+ }) as ToolUIPart;
27
+
28
+ const reasoning = (text: string): UIMessage["parts"][number] => ({
29
+ type: "reasoning",
30
+ text,
31
+ state: "done",
21
32
  });
22
33
 
23
- function show(parts: UIMessage["parts"], isActive = false) {
34
+ function show(
35
+ parts: UIMessage["parts"],
36
+ {
37
+ isActive = false,
38
+ metadata,
39
+ }: { isActive?: boolean; metadata?: Record<string, number | boolean> } = {},
40
+ ) {
24
41
  return render(
25
- renderUIMessage({
26
- message: { id: "message", role: "assistant", parts },
27
- isStreamingReasoning: false,
28
- isLast: true,
29
- isActive,
30
- }),
42
+ <div>
43
+ {renderUIMessage({
44
+ message: { id: "message", role: "assistant", parts, metadata },
45
+ isLast: true,
46
+ isActive,
47
+ })}
48
+ </div>,
31
49
  );
32
50
  }
33
51
 
34
- describe("chat tool activity", () => {
35
- it("collapses consecutive edits across SDK step boundaries and exposes details on demand", () => {
36
- show([done(), { type: "step-start" }, done(), done(), done()]);
52
+ describe("assistant turn rendering", () => {
53
+ it("folds interleaved reasoning, narration, and tools into one block above the answer", () => {
54
+ show([
55
+ reasoning("Plan the cells"),
56
+ tool("get_lightweight_cell_map"),
57
+ { type: "step-start" },
58
+ { type: "text", text: "Adding the function now." },
59
+ reasoning("Write fib"),
60
+ tool(),
61
+ { type: "text", text: "Added `fib`." },
62
+ ]);
63
+
37
64
  const summary = screen.getByRole("button", {
38
- name: "Edited notebook · 4 operations",
65
+ name: "Worked through 2 steps",
39
66
  });
40
67
  expect(summary.getAttribute("aria-expanded")).toBe("false");
41
- expect(screen.queryByText("tool-edit_notebook_tool")).toBeNull();
68
+ expect(screen.getByText("Added `fib`.")).toBeTruthy();
69
+ expect(screen.queryByText("Plan the cells")).toBeNull();
70
+
42
71
  fireEvent.click(summary);
43
- expect(screen.getAllByText("tool-edit_notebook_tool")).toHaveLength(4);
72
+ expect(screen.getByText("Plan the cells")).toBeTruthy();
73
+ expect(screen.getByText("Adding the function now.")).toBeTruthy();
74
+ expect(screen.getByText("tool-get_lightweight_cell_map")).toBeTruthy();
75
+ expect(screen.getByText("tool-edit_notebook_tool")).toBeTruthy();
44
76
  });
45
77
 
46
- it("starts a new group after prose and summarizes mixed tool calls", () => {
47
- show([
48
- done(),
49
- { type: "text", text: "Checking the result." },
50
- done("lint_notebook"),
51
- {
52
- type: "dynamic-tool",
53
- toolName: "get_lightweight_cell_map",
54
- toolCallId: "dynamic",
55
- state: "output-available",
56
- input: {},
57
- output: {},
58
- },
59
- ]);
60
- expect(screen.getByText("Checking the result.")).toBeTruthy();
78
+ it("reports how long the turn took", () => {
79
+ const view = show([tool(), { type: "text", text: "Done." }], {
80
+ metadata: { startedAt: 0, finishedAt: 65_000 },
81
+ });
61
82
  expect(
62
- screen.getByRole("button", { name: "Edited notebook · 1 operation" }),
83
+ screen.getByRole("button", { name: "Worked for 1m 5s" }),
63
84
  ).toBeTruthy();
85
+ view.unmount();
86
+
87
+ show([reasoning("Hmm"), { type: "text", text: "Hi." }], {
88
+ metadata: { startedAt: 0, finishedAt: 8_000 },
89
+ });
90
+ expect(screen.getByRole("button", { name: "Thought for 8s" })).toBeTruthy();
91
+ });
92
+
93
+ it("stays open while running and collapses once the answer is in", () => {
94
+ const parts: UIMessage["parts"] = [
95
+ reasoning("Plan"),
96
+ tool("edit_notebook_tool", "input-available"),
97
+ ];
98
+ const view = show(parts, { isActive: true });
99
+ expect(screen.getByRole("status").textContent).toBe("Working…");
100
+ expect(screen.getByText("tool-edit_notebook_tool")).toBeTruthy();
101
+
102
+ view.rerender(
103
+ <div>
104
+ {renderUIMessage({
105
+ message: {
106
+ id: "message",
107
+ role: "assistant",
108
+ parts: [
109
+ ...parts.slice(0, 1),
110
+ tool(),
111
+ { type: "text", text: "Done." },
112
+ ],
113
+ },
114
+ isLast: true,
115
+ isActive: false,
116
+ })}
117
+ </div>,
118
+ );
119
+ const summary = screen.getByRole("button", {
120
+ name: "Worked through 1 step",
121
+ });
122
+ expect(summary.getAttribute("aria-expanded")).toBe("false");
123
+ });
124
+
125
+ it("leaves a stopped turn open since there is no answer to read", () => {
126
+ show([reasoning("Plan"), tool("edit_notebook_tool", "input-available")]);
127
+ const summary = screen.getByRole("button", { name: "Stopped" });
128
+ expect(summary.getAttribute("aria-expanded")).toBe("true");
129
+ });
130
+
131
+ it("labels a stopped turn even without pending tools", () => {
132
+ show([reasoning("Plan"), { type: "text", text: "Partial" }], {
133
+ metadata: { startedAt: 0, finishedAt: 3_000, stopped: true },
134
+ });
64
135
  expect(
65
- screen.getByRole("button", { name: "Completed 2 steps" }),
136
+ screen.getByRole("button", { name: "Stopped after 3s" }),
66
137
  ).toBeTruthy();
67
138
  });
68
139
 
69
- it("keeps errors, denials, and approval requests outside collapsed groups", () => {
140
+ it("flags failed steps in the summary", () => {
70
141
  show([
71
- done(),
72
- {
73
- type: "tool-failed",
74
- toolCallId: "error",
75
- state: "output-error",
76
- input: {},
77
- errorText: "Failed",
78
- },
79
- {
80
- type: "tool-approval",
81
- toolCallId: "approval",
82
- state: "approval-requested",
83
- input: {},
84
- approval: { id: "approval" },
85
- },
86
- {
87
- type: "tool-denied",
88
- toolCallId: "denied",
89
- state: "output-denied",
90
- input: {},
91
- approval: { id: "denied", approved: false },
92
- },
93
- done(),
142
+ tool("lint_notebook", "output-error"),
143
+ { type: "text", text: "Hm." },
94
144
  ]);
95
- expect(screen.getByText("tool-failed")).toBeTruthy();
96
- expect(screen.getByText("tool-approval")).toBeTruthy();
97
- expect(screen.getByText("tool-denied")).toBeTruthy();
98
- expect(
99
- screen.getAllByRole("button", { name: "Edited notebook · 1 operation" }),
100
- ).toHaveLength(2);
145
+ expect(screen.getByRole("button", { name: /1 failed/ })).toBeTruthy();
101
146
  });
102
147
 
103
- it("distinguishes running edits from interrupted activity", () => {
104
- const parts: UIMessage["parts"] = [
105
- done(),
106
- {
107
- type: "tool-edit_notebook_tool",
108
- toolCallId: "pending",
109
- state: "input-available",
110
- input: {},
111
- },
112
- ];
113
- const view = show(parts, true);
114
- expect(
115
- screen.getByRole("button", { name: "Editing notebook…" }),
116
- ).toBeTruthy();
117
- view.unmount();
118
- show(parts);
148
+ it("shows a thinking indicator before any content arrives", () => {
149
+ show([reasoning("")], { isActive: true });
150
+ expect(screen.getByRole("status").textContent).toBe("Thinking…");
151
+ expect(screen.queryByRole("button")).toBeNull();
152
+ });
153
+
154
+ it("keeps pending approvals, and the text explaining them, outside the block", () => {
155
+ show([
156
+ reasoning("Needs approval"),
157
+ { type: "text", text: "I'll run a query." },
158
+ tool("run_query", "approval-requested"),
159
+ ]);
160
+ expect(screen.getByText("I'll run a query.")).toBeTruthy();
161
+ expect(screen.getByText("tool-run_query")).toBeTruthy();
119
162
  expect(
120
- screen.getByRole("button", {
121
- name: "Stopped before completing all steps",
122
- }),
163
+ screen.getByRole("button", { name: "Thought process" }),
123
164
  ).toBeTruthy();
124
165
  });
166
+
167
+ it("renders plain answers without an activity block", () => {
168
+ show([{ type: "text", text: "Hello!" }]);
169
+ expect(screen.getByText("Hello!")).toBeTruthy();
170
+ expect(screen.queryByRole("button")).toBeNull();
171
+ });
172
+ });
173
+
174
+ describe("getAnswerText", () => {
175
+ it("copies the visible answer, not the narration folded into the block", () => {
176
+ const message: UIMessage = {
177
+ id: "m",
178
+ role: "assistant",
179
+ parts: [
180
+ { type: "text", text: "Adding the function now." },
181
+ tool(),
182
+ { type: "text", text: "Added `fib`." },
183
+ ],
184
+ };
185
+ expect(getAnswerText(message)).toBe("Added `fib`.");
186
+ });
187
+
188
+ it("falls back to the narration when there is no answer", () => {
189
+ const message: UIMessage = {
190
+ id: "m",
191
+ role: "assistant",
192
+ parts: [{ type: "text", text: "Starting." }, tool()],
193
+ };
194
+ expect(getAnswerText(message)).toBe("Starting.");
195
+ });
125
196
  });
@@ -9,6 +9,7 @@ import {
9
9
  hasPendingToolCalls,
10
10
  hasUnresolvedToolCalls,
11
11
  shouldFlushQueue,
12
+ willAutoContinue,
12
13
  } from "../chat-utils";
13
14
 
14
15
  /**
@@ -560,3 +561,44 @@ describe("handleToolCall", () => {
560
561
  },
561
562
  );
562
563
  });
564
+
565
+ describe("willAutoContinue", () => {
566
+ const toolPart = (state: string) =>
567
+ ({
568
+ type: "tool-run_query",
569
+ toolCallId: "call-1",
570
+ state,
571
+ input: {},
572
+ output: {},
573
+ approval: { id: "approval-1" },
574
+ }) as unknown as UIMessage["parts"][number];
575
+
576
+ it.each([
577
+ ["input-available", true],
578
+ ["output-available", true],
579
+ ["approval-requested", false],
580
+ ["input-streaming", false],
581
+ ])("returns %s → %s", (state, expected) => {
582
+ expect(
583
+ willAutoContinue([
584
+ userMessage("run it"),
585
+ assistantToolMessage([toolPart(state)]),
586
+ ]),
587
+ ).toBe(expected);
588
+ });
589
+
590
+ it.each(["output-available", "input-available"])(
591
+ "returns false once text follows a tool that is %s",
592
+ (state) => {
593
+ expect(
594
+ willAutoContinue([
595
+ userMessage("run it"),
596
+ assistantToolMessage([
597
+ toolPart(state),
598
+ { type: "text", text: "Done." },
599
+ ]),
600
+ ]),
601
+ ).toBe(false);
602
+ },
603
+ );
604
+ });
@@ -0,0 +1,84 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import type { UIMessage } from "ai";
4
+ import { describe, expect, it } from "vitest";
5
+ import { formatDuration, getTurnTiming, withTurnTiming } from "../turn-timing";
6
+
7
+ const user: UIMessage = { id: "u", role: "user", parts: [] };
8
+ const assistant = (metadata?: unknown): UIMessage => ({
9
+ id: "a",
10
+ role: "assistant",
11
+ parts: [],
12
+ metadata,
13
+ });
14
+
15
+ describe("withTurnTiming", () => {
16
+ it("stamps the turn start and finish on the trailing assistant message", () => {
17
+ const [, stamped] = withTurnTiming(
18
+ [user, assistant()],
19
+ { startedAt: 100, stopped: false },
20
+ 500,
21
+ );
22
+ expect(getTurnTiming(stamped)).toEqual({
23
+ startedAt: 100,
24
+ finishedAt: 500,
25
+ stopped: false,
26
+ });
27
+ });
28
+
29
+ it("prefers the live turn's start, which moves forward after approvals", () => {
30
+ const first = withTurnTiming(
31
+ [user, assistant({ other: 1 })],
32
+ { startedAt: 100, stopped: false },
33
+ 500,
34
+ );
35
+ const [, stamped] = withTurnTiming(
36
+ first,
37
+ { startedAt: 400, stopped: true },
38
+ 900,
39
+ );
40
+ expect(stamped.metadata).toEqual({
41
+ other: 1,
42
+ startedAt: 400,
43
+ finishedAt: 900,
44
+ stopped: true,
45
+ });
46
+ });
47
+
48
+ it("keeps the stored start when no turn is live", () => {
49
+ const first = withTurnTiming(
50
+ [user, assistant()],
51
+ { startedAt: 100, stopped: false },
52
+ 500,
53
+ );
54
+ const [, stamped] = withTurnTiming(
55
+ first,
56
+ { startedAt: null, stopped: false },
57
+ 900,
58
+ );
59
+ expect(getTurnTiming(stamped)?.startedAt).toBe(100);
60
+ });
61
+
62
+ it("leaves messages untouched without an assistant reply or start time", () => {
63
+ const pending = [user];
64
+ const turn = { startedAt: 100, stopped: false };
65
+ expect(withTurnTiming(pending, turn, 500)).toBe(pending);
66
+ const unknownStart = [user, assistant()];
67
+ expect(
68
+ withTurnTiming(unknownStart, { ...turn, startedAt: null }, 500),
69
+ ).toBe(unknownStart);
70
+ });
71
+ });
72
+
73
+ describe("formatDuration", () => {
74
+ it.each([
75
+ [200, "1s"],
76
+ [8_400, "8s"],
77
+ [60_000, "1m"],
78
+ [65_000, "1m 5s"],
79
+ [3_600_000, "1h"],
80
+ [3_780_000, "1h 3m"],
81
+ ])("%d ms → %s", (ms, expected) => {
82
+ expect(formatDuration(ms)).toBe(expected);
83
+ });
84
+ });
@@ -0,0 +1,95 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { act, renderHook } from "@testing-library/react";
4
+ import type { UIMessage } from "ai";
5
+ import { describe, expect, it } from "vitest";
6
+ import { useAssistantTurn } from "../use-assistant-turn";
7
+
8
+ const awaitingTool: UIMessage[] = [
9
+ { id: "u", role: "user", parts: [{ type: "text", text: "go" }] },
10
+ {
11
+ id: "a",
12
+ role: "assistant",
13
+ parts: [
14
+ {
15
+ type: "tool-lint_notebook",
16
+ toolCallId: "call",
17
+ state: "input-available",
18
+ input: {},
19
+ },
20
+ ],
21
+ },
22
+ ];
23
+
24
+ function setup() {
25
+ return renderHook(() => useAssistantTurn());
26
+ }
27
+
28
+ const idle = { isLoading: false, hasError: false, messages: awaitingTool };
29
+
30
+ describe("useAssistantTurn", () => {
31
+ it("stays active between requests while a tool is still running", () => {
32
+ const { result } = setup();
33
+ expect(result.current.isActive(idle)).toBe(false);
34
+ act(() => result.current.start());
35
+ expect(result.current.isActive(idle)).toBe(true);
36
+ expect(result.current.startedAt).toEqual(expect.any(Number));
37
+ });
38
+
39
+ it("ends on stop, error, or chat switch", () => {
40
+ const { result } = setup();
41
+ act(() => result.current.start());
42
+ act(() => {
43
+ result.current.finish(awaitingTool, { isAbort: true, isError: false });
44
+ });
45
+ expect(result.current.isActive(idle)).toBe(false);
46
+
47
+ act(() => result.current.start());
48
+ act(() => {
49
+ result.current.finish(awaitingTool, { isAbort: false, isError: true });
50
+ });
51
+ expect(result.current.isActive(idle)).toBe(false);
52
+
53
+ act(() => result.current.start());
54
+ expect(result.current.isActive({ ...idle, hasError: true })).toBe(false);
55
+
56
+ act(() => result.current.reset());
57
+ expect(result.current.isActive(idle)).toBe(false);
58
+ expect(result.current.startedAt).toBeNull();
59
+ });
60
+
61
+ it("stamps the turn's start on the reply", () => {
62
+ const { result } = setup();
63
+ act(() => result.current.start());
64
+ const [, reply] = result.current.finish(awaitingTool, {
65
+ isAbort: false,
66
+ isError: false,
67
+ });
68
+ expect(reply.metadata).toMatchObject({
69
+ startedAt: result.current.startedAt,
70
+ });
71
+ });
72
+
73
+ it("excludes time spent waiting on an approval", () => {
74
+ const reply: UIMessage = {
75
+ id: "a",
76
+ role: "assistant",
77
+ parts: [],
78
+ metadata: { startedAt: 1_000, finishedAt: 4_000 },
79
+ };
80
+ const { result } = setup();
81
+ act(() => result.current.resume(reply));
82
+ expect(Date.now() - (result.current.startedAt ?? 0)).toBeGreaterThanOrEqual(
83
+ 3_000,
84
+ );
85
+ expect(Date.now() - (result.current.startedAt ?? 0)).toBeLessThan(4_000);
86
+ });
87
+
88
+ it("stops auto-continuing once the user stops the turn", () => {
89
+ const { result } = setup();
90
+ act(() => result.current.start());
91
+ act(() => result.current.stop());
92
+ expect(result.current.hasEnded).toBe(true);
93
+ expect(result.current.isActive(idle)).toBe(false);
94
+ });
95
+ });
@@ -0,0 +1,27 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { render, screen } from "@testing-library/react";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import { TooltipProvider } from "@/components/ui/tooltip";
6
+ import { UserMessage } from "../user-message";
7
+
8
+ describe("UserMessage", () => {
9
+ it("shows context mentions as chips instead of raw URIs", () => {
10
+ render(
11
+ <TooltipProvider>
12
+ <UserMessage
13
+ message={{
14
+ id: "u",
15
+ role: "user",
16
+ parts: [{ type: "text", text: "Plot @variable://df please" }],
17
+ }}
18
+ canEdit={true}
19
+ onEdit={vi.fn()}
20
+ />
21
+ </TooltipProvider>,
22
+ );
23
+ const chip = screen.getByTitle("variable://df");
24
+ expect(chip.textContent).toBe("@df");
25
+ expect(screen.queryByText(/@variable:\/\//)).toBeNull();
26
+ });
27
+ });
@@ -1,5 +1,6 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
+ import { PendingAICells } from "@/components/editor/chrome/wrapper/pending-ai-cells";
3
4
  import { useAtom } from "jotai";
4
5
  import {
5
6
  BotMessageSquareIcon,
@@ -1246,6 +1247,7 @@ const AgentPanel: React.FC = () => {
1246
1247
  </div>
1247
1248
  )}
1248
1249
 
1250
+ {!isLoading && <PendingAICells />}
1249
1251
  <PromptArea
1250
1252
  isLoading={isLoading}
1251
1253
  activeSessionId={activeSessionId}
@@ -0,0 +1,132 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { ChevronRightIcon } from "lucide-react";
4
+ import React from "react";
5
+ import { cn } from "@/utils/cn";
6
+ import { formatDuration, type TurnTiming } from "./turn-timing";
7
+
8
+ interface AgentActivityProps {
9
+ running: boolean;
10
+ thinking?: boolean;
11
+ interrupted?: boolean;
12
+ defaultOpen?: boolean;
13
+ timing?: TurnTiming;
14
+ toolCount?: number;
15
+ failedCount?: number;
16
+ steps?: React.ReactNode[];
17
+ }
18
+
19
+ export const AgentActivity: React.FC<AgentActivityProps> = ({
20
+ running,
21
+ thinking = false,
22
+ interrupted = false,
23
+ defaultOpen = false,
24
+ timing,
25
+ toolCount = 0,
26
+ failedCount = 0,
27
+ steps = NO_STEPS,
28
+ }) => {
29
+ const [userOpen, setUserOpen] = React.useState<boolean>();
30
+ const open = userOpen ?? (running || defaultOpen);
31
+ const now = useNow(running);
32
+ const hasSteps = steps.length > 0;
33
+
34
+ const header = running ? (
35
+ <>
36
+ <output className={SHIMMER_CLASS}>
37
+ {thinking ? "Thinking…" : "Working…"}
38
+ </output>
39
+ {timing && (
40
+ <span className="tabular-nums">
41
+ {formatDuration(now - timing.startedAt)}
42
+ </span>
43
+ )}
44
+ </>
45
+ ) : (
46
+ <>
47
+ <span>{doneLabel({ interrupted, timing, toolCount })}</span>
48
+ {failedCount > 0 && (
49
+ <span className="text-(--red-11)">· {failedCount} failed</span>
50
+ )}
51
+ </>
52
+ );
53
+
54
+ return (
55
+ <div className="my-1 w-full">
56
+ {hasSteps ? (
57
+ <button
58
+ type="button"
59
+ onClick={() => setUserOpen(!open)}
60
+ aria-expanded={open}
61
+ className="group/activity -mx-1.5 flex items-center gap-1.5 rounded-md px-1.5 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-foreground/6 hover:text-foreground focus-visible:outline-1 focus-visible:outline-(--slate-8)"
62
+ >
63
+ {header}
64
+ <ChevronRightIcon
65
+ aria-hidden={true}
66
+ className={cn(
67
+ "size-3 shrink-0 opacity-60 transition-transform group-hover/activity:opacity-100",
68
+ open && "rotate-90",
69
+ )}
70
+ />
71
+ </button>
72
+ ) : (
73
+ <div className="flex items-center gap-1.5 py-0.5 text-xs text-muted-foreground">
74
+ {header}
75
+ </div>
76
+ )}
77
+ {open && hasSteps && (
78
+ <div className="mt-1 mb-2 ml-1.5 flex flex-col gap-1 border-l border-foreground/15 pl-3">
79
+ {steps}
80
+ </div>
81
+ )}
82
+ </div>
83
+ );
84
+ };
85
+
86
+ const NO_STEPS: React.ReactNode[] = [];
87
+
88
+ const SHIMMER_CLASS =
89
+ "animate-shimmer bg-[linear-gradient(90deg,var(--muted-foreground)_35%,var(--foreground)_50%,var(--muted-foreground)_65%)] bg-size-[200%_100%] bg-clip-text text-transparent motion-reduce:animate-none";
90
+
91
+ function doneLabel({
92
+ interrupted,
93
+ timing,
94
+ toolCount,
95
+ }: {
96
+ interrupted: boolean;
97
+ timing?: TurnTiming;
98
+ toolCount: number;
99
+ }): string {
100
+ const duration =
101
+ timing?.finishedAt == null
102
+ ? undefined
103
+ : formatDuration(timing.finishedAt - timing.startedAt);
104
+ if (interrupted) {
105
+ return duration ? `Stopped after ${duration}` : "Stopped";
106
+ }
107
+ if (toolCount === 0) {
108
+ return duration ? `Thought for ${duration}` : "Thought process";
109
+ }
110
+ if (duration) {
111
+ return `Worked for ${duration}`;
112
+ }
113
+ return `Worked through ${toolCount} ${toolCount === 1 ? "step" : "steps"}`;
114
+ }
115
+
116
+ function useNow(ticking: boolean): number {
117
+ const [now, setNow] = React.useState(() => Date.now());
118
+ React.useEffect(() => {
119
+ if (!ticking) {
120
+ return;
121
+ }
122
+ const tick = () => setNow(Date.now());
123
+ // Catch up at once when resuming, e.g. after a tool approval.
124
+ const frame = requestAnimationFrame(tick);
125
+ const id = setInterval(tick, 1000);
126
+ return () => {
127
+ cancelAnimationFrame(frame);
128
+ clearInterval(id);
129
+ };
130
+ }, [ticking]);
131
+ return now;
132
+ }