@marimo-team/islands 0.25.3-dev265 → 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-DzYPyiOs.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-CJExrSFe.js → code-visibility-BKBl9Rh6.js} +9 -9
  14. package/dist/{common-5mM7a8cm.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--Un2Lk6m.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-C2NUXbzh.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-Dj9U-NK9.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-CkbsxIrq.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-H8QAAmdr.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-BperrCFw.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
@@ -2,11 +2,9 @@
2
2
 
3
3
  import type { ChatAddToolApproveResponseFunction } from "ai";
4
4
  import React from "react";
5
- import { logNever } from "@/utils/assertNever";
6
5
  import type { ToolApproval, ToolState } from "./shared";
7
6
  import { ToolApprovalCard } from "./tool-approval-card";
8
- import { ToolErrorCard } from "./tool-error-card";
9
- import { ToolHistoryRow } from "./tool-history-row";
7
+ import { ToolStep } from "./tool-step";
10
8
 
11
9
  interface ToolCallViewProps {
12
10
  toolName: string;
@@ -16,88 +14,36 @@ interface ToolCallViewProps {
16
14
  input?: unknown;
17
15
  approval?: ToolApproval;
18
16
  onApprove?: ChatAddToolApproveResponseFunction;
19
- index?: number;
20
17
  className?: string;
21
- /**
22
- * Whether this tool call belongs to the latest message in the conversation.
23
- * Used by error cards to decide whether to stay expanded (live) or collapse
24
- * (the user has moved on).
25
- */
26
- isLive?: boolean;
27
18
  isActive?: boolean;
28
19
  }
29
20
 
30
21
  export const ToolCallView: React.FC<ToolCallViewProps> = ({
31
22
  toolName,
32
23
  state,
33
- result,
34
- errorText,
35
- input,
36
24
  approval,
37
25
  onApprove,
38
- index,
39
- className,
40
- isLive = true,
41
- isActive = true,
26
+ ...props
42
27
  }) => {
43
- switch (state) {
44
- case "approval-requested":
45
- // Approval is a live, blocking action — render it as a prominent card
46
- // Fall back to history row if the wiring isn't
47
- // available (shouldn't happen in practice).
48
- if (approval != null && onApprove != null) {
49
- return (
50
- <ToolApprovalCard
51
- toolName={toolName}
52
- input={input}
53
- approval={approval}
54
- onApprove={onApprove}
55
- className={className}
56
- />
57
- );
58
- }
59
- return (
60
- <ToolHistoryRow
61
- toolName={toolName}
62
- state="input-available"
63
- input={input}
64
- index={index}
65
- className={className}
66
- isActive={isActive}
67
- />
68
- );
69
-
70
- case "output-error":
71
- return (
72
- <ToolErrorCard
73
- toolName={toolName}
74
- input={input}
75
- errorText={errorText}
76
- isLive={isLive}
77
- className={className}
78
- />
79
- );
80
-
81
- case "input-streaming":
82
- case "input-available":
83
- case "approval-responded":
84
- case "output-available":
85
- case "output-denied":
86
- return (
87
- <ToolHistoryRow
88
- toolName={toolName}
89
- state={state}
90
- input={input}
91
- result={result}
92
- approval={approval}
93
- index={index}
94
- className={className}
95
- isActive={isActive}
96
- />
97
- );
98
-
99
- default:
100
- logNever(state);
101
- return null;
28
+ if (state === "approval-requested") {
29
+ return approval != null && onApprove != null ? (
30
+ <ToolApprovalCard
31
+ toolName={toolName}
32
+ input={props.input}
33
+ approval={approval}
34
+ onApprove={onApprove}
35
+ className={props.className}
36
+ />
37
+ ) : (
38
+ <ToolStep {...props} toolName={toolName} state="input-available" />
39
+ );
102
40
  }
41
+ return (
42
+ <ToolStep
43
+ {...props}
44
+ toolName={toolName}
45
+ state={state}
46
+ approval={approval}
47
+ />
48
+ );
103
49
  };
@@ -0,0 +1,172 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import {
4
+ BookOpenIcon,
5
+ BugIcon,
6
+ DatabaseIcon,
7
+ FileSearchIcon,
8
+ ListChecksIcon,
9
+ type LucideIcon,
10
+ NetworkIcon,
11
+ NotebookIcon,
12
+ PencilIcon,
13
+ PlusIcon,
14
+ ScanSearchIcon,
15
+ SquareTerminalIcon,
16
+ Trash2Icon,
17
+ VariableIcon,
18
+ WrenchIcon,
19
+ } from "lucide-react";
20
+ import { formatToolName } from "./shared";
21
+
22
+ interface ToolDescription {
23
+ label: string;
24
+ detail?: string;
25
+ icon: LucideIcon;
26
+ }
27
+
28
+ type Phase = "running" | "done";
29
+
30
+ const KNOWN_TOOLS: Record<
31
+ string,
32
+ { running: string; done: string; icon: LucideIcon }
33
+ > = {
34
+ get_marimo_rules: {
35
+ running: "Reading marimo guidelines",
36
+ done: "Read marimo guidelines",
37
+ icon: BookOpenIcon,
38
+ },
39
+ get_active_notebooks: {
40
+ running: "Listing notebooks",
41
+ done: "Listed notebooks",
42
+ icon: NotebookIcon,
43
+ },
44
+ get_lightweight_cell_map: {
45
+ running: "Reading notebook",
46
+ done: "Read notebook",
47
+ icon: FileSearchIcon,
48
+ },
49
+ get_cell_runtime_data: {
50
+ running: "Inspecting cells",
51
+ done: "Inspected cells",
52
+ icon: ScanSearchIcon,
53
+ },
54
+ get_cell_outputs: {
55
+ running: "Reading cell outputs",
56
+ done: "Read cell outputs",
57
+ icon: ScanSearchIcon,
58
+ },
59
+ get_tables_and_variables: {
60
+ running: "Reading variables",
61
+ done: "Read variables",
62
+ icon: VariableIcon,
63
+ },
64
+ get_database_tables: {
65
+ running: "Reading database tables",
66
+ done: "Read database tables",
67
+ icon: DatabaseIcon,
68
+ },
69
+ get_notebook_errors: {
70
+ running: "Checking for errors",
71
+ done: "Checked for errors",
72
+ icon: BugIcon,
73
+ },
74
+ lint_notebook: {
75
+ running: "Linting notebook",
76
+ done: "Linted notebook",
77
+ icon: ListChecksIcon,
78
+ },
79
+ get_cell_dependency_graph: {
80
+ running: "Reading dependency graph",
81
+ done: "Read dependency graph",
82
+ icon: NetworkIcon,
83
+ },
84
+ };
85
+
86
+ const EDIT_LABELS = {
87
+ add_cell: { running: "Adding cell", done: "Added cell", icon: PlusIcon },
88
+ update_cell: {
89
+ running: "Editing cell",
90
+ done: "Edited cell",
91
+ icon: PencilIcon,
92
+ },
93
+ delete_cell: {
94
+ running: "Deleting cell",
95
+ done: "Deleted cell",
96
+ icon: Trash2Icon,
97
+ },
98
+ } as const;
99
+
100
+ export function describeToolCall(
101
+ toolName: string,
102
+ input: unknown,
103
+ phase: Phase,
104
+ ): ToolDescription {
105
+ const name = formatToolName(toolName);
106
+
107
+ if (name === "execute_code") {
108
+ return {
109
+ label: phase === "running" ? "Running code" : "Ran code",
110
+ detail: isRecord(input) ? summarizeCode(input.code) : undefined,
111
+ icon: SquareTerminalIcon,
112
+ };
113
+ }
114
+
115
+ if (name === "edit_notebook_tool") {
116
+ const edit = getEdit(input);
117
+ if (edit) {
118
+ const labels = EDIT_LABELS[edit.type];
119
+ return {
120
+ label: labels[phase],
121
+ detail: summarizeCode(edit.code),
122
+ icon: labels.icon,
123
+ };
124
+ }
125
+ return {
126
+ label: phase === "running" ? "Editing notebook" : "Edited notebook",
127
+ icon: PencilIcon,
128
+ };
129
+ }
130
+
131
+ if (Object.hasOwn(KNOWN_TOOLS, name)) {
132
+ const known = KNOWN_TOOLS[name];
133
+ return { label: known[phase], icon: known.icon };
134
+ }
135
+
136
+ return {
137
+ label: phase === "running" ? "Calling" : "Called",
138
+ detail: name,
139
+ icon: WrenchIcon,
140
+ };
141
+ }
142
+
143
+ function getEdit(
144
+ input: unknown,
145
+ ): { type: keyof typeof EDIT_LABELS; code?: string } | undefined {
146
+ if (!isRecord(input) || !isRecord(input.edit)) {
147
+ return undefined;
148
+ }
149
+ const { type, code } = input.edit;
150
+ if (typeof type !== "string" || !Object.hasOwn(EDIT_LABELS, type)) {
151
+ return undefined;
152
+ }
153
+ return {
154
+ type: type as keyof typeof EDIT_LABELS,
155
+ code: typeof code === "string" ? code : undefined,
156
+ };
157
+ }
158
+
159
+ function summarizeCode(code: unknown): string | undefined {
160
+ if (typeof code !== "string") {
161
+ return undefined;
162
+ }
163
+ const lines = code
164
+ .split("\n")
165
+ .map((line) => line.trim())
166
+ .filter((line) => line.length > 0);
167
+ return lines.find((line) => !/^(import\s|from\s|#)/.test(line)) ?? lines[0];
168
+ }
169
+
170
+ function isRecord(value: unknown): value is Record<string, unknown> {
171
+ return typeof value === "object" && value !== null;
172
+ }
@@ -0,0 +1,145 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import {
4
+ BanIcon,
5
+ ChevronRightIcon,
6
+ CircleSlashIcon,
7
+ Loader2Icon,
8
+ type LucideIcon,
9
+ XCircleIcon,
10
+ } from "lucide-react";
11
+ import React from "react";
12
+ import { cn } from "@/utils/cn";
13
+ import {
14
+ PENDING_TOOL_STATES,
15
+ type ToolApproval,
16
+ type ToolState,
17
+ } from "./shared";
18
+ import { ToolArgsRenderer } from "./tool-args";
19
+ import { describeToolCall } from "./tool-labels";
20
+ import { ResultRenderer } from "./tool-result";
21
+
22
+ type ToolStepState = Exclude<ToolState, "approval-requested">;
23
+
24
+ interface ToolStepProps {
25
+ toolName: string;
26
+ state: ToolStepState;
27
+ input?: unknown;
28
+ result?: unknown;
29
+ errorText?: string;
30
+ approval?: ToolApproval;
31
+ isActive?: boolean;
32
+ className?: string;
33
+ }
34
+
35
+ export const ToolStep: React.FC<ToolStepProps> = ({
36
+ toolName,
37
+ state,
38
+ input,
39
+ result,
40
+ errorText,
41
+ approval,
42
+ isActive = true,
43
+ className,
44
+ }) => {
45
+ const [open, setOpen] = React.useState(false);
46
+ const pending = PENDING_TOOL_STATES.has(state);
47
+ const { label, detail, icon } = describeToolCall(
48
+ toolName,
49
+ input,
50
+ pending ? "running" : "done",
51
+ );
52
+ const status = getStatus(state, pending && !isActive, icon);
53
+ const failed = state === "output-error";
54
+
55
+ return (
56
+ <div className={cn("w-full", className)}>
57
+ <button
58
+ type="button"
59
+ onClick={() => setOpen((prev) => !prev)}
60
+ aria-expanded={open}
61
+ className={cn(
62
+ "group/step -mx-1.5 flex w-fit max-w-full min-w-0 items-center gap-1.5 rounded-md px-1.5 py-0.5 text-left text-xs leading-4 text-muted-foreground transition-colors hover:bg-foreground/6 hover:text-foreground focus-visible:outline-1 focus-visible:outline-(--slate-8)",
63
+ open && "text-foreground",
64
+ failed && "text-(--red-11) hover:bg-(--red-3) hover:text-(--red-11)",
65
+ )}
66
+ >
67
+ <status.icon
68
+ aria-hidden={true}
69
+ className={cn(
70
+ // Offsets lucide's built-in padding.
71
+ "-ml-px size-3 shrink-0",
72
+ status.spinning && "animate-spin",
73
+ )}
74
+ />
75
+ <span className="shrink-0">{label}</span>
76
+ {detail && (
77
+ <code className="min-w-0 truncate font-mono text-[0.6875rem] opacity-80">
78
+ {detail}
79
+ </code>
80
+ )}
81
+ {status.suffix && (
82
+ <span className="shrink-0 opacity-80">· {status.suffix}</span>
83
+ )}
84
+ <ChevronRightIcon
85
+ aria-hidden={true}
86
+ className={cn(
87
+ "size-3 shrink-0 opacity-40 transition-transform group-hover/step:opacity-100",
88
+ open && "rotate-90 opacity-100",
89
+ )}
90
+ />
91
+ </button>
92
+ {open && (
93
+ <ToolStepDetails
94
+ state={state}
95
+ input={input}
96
+ result={result}
97
+ errorText={errorText}
98
+ approval={approval}
99
+ />
100
+ )}
101
+ </div>
102
+ );
103
+ };
104
+
105
+ function getStatus(
106
+ state: ToolStepState,
107
+ interrupted: boolean,
108
+ doneIcon: LucideIcon,
109
+ ): { icon: LucideIcon; suffix?: string; spinning?: boolean } {
110
+ if (interrupted) {
111
+ return { icon: CircleSlashIcon, suffix: "Stopped" };
112
+ }
113
+ switch (state) {
114
+ case "output-error":
115
+ return { icon: XCircleIcon, suffix: "Failed" };
116
+ case "output-denied":
117
+ return { icon: BanIcon, suffix: "Denied" };
118
+ case "output-available":
119
+ return { icon: doneIcon };
120
+ default:
121
+ return { icon: Loader2Icon, spinning: true };
122
+ }
123
+ }
124
+
125
+ const ToolStepDetails: React.FC<
126
+ Pick<ToolStepProps, "state" | "input" | "result" | "errorText" | "approval">
127
+ > = ({ state, input, result, errorText, approval }) => (
128
+ <div className="mt-1 mb-2 ml-5.5 space-y-3">
129
+ <ToolArgsRenderer input={input} />
130
+ {state === "output-available" && result != null && (
131
+ <ResultRenderer result={result} />
132
+ )}
133
+ {state === "output-error" && errorText && (
134
+ <pre className="max-h-64 overflow-auto rounded border border-(--red-6) bg-(--red-2) p-2 text-xs leading-relaxed whitespace-pre-wrap text-(--red-11) scrollbar-thin">
135
+ {errorText}
136
+ </pre>
137
+ )}
138
+ {state === "output-denied" && (
139
+ <p className="text-xs text-muted-foreground">
140
+ Tool execution was denied
141
+ {approval?.reason ? `: ${approval.reason}` : "."}
142
+ </p>
143
+ )}
144
+ </div>
145
+ );
@@ -0,0 +1,73 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import type { UIMessage } from "ai";
4
+
5
+ export interface TurnTiming {
6
+ startedAt: number;
7
+ finishedAt?: number;
8
+ stopped?: boolean;
9
+ }
10
+
11
+ export function getTurnTiming(message: UIMessage): TurnTiming | undefined {
12
+ const metadata = message.metadata;
13
+ if (typeof metadata !== "object" || metadata === null) {
14
+ return undefined;
15
+ }
16
+ const { startedAt, finishedAt, stopped } = metadata as Record<
17
+ string,
18
+ unknown
19
+ >;
20
+ if (typeof startedAt !== "number") {
21
+ return undefined;
22
+ }
23
+ return {
24
+ startedAt,
25
+ finishedAt: typeof finishedAt === "number" ? finishedAt : undefined,
26
+ stopped: stopped === true,
27
+ };
28
+ }
29
+
30
+ export function withTurnTiming(
31
+ messages: UIMessage[],
32
+ turn: { startedAt: number | null; stopped: boolean },
33
+ now = Date.now(),
34
+ ): UIMessage[] {
35
+ const last = messages.at(-1);
36
+ if (last?.role !== "assistant") {
37
+ return messages;
38
+ }
39
+ const startedAt = turn.startedAt ?? getTurnTiming(last)?.startedAt;
40
+ if (startedAt == null) {
41
+ return messages;
42
+ }
43
+ const metadata =
44
+ typeof last.metadata === "object" && last.metadata !== null
45
+ ? last.metadata
46
+ : {};
47
+ return [
48
+ ...messages.slice(0, -1),
49
+ {
50
+ ...last,
51
+ metadata: {
52
+ ...metadata,
53
+ startedAt,
54
+ finishedAt: now,
55
+ stopped: turn.stopped,
56
+ },
57
+ },
58
+ ];
59
+ }
60
+
61
+ export function formatDuration(ms: number): string {
62
+ const totalSeconds = Math.max(1, Math.round(ms / 1000));
63
+ const hours = Math.floor(totalSeconds / 3600);
64
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
65
+ const seconds = totalSeconds % 60;
66
+ if (hours > 0) {
67
+ return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
68
+ }
69
+ if (minutes > 0) {
70
+ return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
71
+ }
72
+ return `${seconds}s`;
73
+ }
@@ -0,0 +1,65 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import type { UIMessage } from "ai";
4
+ import { useState } from "react";
5
+ import useEvent from "react-use-event-hook";
6
+ import { willAutoContinue } from "./chat-utils";
7
+ import { getTurnTiming, withTurnTiming } from "./turn-timing";
8
+
9
+ export function useAssistantTurn() {
10
+ const [turn, setTurn] = useState<{
11
+ startedAt: number;
12
+ ended: boolean;
13
+ } | null>(null);
14
+
15
+ const start = useEvent(() =>
16
+ setTurn({ startedAt: Date.now(), ended: false }),
17
+ );
18
+ const reset = useEvent(() => setTurn(null));
19
+ const stop = useEvent(() =>
20
+ setTurn((prev) => prev && { ...prev, ended: true }),
21
+ );
22
+
23
+ // Shift the start forward so time spent waiting on the user isn't counted.
24
+ const resume = useEvent((reply: UIMessage | undefined) => {
25
+ const timing = reply && getTurnTiming(reply);
26
+ const workedMs =
27
+ timing?.finishedAt == null ? 0 : timing.finishedAt - timing.startedAt;
28
+ setTurn({ startedAt: Date.now() - workedMs, ended: false });
29
+ });
30
+
31
+ const finish = useEvent(
32
+ (finished: UIMessage[], opts: { isAbort: boolean; isError: boolean }) => {
33
+ if (opts.isAbort || opts.isError) {
34
+ stop();
35
+ }
36
+ return withTurnTiming(finished, {
37
+ startedAt: turn?.startedAt ?? null,
38
+ stopped: opts.isAbort,
39
+ });
40
+ },
41
+ );
42
+
43
+ // Between requests of one turn the SDK briefly reports "ready".
44
+ const isActive = (state: {
45
+ isLoading: boolean;
46
+ hasError: boolean;
47
+ messages: UIMessage[];
48
+ }) =>
49
+ state.isLoading ||
50
+ (turn != null &&
51
+ !turn.ended &&
52
+ !state.hasError &&
53
+ willAutoContinue(state.messages));
54
+
55
+ return {
56
+ startedAt: turn?.startedAt ?? null,
57
+ hasEnded: turn?.ended === true,
58
+ start,
59
+ resume,
60
+ stop,
61
+ reset,
62
+ finish,
63
+ isActive,
64
+ };
65
+ }
@@ -5,15 +5,16 @@ import { useSetAtom } from "jotai";
5
5
  import { useEffect } from "react";
6
6
  import { chatGenerationInProgressAtom } from "@/core/ai/state";
7
7
 
8
- export function useChatGeneration(status: ChatStatus) {
8
+ export function isChatBusy(status: ChatStatus): boolean {
9
+ return status === "submitted" || status === "streaming";
10
+ }
11
+
12
+ export function useChatGeneration(isGenerating: boolean) {
9
13
  const setGenerating = useSetAtom(chatGenerationInProgressAtom);
10
- const isGenerating = status === "submitted" || status === "streaming";
11
14
 
12
15
  useEffect(() => {
13
16
  setGenerating(isGenerating);
14
17
  // useChat stops its request when the panel unmounts or becomes hidden.
15
18
  return () => setGenerating(false);
16
19
  }, [isGenerating, setGenerating]);
17
-
18
- return isGenerating;
19
20
  }