@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.
- package/README.md +95 -0
- package/dist/chunk-2WFDP7G5.js +231 -0
- package/dist/chunk-2WFDP7G5.js.map +1 -0
- package/dist/chunk-44HBZYKP.js +224 -0
- package/dist/chunk-44HBZYKP.js.map +1 -0
- package/dist/chunk-5SEVKHS5.cjs +229 -0
- package/dist/chunk-5SEVKHS5.cjs.map +1 -0
- package/dist/chunk-UIRB6L36.cjs +249 -0
- package/dist/chunk-UIRB6L36.cjs.map +1 -0
- package/dist/hooks.cjs +251 -0
- package/dist/hooks.cjs.map +1 -0
- package/dist/hooks.d.cts +132 -0
- package/dist/hooks.d.ts +132 -0
- package/dist/hooks.js +237 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.cjs +2976 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +69 -0
- package/dist/index.d.ts +69 -0
- package/dist/index.js +2926 -0
- package/dist/index.js.map +1 -0
- package/dist/result-chart-NFAJ4IQ5.js +398 -0
- package/dist/result-chart-NFAJ4IQ5.js.map +1 -0
- package/dist/result-chart-YLCKBNV4.cjs +400 -0
- package/dist/result-chart-YLCKBNV4.cjs.map +1 -0
- package/dist/styles.css +59 -0
- package/dist/use-dark-mode-rFxawUv1.d.cts +123 -0
- package/dist/use-dark-mode-rFxawUv1.d.ts +123 -0
- package/dist/widget.css +2 -0
- package/dist/widget.js +445 -0
- package/package.json +113 -0
- package/src/components/__tests__/tool-renderers.test.tsx +239 -0
- package/src/components/actions/action-approval-card.tsx +296 -0
- package/src/components/actions/action-status-badge.tsx +50 -0
- package/src/components/admin/change-password-dialog.tsx +128 -0
- package/src/components/atlas-chat.tsx +656 -0
- package/src/components/chart/chart-detection.ts +318 -0
- package/src/components/chart/result-chart.tsx +590 -0
- package/src/components/chat/api-key-bar.tsx +66 -0
- package/src/components/chat/copy-button.tsx +25 -0
- package/src/components/chat/data-table.tsx +104 -0
- package/src/components/chat/error-banner.tsx +32 -0
- package/src/components/chat/explore-card.tsx +41 -0
- package/src/components/chat/follow-up-chips.tsx +29 -0
- package/src/components/chat/loading-card.tsx +10 -0
- package/src/components/chat/managed-auth-card.tsx +116 -0
- package/src/components/chat/markdown.tsx +146 -0
- package/src/components/chat/python-result-card.tsx +245 -0
- package/src/components/chat/sql-block.tsx +54 -0
- package/src/components/chat/sql-result-card.tsx +163 -0
- package/src/components/chat/starter-prompts.ts +6 -0
- package/src/components/chat/tool-part.tsx +106 -0
- package/src/components/chat/typing-indicator.tsx +22 -0
- package/src/components/conversations/conversation-item.tsx +135 -0
- package/src/components/conversations/conversation-list.tsx +69 -0
- package/src/components/conversations/conversation-sidebar.tsx +113 -0
- package/src/components/conversations/delete-confirmation.tsx +27 -0
- package/src/components/schema-explorer/schema-explorer.tsx +517 -0
- package/src/components/ui/alert-dialog.tsx +196 -0
- package/src/components/ui/badge.tsx +48 -0
- package/src/components/ui/button.tsx +64 -0
- package/src/components/ui/card.tsx +92 -0
- package/src/components/ui/dialog.tsx +158 -0
- package/src/components/ui/dropdown-menu.tsx +257 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/label.tsx +24 -0
- package/src/components/ui/scroll-area.tsx +62 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/sheet.tsx +143 -0
- package/src/components/ui/table.tsx +116 -0
- package/src/components/ui/toggle-group.tsx +83 -0
- package/src/components/ui/toggle.tsx +47 -0
- package/src/context.tsx +85 -0
- package/src/env.d.ts +9 -0
- package/src/hooks/__tests__/provider.test.tsx +83 -0
- package/src/hooks/__tests__/use-atlas-auth.test.tsx +283 -0
- package/src/hooks/__tests__/use-atlas-chat.test.tsx +157 -0
- package/src/hooks/__tests__/use-atlas-conversations.test.tsx +159 -0
- package/src/hooks/__tests__/use-atlas-theme.test.tsx +56 -0
- package/src/hooks/index.ts +47 -0
- package/src/hooks/provider.tsx +77 -0
- package/src/hooks/theme-init-script.ts +17 -0
- package/src/hooks/use-atlas-auth.ts +131 -0
- package/src/hooks/use-atlas-chat.ts +102 -0
- package/src/hooks/use-atlas-conversations.ts +61 -0
- package/src/hooks/use-atlas-theme.ts +34 -0
- package/src/hooks/use-conversations.ts +189 -0
- package/src/hooks/use-dark-mode.ts +150 -0
- package/src/index.ts +36 -0
- package/src/lib/action-types.ts +11 -0
- package/src/lib/helpers.ts +198 -0
- package/src/lib/tool-renderer-types.ts +76 -0
- package/src/lib/types.ts +29 -0
- package/src/lib/utils.ts +6 -0
- package/src/styles.css +59 -0
- package/src/test-setup.ts +55 -0
- package/src/widget-entry.ts +20 -0
- 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
|
+
}
|