@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
@@ -7,103 +7,95 @@ import type {
7
7
  ToolUIPart,
8
8
  UIMessage,
9
9
  } from "ai";
10
- import { ExternalLinkIcon, FileTextIcon } from "lucide-react";
10
+ import { BrainIcon, ExternalLinkIcon, FileTextIcon } from "lucide-react";
11
11
  import React from "react";
12
12
  import { renderHTML } from "@/plugins/core/RenderHTML";
13
13
  import { logNever } from "@/utils/assertNever";
14
- import { Logger } from "@/utils/Logger";
15
- import { ChatMarkdown } from "./chat-markdown";
14
+ import { AgentActivity } from "./agent-activity";
16
15
  import { AttachmentRenderer, SourceChip } from "./chat-components";
17
- import { ReasoningAccordion } from "./reasoning-accordion";
18
- import { ToolActivityGroup } from "./tool-call/tool-activity-group";
16
+ import { ChatMarkdown } from "./chat-markdown";
17
+ import { PENDING_TOOL_STATES } from "./tool-call/shared";
19
18
  import { ToolCallView } from "./tool-call/tool-call-view";
19
+ import { getTurnTiming } from "./turn-timing";
20
+
21
+ type Part = UIMessage["parts"][number];
22
+ interface IndexedPart {
23
+ part: Part;
24
+ index: number;
25
+ }
20
26
 
