@useatlas/react 0.0.1

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 (98) hide show
  1. package/README.md +95 -0
  2. package/dist/chunk-2WFDP7G5.js +231 -0
  3. package/dist/chunk-2WFDP7G5.js.map +1 -0
  4. package/dist/chunk-44HBZYKP.js +224 -0
  5. package/dist/chunk-44HBZYKP.js.map +1 -0
  6. package/dist/chunk-5SEVKHS5.cjs +229 -0
  7. package/dist/chunk-5SEVKHS5.cjs.map +1 -0
  8. package/dist/chunk-UIRB6L36.cjs +249 -0
  9. package/dist/chunk-UIRB6L36.cjs.map +1 -0
  10. package/dist/hooks.cjs +251 -0
  11. package/dist/hooks.cjs.map +1 -0
  12. package/dist/hooks.d.cts +132 -0
  13. package/dist/hooks.d.ts +132 -0
  14. package/dist/hooks.js +237 -0
  15. package/dist/hooks.js.map +1 -0
  16. package/dist/index.cjs +2976 -0
  17. package/dist/index.cjs.map +1 -0
  18. package/dist/index.d.cts +69 -0
  19. package/dist/index.d.ts +69 -0
  20. package/dist/index.js +2926 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/result-chart-NFAJ4IQ5.js +398 -0
  23. package/dist/result-chart-NFAJ4IQ5.js.map +1 -0
  24. package/dist/result-chart-YLCKBNV4.cjs +400 -0
  25. package/dist/result-chart-YLCKBNV4.cjs.map +1 -0
  26. package/dist/styles.css +59 -0
  27. package/dist/use-dark-mode-rFxawUv1.d.cts +123 -0
  28. package/dist/use-dark-mode-rFxawUv1.d.ts +123 -0
  29. package/dist/widget.css +2 -0
  30. package/dist/widget.js +445 -0
  31. package/package.json +113 -0
  32. package/src/components/__tests__/tool-renderers.test.tsx +239 -0
  33. package/src/components/actions/action-approval-card.tsx +296 -0
  34. package/src/components/actions/action-status-badge.tsx +50 -0
  35. package/src/components/admin/change-password-dialog.tsx +128 -0
  36. package/src/components/atlas-chat.tsx +656 -0
  37. package/src/components/chart/chart-detection.ts +318 -0
  38. package/src/components/chart/result-chart.tsx +590 -0
  39. package/src/components/chat/api-key-bar.tsx +66 -0
  40. package/src/components/chat/copy-button.tsx +25 -0
  41. package/src/components/chat/data-table.tsx +104 -0
  42. package/src/components/chat/error-banner.tsx +32 -0
  43. package/src/components/chat/explore-card.tsx +41 -0
  44. package/src/components/chat/follow-up-chips.tsx +29 -0
  45. package/src/components/chat/loading-card.tsx +10 -0
  46. package/src/components/chat/managed-auth-card.tsx +116 -0
  47. package/src/components/chat/markdown.tsx +146 -0
  48. package/src/components/chat/python-result-card.tsx +245 -0
  49. package/src/components/chat/sql-block.tsx +54 -0
  50. package/src/components/chat/sql-result-card.tsx +163 -0
  51. package/src/components/chat/starter-prompts.ts +6 -0
  52. package/src/components/chat/tool-part.tsx +106 -0
  53. package/src/components/chat/typing-indicator.tsx +22 -0
  54. package/src/components/conversations/conversation-item.tsx +135 -0
  55. package/src/components/conversations/conversation-list.tsx +69 -0
  56. package/src/components/conversations/conversation-sidebar.tsx +113 -0
  57. package/src/components/conversations/delete-confirmation.tsx +27 -0
  58. package/src/components/schema-explorer/schema-explorer.tsx +517 -0
  59. package/src/components/ui/alert-dialog.tsx +196 -0
  60. package/src/components/ui/badge.tsx +48 -0
  61. package/src/components/ui/button.tsx +64 -0
  62. package/src/components/ui/card.tsx +92 -0
  63. package/src/components/ui/dialog.tsx +158 -0
  64. package/src/components/ui/dropdown-menu.tsx +257 -0
  65. package/src/components/ui/input.tsx +21 -0
  66. package/src/components/ui/label.tsx +24 -0
  67. package/src/components/ui/scroll-area.tsx +62 -0
  68. package/src/components/ui/separator.tsx +28 -0
  69. package/src/components/ui/sheet.tsx +143 -0
  70. package/src/components/ui/table.tsx +116 -0
  71. package/src/components/ui/toggle-group.tsx +83 -0
  72. package/src/components/ui/toggle.tsx +47 -0
  73. package/src/context.tsx +85 -0
  74. package/src/env.d.ts +9 -0
  75. package/src/hooks/__tests__/provider.test.tsx +83 -0
  76. package/src/hooks/__tests__/use-atlas-auth.test.tsx +283 -0
  77. package/src/hooks/__tests__/use-atlas-chat.test.tsx +157 -0
  78. package/src/hooks/__tests__/use-atlas-conversations.test.tsx +159 -0
  79. package/src/hooks/__tests__/use-atlas-theme.test.tsx +56 -0
  80. package/src/hooks/index.ts +47 -0
  81. package/src/hooks/provider.tsx +77 -0
  82. package/src/hooks/theme-init-script.ts +17 -0
  83. package/src/hooks/use-atlas-auth.ts +131 -0
  84. package/src/hooks/use-atlas-chat.ts +102 -0
  85. package/src/hooks/use-atlas-conversations.ts +61 -0
  86. package/src/hooks/use-atlas-theme.ts +34 -0
  87. package/src/hooks/use-conversations.ts +189 -0
  88. package/src/hooks/use-dark-mode.ts +150 -0
  89. package/src/index.ts +36 -0
  90. package/src/lib/action-types.ts +11 -0
  91. package/src/lib/helpers.ts +198 -0
  92. package/src/lib/tool-renderer-types.ts +76 -0
  93. package/src/lib/types.ts +29 -0
  94. package/src/lib/utils.ts +6 -0
  95. package/src/styles.css +59 -0
  96. package/src/test-setup.ts +55 -0
  97. package/src/widget-entry.ts +20 -0
  98. package/src/widget.css +12 -0
