@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
@@ -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
+ }
@@ -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
  }