21
27
  export const renderUIMessage = ({
22
28
  message,
23
- isStreamingReasoning,
24
29
  isLast,
25
30
  isActive,
31
+ turnStartedAt,
26
32
  addToolApprovalResponse,
27
33
  }: {
28
34
  message: UIMessage;
29
- isStreamingReasoning: boolean;
30
35
  isLast: boolean;
31
36
  /** Whether the chat is currently streaming/submitting a response. */
32
37
  isActive: boolean;
38
+ turnStartedAt?: number | null;
33
39
  addToolApprovalResponse?: ChatAddToolApproveResponseFunction;
34
- }) => {
40
+ }): React.ReactNode[] => {
41
+ const running = isActive && isLast;
42
+ const { work, rest } = splitTurn(message.parts);
35
43
  const content: React.ReactNode[] = [];
36
- let activity: { part: ToolUIPart | DynamicToolUIPart; index: number }[] = [];
37
44
 
38
- const flushActivity = () => {
39
- if (activity.length === 0) {
40
- return;
41
- }
45
+ if (work.length > 0 || (running && rest.length === 0)) {
46
+ const tools = work.flatMap(({ part }) =>
47
+ isAnyToolPart(part) ? [part] : [],
48
+ );
49
+ const timing =
50
+ running && turnStartedAt != null
51
+ ? { startedAt: turnStartedAt }
52
+ : getTurnTiming(message);
42
53
  content.push(
43
- <ToolActivityGroup
44
- key={activity[0].index}
45
- tools={activity.map(({ part }) => ({
46
- toolName: part.type === "dynamic-tool" ? part.toolName : part.type,
47
- state: part.state,
48
- }))}
49
- isActive={isActive && isLast}
50
- >
51
- {activity.map(({ part, index }) => renderUIMessagePart(part, index))}
52
- </ToolActivityGroup>,
54
+ <AgentActivity
55
+ key="activity"
56
+ running={running}
57
+ thinking={
58
+ rest.length === 0 &&
59
+ (work.length === 0 || work.at(-1)?.part.type === "reasoning")
60
+ }
61
+ interrupted={
62
+ !running &&
63
+ (timing?.stopped === true ||
64
+ tools.some((part) => PENDING_TOOL_STATES.has(part.state)))
65
+ }
66
+ defaultOpen={!rest.some(({ part }) => !isPendingApproval(part))}
67
+ timing={timing}
68
+ toolCount={tools.length}
69
+ failedCount={
70
+ tools.filter((part) => part.state === "output-error").length
71
+ }
72
+ steps={work.map(({ part, index }) => renderPart(part, index, true))}
73
+ />,
53
74
  );
54
- activity = [];
55
- };
56
-
57
- message.parts.forEach((part, index) => {
58
- // SDK step boundaries and invisible parts should not split visible activity.
59
- if (
60
- part.type === "step-start" ||
61
- isDataPart(part) ||
62
- (part.type === "text" && !part.text.trim())
63
- ) {
64
- return;
65
- }
66
- if (
67
- (isToolPart(part) || part.type === "dynamic-tool") &&
68
- part.state !== "output-error" &&
69
- part.state !== "output-denied" &&
70
- part.state !== "approval-requested"
71
- ) {
72
- activity.push({ part, index });
73
- return;
74
- }
75
- flushActivity();
76
- content.push(renderUIMessagePart(part, index));
77
- });
78
- flushActivity();
79
-
75
+ }
76
+ for (const { part, index } of rest) {
77
+ content.push(renderPart(part, index, false));
78
+ }
80
79
  return content;
81
80
 
82
- function renderUIMessagePart(
83
- part: UIMessage["parts"][number],
84
- index: number,
85
- ) {
86
- if (isToolPart(part)) {
81
+ function renderPart(part: Part, index: number, inActivity: boolean) {
82
+ if (isToolPart(part) || part.type === "dynamic-tool") {
87
83
  return (
88
84
  <ToolCallView
89
85
  key={index}
90
- index={index}
91
- toolName={part.type}
86
+ toolName={part.type === "dynamic-tool" ? part.toolName : part.type}
92
87
  result={part.output}
93
88
  errorText={part.state === "output-error" ? part.errorText : undefined}
94
- className="my-1"
95
89
  state={part.state}
96
90
  input={part.input}
97
91
  approval={part.approval}
98
92
  onApprove={addToolApprovalResponse}
99
- isLive={isLast}
100
- isActive={isActive}
93
+ isActive={running}
94
+ className={inActivity ? undefined : "my-1"}
101
95
  />
102
96
  );
103
97
  }
104
-
105
98
  if (isDataPart(part)) {
106
- Logger.debug("Found data part", part);
107
99
  return null;
108
100
  }
109
101
 
@@ -114,26 +106,30 @@ export const renderUIMessage = ({
114
106
  if (part.text.includes("<marimo-")) {
115
107
  return (
116
108
  <React.Fragment key={index}>
117
- {renderHTML({
118
- html: part.text,
119
- })}
109
+ {renderHTML({ html: part.text })}
120
110
  </React.Fragment>
121
111
  );
122
112
  }
123
- return <ChatMarkdown key={index} content={part.text} />;
113
+ return (
114
+ <div key={index} className={inActivity ? "py-1.5" : undefined}>
115
+ <ChatMarkdown content={part.text} />
116
+ </div>
117
+ );
124
118
  case "reasoning":
125
119
  return (
126
- <ReasoningAccordion
120
+ <div
127
121
  key={index}
128
- reasoning={part.text}
129
- index={index}
130
- isStreaming={
131
- isStreamingReasoning &&
132
- isLast &&
133
- // If there are multiple reasoning parts, only stream the last one
134
- index === (message.parts.length || 0) - 1
135
- }
136
- />
122
+ className="flex gap-1.5 py-1.5 text-muted-foreground"
123
+ >
124
+ <BrainIcon
125
+ aria-hidden={true}
126
+ className="-ml-px mt-1 size-3 shrink-0"
127
+ />
128
+ <div className="min-w-0 italic [&_p]:my-1 [&_p:first-child]:mt-0">
129
+ <span className="sr-only">Reasoning: </span>
130
+ <ChatMarkdown content={part.text} />
131
+ </div>
132
+ </div>
137
133
  );
138
134
  case "file":
139
135
  return <AttachmentRenderer attachment={part} key={index} />;
@@ -148,23 +144,6 @@ export const renderUIMessage = ({
148
144
  }}
149
145
  />
150
146
  );
151
- case "dynamic-tool":
152
- return (
153
- <ToolCallView
154
- key={index}
155
- toolName={part.toolName}
156
- result={part.output}
157
- errorText={
158
- part.state === "output-error" ? part.errorText : undefined
159
- }
160
- state={part.state}
161
- input={part.input}
162
- approval={part.approval}
163
- onApprove={addToolApprovalResponse}
164
- isLive={isLast}
165
- isActive={isActive}
166
- />
167
- );
168
147
  case "source-document":
169
148
  return (
170
149
  <SourceChip
@@ -199,12 +178,62 @@ export const renderUIMessage = ({
199
178
  }
200
179
  };
201
180
 
202
- function isToolPart(part: UIMessage["parts"][number]): part is ToolUIPart {
181
+ export function getAnswerText(message: UIMessage): string {
182
+ const textOf = (parts: IndexedPart[]) =>
183
+ parts.flatMap(({ part }) => (part.type === "text" ? [part.text] : []));
184
+ const { work, rest } = splitTurn(message.parts);
185
+ const answer = textOf(rest);
186
+ return (answer.length > 0 ? answer : textOf(work)).join("\n");
187
+ }
188
+
189
+ function splitTurn(parts: UIMessage["parts"]): {
190
+ work: IndexedPart[];
191
+ rest: IndexedPart[];
192
+ } {
193
+ const visible = parts
194
+ .map((part, index) => ({ part, index }))
195
+ .filter(({ part }) => isVisible(part));
196
+ const lastWork = visible.findLastIndex(
197
+ ({ part }) =>
198
+ part.type === "reasoning" ||
199
+ (isAnyToolPart(part) && !isPendingApproval(part)),
200
+ );
201
+ return {
202
+ work: visible.filter(
203
+ ({ part }, i) => i <= lastWork && !isPendingApproval(part),
204
+ ),
205
+ rest: visible.filter(
206
+ ({ part }, i) => i > lastWork || isPendingApproval(part),
207
+ ),
208
+ };
209
+ }
210
+
211
+ function isPendingApproval(part: Part): boolean {
212
+ return isAnyToolPart(part) && part.state === "approval-requested";
213
+ }
214
+
215
+ function isVisible(part: Part): boolean {
216
+ switch (part.type) {
217
+ case "step-start":
218
+ return false;
219
+ case "text":
220
+ return part.text.trim().length > 0;
221
+ case "reasoning":
222
+ // Some models (e.g. OpenAI's o-series) emit empty reasoning parts.
223
+ return part.text.trim().length > 0;
224
+ default:
225
+ return !isDataPart(part);
226
+ }
227
+ }
228
+
229
+ function isAnyToolPart(part: Part): part is ToolUIPart | DynamicToolUIPart {
230
+ return isToolPart(part) || part.type === "dynamic-tool";
231
+ }
232
+
233
+ function isToolPart(part: Part): part is ToolUIPart {
203
234
  return part.type.startsWith("tool-");
204
235
  }
205
236
 
206
- function isDataPart(
207
- part: UIMessage["parts"][number],
208
- ): part is DataUIPart<Record<string, unknown>> {
237
+ function isDataPart(part: Part): part is DataUIPart<Record<string, unknown>> {
209
238
  return part.type.startsWith("data-");
210
239
  }
@@ -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
  );