package/package.json ADDED
@@ -0,0 +1,113 @@
1
+ {
2
+ "name": "@useatlas/react",
3
+ "version": "0.0.1",
4
+ "description": "Embeddable Atlas chat UI for React applications",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./src/index.ts",
8
+ "module": "./src/index.ts",
9
+ "types": "./src/index.ts",
10
+ "exports": {
11
+ ".": "./src/index.ts",
12
+ "./hooks": "./src/hooks/index.ts",
13
+ "./styles.css": "./src/styles.css"
14
+ },
15
+ "publishConfig": {
16
+ "main": "./dist/index.cjs",
17
+ "module": "./dist/index.js",
18
+ "types": "./dist/index.d.ts",
19
+ "exports": {
20
+ ".": {
21
+ "import": {
22
+ "types": "./dist/index.d.ts",
23
+ "default": "./dist/index.js"
24
+ },
25
+ "require": {
26
+ "types": "./dist/index.d.cts",
27
+ "default": "./dist/index.cjs"
28
+ }
29
+ },
30
+ "./hooks": {
31
+ "import": {
32
+ "types": "./dist/hooks.d.ts",
33
+ "default": "./dist/hooks.js"
34
+ },
35
+ "require": {
36
+ "types": "./dist/hooks.d.cts",
37
+ "default": "./dist/hooks.cjs"
38
+ }
39
+ },
40
+ "./styles.css": "./dist/styles.css"
41
+ }
42
+ },
43
+ "files": [
44
+ "dist",
45
+ "src"
46
+ ],
47
+ "sideEffects": [
48
+ "*.css"
49
+ ],
50
+ "scripts": {
51
+ "build": "tsup && cp src/styles.css dist/styles.css && bun x @tailwindcss/cli -i src/widget.css -o dist/widget.css --minify",
52
+ "dev": "tsup --watch",
53
+ "type": "tsc --noEmit"
54
+ },
55
+ "peerDependencies": {
56
+ "@ai-sdk/react": ">=3.0.0",
57
+ "ai": ">=6.0.0",
58
+ "lucide-react": ">=0.400.0",
59
+ "react": ">=18.0.0",
60
+ "react-dom": ">=18.0.0",
61
+ "react-syntax-highlighter": ">=15.0.0",
62
+ "recharts": ">=2.0.0",
63
+ "tailwindcss": ">=4.0.0",
64
+ "xlsx": ">=0.18.0"
65
+ },
66
+ "peerDependenciesMeta": {
67
+ "lucide-react": {
68
+ "optional": true
69
+ },
70
+ "react-syntax-highlighter": {
71
+ "optional": true
72
+ },
73
+ "recharts": {
74
+ "optional": true
75
+ },
76
+ "tailwindcss": {
77
+ "optional": true
78
+ },
79
+ "xlsx": {
80
+ "optional": true
81
+ }
82
+ },
83
+ "dependencies": {
84
+ "@useatlas/types": "workspace:*",
85
+ "@radix-ui/react-slot": "^1.2.3",
86
+ "class-variance-authority": "^0.7.1",
87
+ "clsx": "^2.1.1",
88
+ "radix-ui": "^1.4.3",
89
+ "react-markdown": "^10.1.0",
90
+ "remark-gfm": "^4.0.1",
91
+ "tailwind-merge": "^3.5.0"
92
+ },
93
+ "devDependencies": {
94
+ "@ai-sdk/react": "^3.0.99",
95
+ "@tailwindcss/cli": "^4.2.1",
96
+ "@testing-library/dom": "^10.4.1",
97
+ "@testing-library/react": "^16.3.2",
98
+ "@types/react": "^19.2.14",
99
+ "@types/react-dom": "^19.2.3",
100
+ "@types/react-syntax-highlighter": "^15.5.13",
101
+ "ai": "^6.0.97",
102
+ "happy-dom": "16.6.0",
103
+ "lucide-react": "^0.577.0",
104
+ "react": "^19.2.4",
105
+ "react-dom": "^19.2.4",
106
+ "react-syntax-highlighter": "^16.1.0",
107
+ "recharts": "^3.7.0",
108
+ "tailwindcss": "^4.2.1",
109
+ "tsup": "^8.5.0",
110
+ "typescript": "^5.9.3",
111
+ "xlsx": "^0.18.5"
112
+ }
113
+ }
@@ -0,0 +1,239 @@
1
+ import { describe, it, expect, mock } from "bun:test";
2
+ import type React from "react";
3
+ import { render, screen } from "@testing-library/react";
4
+ import type { ToolRendererProps, ToolRenderers } from "../../lib/tool-renderer-types";
5
+
6
+ // Mock the `ai` module — must mock ALL named exports used by the component tree
7
+ mock.module("ai", () => ({
8
+ getToolName: (part: Record<string, unknown>) => {
9
+ if (!part.toolName) throw new Error("Unknown tool part");
10
+ return part.toolName as string;
11
+ },
12
+ isToolUIPart: () => true,
13
+ DefaultChatTransport: class {},
14
+ }));
15
+
16
+ // Import after mocks
17
+ const { ToolPart } = await import("../chat/tool-part");
18
+
19
+ function makePart(toolName: string, args: Record<string, unknown>, output: unknown, state = "output-available") {
20
+ return { toolName, input: args, output, state };
21
+ }
22
+
23
+ describe("ToolPart with custom renderers", () => {
24
+ it("uses custom renderer when provided for a tool", () => {
25
+ function CustomSQL({ toolName, args, result, isLoading }: ToolRendererProps) {
26
+ return (
27
+ <div data-testid="custom-sql">
28
+ {toolName}|{String(args.sql)}|{JSON.stringify(result)}|{String(isLoading)}
29
+ </div>
30
+ );
31
+ }
32
+
33
+ const renderers: ToolRenderers = { executeSQL: CustomSQL };
34
+ const part = makePart("executeSQL", { sql: "SELECT 1" }, { success: true, columns: ["a"], rows: [{ a: 1 }] });
35
+
36
+ render(<ToolPart part={part} toolRenderers={renderers} />);
37
+
38
+ const el = screen.getByTestId("custom-sql");
39
+ expect(el.textContent).toContain("executeSQL");
40
+ expect(el.textContent).toContain("SELECT 1");
41
+ expect(el.textContent).toContain("false"); // isLoading should be false when state is output-available
42
+ });
43
+
44
+ it("falls back to default renderer when no custom renderer provided", () => {
45
+ const part = makePart("executeSQL", { sql: "SELECT 1" }, { success: true, columns: [], rows: [] });
46
+
47
+ // No toolRenderers prop → default renderer
48
+ const { container } = render(<ToolPart part={part} />);
49
+
50
+ // Default SQL card renders — should not have our custom testid
51
+ expect(screen.queryByTestId("custom-sql")).toBeNull();
52
+ // The default card renders something (the SQL result card)
53
+ expect(container.innerHTML.length).toBeGreaterThan(0);
54
+ });
55
+
56
+ it("falls back to default renderer for tools not in the renderers map", () => {
57
+ function CustomSQL() {
58
+ return <div data-testid="custom-sql">custom</div>;
59
+ }
60
+
61
+ const renderers: ToolRenderers = { executeSQL: CustomSQL };
62
+ const part = makePart("explore", { command: "ls" }, "file.txt");
63
+
64
+ render(<ToolPart part={part} toolRenderers={renderers} />);
65
+
66
+ // explore is not in renderers → uses default ExploreCard
67
+ expect(screen.queryByTestId("custom-sql")).toBeNull();
68
+ });
69
+
70
+ it("passes isLoading=true and result=null when tool is still running", () => {
71
+ const receivedProps: ToolRendererProps[] = [];
72
+ function Spy(props: ToolRendererProps) {
73
+ receivedProps.push(props);
74
+ return <div data-testid="custom-explore">{String(props.isLoading)}</div>;
75
+ }
76
+
77
+ const renderers: ToolRenderers = { explore: Spy };
78
+ const part = makePart("explore", { command: "ls" }, null, "call");
79
+
80
+ render(<ToolPart part={part} toolRenderers={renderers} />);
81
+
82
+ expect(screen.getByTestId("custom-explore").textContent).toBe("true");
83
+ expect(receivedProps[0].isLoading).toBe(true);
84
+ expect(receivedProps[0].result).toBeNull();
85
+ });
86
+
87
+ it("passes isLoading=false when tool is complete", () => {
88
+ function CustomExplore({ isLoading }: ToolRendererProps) {
89
+ return <div data-testid="custom-explore">{String(isLoading)}</div>;
90
+ }
91
+
92
+ const renderers: ToolRenderers = { explore: CustomExplore };
93
+ const part = makePart("explore", { command: "ls" }, "output", "output-available");
94
+
95
+ render(<ToolPart part={part} toolRenderers={renderers} />);
96
+
97
+ expect(screen.getByTestId("custom-explore").textContent).toBe("false");
98
+ });
99
+
100
+ it("supports custom renderer for executePython", () => {
101
+ function CustomPython({ toolName, result }: ToolRendererProps) {
102
+ return <div data-testid="custom-python">{toolName}|{JSON.stringify(result)}</div>;
103
+ }
104
+
105
+ const renderers: ToolRenderers = { executePython: CustomPython };
106
+ const part = makePart("executePython", { code: "print(1)" }, { success: true, output: "1" });
107
+
108
+ render(<ToolPart part={part} toolRenderers={renderers} />);
109
+
110
+ const el = screen.getByTestId("custom-python");
111
+ expect(el.textContent).toContain("executePython");
112
+ expect(el.textContent).toContain('"success":true');
113
+ });
114
+
115
+ it("supports custom renderer for arbitrary tool names", () => {
116
+ function CustomTool({ toolName }: ToolRendererProps) {
117
+ return <div data-testid="custom-tool">{toolName}</div>;
118
+ }
119
+
120
+ const renderers: ToolRenderers = { myCustomTool: CustomTool };
121
+ const part = makePart("myCustomTool", {}, { data: "hello" });
122
+
123
+ render(<ToolPart part={part} toolRenderers={renderers} />);
124
+
125
+ expect(screen.getByTestId("custom-tool").textContent).toBe("myCustomTool");
126
+ });
127
+
128
+ it("passes correct args from the tool part", () => {
129
+ const receivedProps: ToolRendererProps[] = [];
130
+ function Spy(props: ToolRendererProps) {
131
+ receivedProps.push(props);
132
+ return <div data-testid="spy">ok</div>;
133
+ }
134
+
135
+ const renderers: ToolRenderers = { executeSQL: Spy };
136
+ const part = makePart("executeSQL", { sql: "SELECT *", explanation: "Get all" }, { success: true });
137
+
138
+ render(<ToolPart part={part} toolRenderers={renderers} />);
139
+
140
+ expect(receivedProps).toHaveLength(1);
141
+ expect(receivedProps[0].toolName).toBe("executeSQL");
142
+ expect(receivedProps[0].args).toEqual({ sql: "SELECT *", explanation: "Get all" });
143
+ expect(receivedProps[0].result).toEqual({ success: true });
144
+ expect(receivedProps[0].isLoading).toBe(false);
145
+ });
146
+
147
+ it("renders with empty toolRenderers map (all defaults)", () => {
148
+ const renderers: ToolRenderers = {};
149
+ const part = makePart("executeSQL", { sql: "SELECT 1" }, { success: true, columns: [], rows: [] });
150
+
151
+ const { container } = render(<ToolPart part={part} toolRenderers={renderers} />);
152
+
153
+ // Should render the default card, not crash
154
+ expect(container.innerHTML.length).toBeGreaterThan(0);
155
+ });
156
+
157
+ it("renders error fallback when custom renderer throws", () => {
158
+ function BrokenRenderer(): React.ReactNode {
159
+ throw new Error("Renderer exploded");
160
+ }
161
+
162
+ const renderers: ToolRenderers = { executeSQL: BrokenRenderer };
163
+ const part = makePart("executeSQL", { sql: "SELECT 1" }, { success: true });
164
+
165
+ // Error boundary should catch — no crash
166
+ const { container } = render(<ToolPart part={part} toolRenderers={renderers} />);
167
+
168
+ expect(container.textContent).toContain("failed");
169
+ expect(container.textContent).toContain("Renderer exploded");
170
+ });
171
+
172
+ it("renders fallback banner when getToolName fails", () => {
173
+ // Part without toolName triggers the mock's throw
174
+ const malformedPart = { input: {}, output: null, state: "call" };
175
+
176
+ const { container } = render(<ToolPart part={malformedPart} />);
177
+
178
+ expect(container.textContent).toContain("Tool result (unknown type)");
179
+ });
180
+
181
+ it("dispatches to correct renderer when multiple renderers are registered", () => {
182
+ function CustomSQL({ toolName }: ToolRendererProps) {
183
+ return <div data-testid="custom-sql">{toolName}</div>;
184
+ }
185
+ function CustomExplore({ toolName }: ToolRendererProps) {
186
+ return <div data-testid="custom-explore">{toolName}</div>;
187
+ }
188
+ function CustomPython({ toolName }: ToolRendererProps) {
189
+ return <div data-testid="custom-python">{toolName}</div>;
190
+ }
191
+
192
+ const renderers: ToolRenderers = {
193
+ executeSQL: CustomSQL,
194
+ explore: CustomExplore,
195
+ executePython: CustomPython,
196
+ };
197
+
198
+ const sqlPart = makePart("executeSQL", { sql: "SELECT 1" }, { success: true });
199
+ const { unmount: u1 } = render(<ToolPart part={sqlPart} toolRenderers={renderers} />);
200
+ expect(screen.getByTestId("custom-sql").textContent).toBe("executeSQL");
201
+ u1();
202
+
203
+ const explorePart = makePart("explore", { command: "ls" }, "files");
204
+ const { unmount: u2 } = render(<ToolPart part={explorePart} toolRenderers={renderers} />);
205
+ expect(screen.getByTestId("custom-explore").textContent).toBe("explore");
206
+ u2();
207
+
208
+ const pyPart = makePart("executePython", { code: "1+1" }, { success: true });
209
+ render(<ToolPart part={pyPart} toolRenderers={renderers} />);
210
+ expect(screen.getByTestId("custom-python").textContent).toBe("executePython");
211
+ });
212
+
213
+ it("passes error-shaped result (success: false) through to custom renderer", () => {
214
+ const receivedProps: ToolRendererProps[] = [];
215
+ function Spy(props: ToolRendererProps) {
216
+ receivedProps.push(props);
217
+ return <div data-testid="spy">error</div>;
218
+ }
219
+
220
+ const renderers: ToolRenderers = { executeSQL: Spy };
221
+ const part = makePart("executeSQL", { sql: "BAD SQL" }, { success: false, error: "syntax error" });
222
+
223
+ render(<ToolPart part={part} toolRenderers={renderers} />);
224
+
225
+ expect(receivedProps[0].result).toEqual({ success: false, error: "syntax error" });
226
+ expect(receivedProps[0].isLoading).toBe(false);
227
+ });
228
+
229
+ it("falls back to default when renderer value is undefined in the map", () => {
230
+ const renderers = { executeSQL: undefined } as unknown as ToolRenderers;
231
+ const part = makePart("executeSQL", { sql: "SELECT 1" }, { success: true, columns: [], rows: [] });
232
+
233
+ const { container } = render(<ToolPart part={part} toolRenderers={renderers} />);
234
+
235
+ // undefined entry → default SQLResultCard renders
236
+ expect(container.innerHTML.length).toBeGreaterThan(0);
237
+ expect(screen.queryByTestId("custom-sql")).toBeNull();
238
+ });
239
+ });
@@ -0,0 +1,296 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { getToolArgs, getToolResult, isToolComplete } from "../../lib/helpers";
5
+ import {
6
+ isActionToolResult,
7
+ RESOLVED_STATUSES,
8
+ type ActionStatus,
9
+ type ResolvedStatus,
10
+ type ActionApprovalResponse,
11
+ type ActionToolResultShape,
12
+ } from "../../lib/action-types";
13
+ import { useAtlasConfig } from "../../context";
14
+ import { useActionAuth } from "../../context";
15
+ import { LoadingCard } from "../chat/loading-card";
16
+ import { ActionStatusBadge } from "./action-status-badge";
17
+
18
+ /* ------------------------------------------------------------------ */
19
+ /* Safe JSON.stringify helper */
20
+ /* ------------------------------------------------------------------ */
21
+
22
+ function safeStringify(value: unknown): string {
23
+ try {
24
+ return JSON.stringify(value, null, 2);
25
+ } catch (err) {
26
+ console.warn("Could not serialize action details:", err);
27
+ return "[Unable to display]";
28
+ }
29
+ }
30
+
31
+ /* ------------------------------------------------------------------ */
32
+ /* Card state machine */
33
+ /* ------------------------------------------------------------------ */
34
+
35
+ type CardState =
36
+ | { phase: "idle" }
37
+ | { phase: "submitting"; action: "approve" | "deny" }
38
+ | { phase: "resolved"; status: ResolvedStatus; result?: unknown }
39
+ | { phase: "error"; message: string };
40
+
41
+ /* ------------------------------------------------------------------ */
42
+ /* Border color by status */
43
+ /* ------------------------------------------------------------------ */
44
+
45
+ function borderColor(status: ActionStatus): string {
46
+ switch (status) {
47
+ case "pending_approval":
48
+ return "border-yellow-300 dark:border-yellow-900/50";
49
+ case "approved":
50
+ case "executed":
51
+ case "auto_approved":
52
+ return "border-green-300 dark:border-green-900/50";
53
+ case "denied":
54
+ case "failed":
55
+ return "border-red-300 dark:border-red-900/50";
56
+ default:
57
+ return "border-zinc-200 dark:border-zinc-700";
58
+ }
59
+ }
60
+
61
+ /* ------------------------------------------------------------------ */
62
+ /* Component */
63
+ /* ------------------------------------------------------------------ */
64
+
65
+ export function ActionApprovalCard({ part }: { part: unknown }) {
66
+ const { apiUrl } = useAtlasConfig();
67
+ const actionAuth = useActionAuth();
68
+ const args = getToolArgs(part);
69
+ const rawResult = getToolResult(part);
70
+ const done = isToolComplete(part);
71
+
72
+ const [cardState, setCardState] = useState<CardState>({ phase: "idle" });
73
+ const [open, setOpen] = useState(false);
74
+ const [denyReason, setDenyReason] = useState("");
75
+ const [showDenyInput, setShowDenyInput] = useState(false);
76
+
77
+ if (!done) return <LoadingCard label="Requesting action approval..." />;
78
+
79
+ if (!isActionToolResult(rawResult)) {
80
+ return (
81
+ <div className="my-2 rounded-lg border border-yellow-300 bg-yellow-50 px-3 py-2 text-xs text-yellow-700 dark:border-yellow-900/50 dark:bg-yellow-950/20 dark:text-yellow-400">
82
+ Action result (unexpected format)
83
+ </div>
84
+ );
85
+ }
86
+
87
+ const toolResult: ActionToolResultShape = rawResult;
88
+
89
+ // Effective status: local optimistic update wins over server result
90
+ const effectiveStatus: ActionStatus =
91
+ cardState.phase === "resolved" ? cardState.status : toolResult.status;
92
+
93
+ const isPending = effectiveStatus === "pending_approval" && cardState.phase !== "submitting";
94
+ const isSubmitting = cardState.phase === "submitting";
95
+ const resolvedResult = cardState.phase === "resolved" ? cardState.result : toolResult.result;
96
+
97
+ /* ---------------------------------------------------------------- */
98
+ /* API helpers */
99
+ /* ---------------------------------------------------------------- */
100
+
101
+ async function callAction(endpoint: "approve" | "deny", body?: Record<string, unknown>) {
102
+ if (!actionAuth) {
103
+ console.warn("ActionApprovalCard: No ActionAuthProvider found. API calls will be sent without authentication.");
104
+ }
105
+
106
+ const headers: Record<string, string> = {
107
+ "Content-Type": "application/json",
108
+ ...(actionAuth?.getHeaders() ?? {}),
109
+ };
110
+ const credentials = actionAuth?.getCredentials() ?? "same-origin";
111
+
112
+ const res = await fetch(
113
+ `${apiUrl}/api/v1/actions/${toolResult.actionId}/${endpoint}`,
114
+ {
115
+ method: "POST",
116
+ headers,
117
+ credentials,
118
+ body: body ? JSON.stringify(body) : undefined,
119
+ },
120
+ );
121
+
122
+ if (res.status === 409) {
123
+ // Already resolved — read current status from response
124
+ let data: ActionApprovalResponse;
125
+ try {
126
+ data = (await res.json()) as ActionApprovalResponse;
127
+ } catch {
128
+ throw new Error("Action was already resolved, but the response could not be read. Refresh the page.");
129
+ }
130
+ const status = typeof data.status === "string" ? data.status as ResolvedStatus : "failed" as ResolvedStatus;
131
+ const label = status.replace(/_/g, " ");
132
+ throw new Error(`This action was already ${label} by another user or policy.`);
133
+ }
134
+
135
+ if (!res.ok) {
136
+ const text = await res.text().catch(() => "Unknown error");
137
+ throw new Error(`Server responded ${res.status}: ${text}`);
138
+ }
139
+
140
+ let data: ActionApprovalResponse;
141
+ try {
142
+ data = (await res.json()) as ActionApprovalResponse;
143
+ } catch {
144
+ throw new Error("Action succeeded, but the response could not be read. Refresh the page.");
145
+ }
146
+ if (typeof data.status !== "string") {
147
+ throw new Error("Action succeeded, but the server returned an invalid status. Refresh the page.");
148
+ }
149
+ setCardState({ phase: "resolved", status: data.status as ResolvedStatus, result: data.result });
150
+ }
151
+
152
+ async function handleApprove() {
153
+ setCardState({ phase: "submitting", action: "approve" });
154
+ try {
155
+ await callAction("approve");
156
+ } catch (err) {
157
+ console.error("Action approval failed:", err);
158
+ const message =
159
+ err instanceof TypeError
160
+ ? "Network error — could not reach the server."
161
+ : err instanceof Error
162
+ ? err.message
163
+ : String(err);
164
+ setCardState({ phase: "error", message });
165
+ }
166
+ }
167
+
168
+ async function handleDeny() {
169
+ setCardState({ phase: "submitting", action: "deny" });
170
+ try {
171
+ await callAction("deny", denyReason.trim() ? { reason: denyReason.trim() } : undefined);
172
+ } catch (err) {
173
+ console.error("Action approval failed:", err);
174
+ const message =
175
+ err instanceof TypeError
176
+ ? "Network error — could not reach the server."
177
+ : err instanceof Error
178
+ ? err.message
179
+ : String(err);
180
+ setCardState({ phase: "error", message });
181
+ }
182
+ }
183
+
184
+ /* ---------------------------------------------------------------- */
185
+ /* Render */
186
+ /* ---------------------------------------------------------------- */
187
+
188
+ return (
189
+ <div className={`my-2 overflow-hidden rounded-lg border ${borderColor(effectiveStatus)} bg-zinc-50 dark:bg-zinc-900`}>
190
+ {/* Header */}
191
+ <button
192
+ onClick={() => setOpen(!open)}
193
+ className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:bg-zinc-100/60 dark:hover:bg-zinc-800/60"
194
+ >
195
+ <ActionStatusBadge status={effectiveStatus} />
196
+ <span className="flex-1 truncate text-zinc-500 dark:text-zinc-400">
197
+ {toolResult.summary ?? String(args.description ?? "Action")}
198
+ </span>
199
+ <span className="text-zinc-400 dark:text-zinc-600">{open ? "\u25BE" : "\u25B8"}</span>
200
+ </button>
201
+
202
+ {/* Expanded details */}
203
+ {open && (
204
+ <div className="border-t border-zinc-100 px-3 py-2 dark:border-zinc-800">
205
+ {toolResult.details && (
206
+ <pre className="mb-2 max-h-48 overflow-auto rounded bg-zinc-100 p-2 text-xs text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400">
207
+ {safeStringify(toolResult.details)}
208
+ </pre>
209
+ )}
210
+ {resolvedResult != null && (
211
+ <div className="mb-2 rounded bg-green-50 p-2 text-xs text-green-700 dark:bg-green-900/20 dark:text-green-400">
212
+ <span className="font-medium">Result: </span>
213
+ {typeof resolvedResult === "string"
214
+ ? resolvedResult
215
+ : safeStringify(resolvedResult)}
216
+ </div>
217
+ )}
218
+ {toolResult.error && (
219
+ <div className="mb-2 rounded bg-red-50 p-2 text-xs text-red-700 dark:bg-red-900/20 dark:text-red-400">
220
+ <span className="font-medium">Error: </span>
221
+ {toolResult.error}
222
+ </div>
223
+ )}
224
+ {toolResult.reason && RESOLVED_STATUSES.has(effectiveStatus) && (
225
+ <div className="mb-2 text-xs text-zinc-500 dark:text-zinc-400">
226
+ <span className="font-medium">Reason: </span>
227
+ {toolResult.reason}
228
+ </div>
229
+ )}
230
+ </div>
231
+ )}
232
+
233
+ {/* Approval buttons — only when pending */}
234
+ {(isPending || isSubmitting || cardState.phase === "error") && (
235
+ <div className="border-t border-zinc-100 px-3 py-2 dark:border-zinc-800">
236
+ {cardState.phase === "error" && (
237
+ <p className="mb-2 text-xs text-red-600 dark:text-red-400">{cardState.message}</p>
238
+ )}
239
+
240
+ <div className="flex items-center gap-2">
241
+ <button
242
+ onClick={handleApprove}
243
+ disabled={isSubmitting}
244
+ className="inline-flex items-center gap-1.5 rounded bg-blue-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-blue-500 disabled:opacity-40"
245
+ >
246
+ {isSubmitting && cardState.action === "approve" && (
247
+ <span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-white/30 border-t-white" />
248
+ )}
249
+ Approve
250
+ </button>
251
+
252
+ {!showDenyInput ? (
253
+ <button
254
+ onClick={() => setShowDenyInput(true)}
255
+ disabled={isSubmitting}
256
+ className="rounded border border-zinc-300 px-3 py-1.5 text-xs font-medium text-zinc-600 transition-colors hover:border-zinc-400 hover:text-zinc-800 disabled:opacity-40 dark:border-zinc-600 dark:text-zinc-400 dark:hover:border-zinc-500 dark:hover:text-zinc-200"
257
+ >
258
+ Deny
259
+ </button>
260
+ ) : (
261
+ <div className="flex flex-1 items-center gap-2">
262
+ <input
263
+ value={denyReason}
264
+ onChange={(e) => setDenyReason(e.target.value)}
265
+ placeholder="Reason (optional)"
266
+ className="flex-1 rounded border border-zinc-200 bg-white px-2 py-1 text-xs text-zinc-900 placeholder-zinc-400 outline-none focus:border-red-400 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:placeholder-zinc-600"
267
+ disabled={isSubmitting}
268
+ />
269
+ <button
270
+ onClick={handleDeny}
271
+ disabled={isSubmitting}
272
+ className="inline-flex items-center gap-1.5 rounded bg-red-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-red-500 disabled:opacity-40"
273
+ >
274
+ {isSubmitting && cardState.action === "deny" && (
275
+ <span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-white/30 border-t-white" />
276
+ )}
277
+ Confirm Deny
278
+ </button>
279
+ <button
280
+ onClick={() => {
281
+ setShowDenyInput(false);
282
+ setDenyReason("");
283
+ }}
284
+ disabled={isSubmitting}
285
+ className="text-xs text-zinc-400 hover:text-zinc-600 disabled:opacity-40 dark:hover:text-zinc-300"
286
+ >
287
+ Cancel
288
+ </button>
289
+ </div>
290
+ )}
291
+ </div>
292
+ </div>
293
+ )}
294
+ </div>
295
+ );
296
+ }