@hienlh/ppm 0.1.0
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/.claude/agent-memory/tester/MEMORY.md +3 -0
- package/.claude/agent-memory/tester/project-ppm-test-conventions.md +32 -0
- package/.env.example +1 -0
- package/.github/workflows/release.yml +46 -0
- package/README.md +349 -0
- package/bun.lock +1217 -0
- package/components.json +21 -0
- package/docs/code-standards.md +574 -0
- package/docs/codebase-summary.md +294 -0
- package/docs/deployment-guide.md +631 -0
- package/docs/design-guidelines.md +661 -0
- package/docs/project-overview-pdr.md +142 -0
- package/docs/project-roadmap.md +400 -0
- package/docs/system-architecture.md +459 -0
- package/package.json +68 -0
- package/plans/260314-2009-ppm-implementation/phase-01-project-skeleton.md +81 -0
- package/plans/260314-2009-ppm-implementation/phase-02-backend-core.md +148 -0
- package/plans/260314-2009-ppm-implementation/phase-03-frontend-shell.md +256 -0
- package/plans/260314-2009-ppm-implementation/phase-04-file-explorer-editor.md +120 -0
- package/plans/260314-2009-ppm-implementation/phase-05-web-terminal.md +174 -0
- package/plans/260314-2009-ppm-implementation/phase-06-git-integration.md +244 -0
- package/plans/260314-2009-ppm-implementation/phase-07-ai-chat.md +242 -0
- package/plans/260314-2009-ppm-implementation/phase-08-cli-commands.md +143 -0
- package/plans/260314-2009-ppm-implementation/phase-09-pwa-build-deploy.md +209 -0
- package/plans/260314-2009-ppm-implementation/phase-10-testing.md +311 -0
- package/plans/260314-2009-ppm-implementation/plan.md +202 -0
- package/plans/260315-0356-project-scoped-api-refactor/phase-01-backend-project-router.md +145 -0
- package/plans/260315-0356-project-scoped-api-refactor/phase-02-frontend-api-migration.md +107 -0
- package/plans/260315-0356-project-scoped-api-refactor/phase-03-per-project-tabs.md +100 -0
- package/plans/260315-0356-project-scoped-api-refactor/phase-04-websocket-migration.md +66 -0
- package/plans/260315-0356-project-scoped-api-refactor/plan.md +87 -0
- package/plans/reports/brainstorm-260314-1938-final-techstack.md +342 -0
- package/plans/reports/docs-manager-260315-1314-documentation-creation.md +386 -0
- package/plans/reports/fullstack-developer-260314-2252-phase-02-backend-core.md +57 -0
- package/plans/reports/fullstack-developer-260314-2253-phase-03-frontend-shell.md +70 -0
- package/plans/reports/fullstack-developer-260314-2300-phase-04-05-file-api-terminal-ws.md +49 -0
- package/plans/reports/fullstack-developer-260314-2300-phase-04-05-file-explorer-editor-terminal.md +52 -0
- package/plans/reports/fullstack-developer-260314-2307-ai-chat-phase7.md +58 -0
- package/plans/reports/fullstack-developer-260314-2307-phase-06-git-integration.md +33 -0
- package/plans/reports/research-260314-1911-ppm-tech-stack.md +318 -0
- package/plans/reports/research-260314-1930-claude-code-integration.md +293 -0
- package/plans/reports/researcher-260314-2232-node-pty-bun-crash-analysis.md +305 -0
- package/plans/reports/researcher-260314-2232-ui-style.md +942 -0
- package/plans/reports/researcher-260315-0300-opcode-claude-interaction.md +745 -0
- package/plans/reports/researcher-260315-0303-opcode-deep-analysis.md +742 -0
- package/plans/reports/researcher-260315-0305-claude-agent-sdk-github-research.md +423 -0
- package/plans/reports/tester-260314-2053-initial-test-suite.md +81 -0
- package/ppm.example.yaml +14 -0
- package/repomix-output.xml +23745 -0
- package/scripts/build.ts +13 -0
- package/src/cli/commands/chat-cmd.ts +259 -0
- package/src/cli/commands/config-cmd.ts +121 -0
- package/src/cli/commands/git-cmd.ts +315 -0
- package/src/cli/commands/init.ts +57 -0
- package/src/cli/commands/open.ts +19 -0
- package/src/cli/commands/projects.ts +100 -0
- package/src/cli/commands/start.ts +3 -0
- package/src/cli/commands/stop.ts +33 -0
- package/src/cli/utils/project-resolver.ts +27 -0
- package/src/index.ts +59 -0
- package/src/providers/claude-agent-sdk.ts +499 -0
- package/src/providers/claude-binary-finder.ts +256 -0
- package/src/providers/claude-code-cli.ts +413 -0
- package/src/providers/claude-process-registry.ts +106 -0
- package/src/providers/mock-provider.ts +171 -0
- package/src/providers/provider.interface.ts +10 -0
- package/src/providers/registry.ts +45 -0
- package/src/server/helpers/resolve-project.ts +22 -0
- package/src/server/index.ts +181 -0
- package/src/server/middleware/auth.ts +30 -0
- package/src/server/routes/chat.ts +153 -0
- package/src/server/routes/files.ts +168 -0
- package/src/server/routes/git.ts +261 -0
- package/src/server/routes/project-scoped.ts +27 -0
- package/src/server/routes/projects.ts +57 -0
- package/src/server/routes/static.ts +26 -0
- package/src/server/ws/chat.ts +130 -0
- package/src/server/ws/terminal.ts +89 -0
- package/src/services/chat.service.ts +110 -0
- package/src/services/claude-usage.service.ts +113 -0
- package/src/services/config.service.ts +90 -0
- package/src/services/file.service.ts +261 -0
- package/src/services/git-dirs.service.ts +112 -0
- package/src/services/git.service.ts +372 -0
- package/src/services/project.service.ts +107 -0
- package/src/services/slash-items.service.ts +184 -0
- package/src/services/terminal.service.ts +212 -0
- package/src/types/api.ts +37 -0
- package/src/types/chat.ts +92 -0
- package/src/types/config.ts +41 -0
- package/src/types/git.ts +50 -0
- package/src/types/project.ts +18 -0
- package/src/types/terminal.ts +20 -0
- package/src/web/app.tsx +168 -0
- package/src/web/components/auth/login-screen.tsx +88 -0
- package/src/web/components/chat/attachment-chips.tsx +55 -0
- package/src/web/components/chat/chat-placeholder.tsx +10 -0
- package/src/web/components/chat/chat-tab.tsx +301 -0
- package/src/web/components/chat/file-picker.tsx +126 -0
- package/src/web/components/chat/message-input.tsx +420 -0
- package/src/web/components/chat/message-list.tsx +838 -0
- package/src/web/components/chat/session-picker.tsx +139 -0
- package/src/web/components/chat/slash-command-picker.tsx +135 -0
- package/src/web/components/chat/usage-badge.tsx +186 -0
- package/src/web/components/editor/code-editor.tsx +329 -0
- package/src/web/components/editor/diff-viewer.tsx +276 -0
- package/src/web/components/editor/editor-placeholder.tsx +10 -0
- package/src/web/components/explorer/file-actions.tsx +191 -0
- package/src/web/components/explorer/file-tree.tsx +298 -0
- package/src/web/components/git/git-graph.tsx +727 -0
- package/src/web/components/git/git-placeholder.tsx +55 -0
- package/src/web/components/git/git-status-panel.tsx +850 -0
- package/src/web/components/layout/mobile-drawer.tsx +137 -0
- package/src/web/components/layout/mobile-nav.tsx +103 -0
- package/src/web/components/layout/sidebar.tsx +90 -0
- package/src/web/components/layout/tab-bar.tsx +152 -0
- package/src/web/components/layout/tab-content.tsx +85 -0
- package/src/web/components/projects/dir-suggest.tsx +152 -0
- package/src/web/components/projects/project-list.tsx +187 -0
- package/src/web/components/settings/settings-tab.tsx +57 -0
- package/src/web/components/terminal/terminal-placeholder.tsx +10 -0
- package/src/web/components/terminal/terminal-tab.tsx +133 -0
- package/src/web/components/ui/button.tsx +64 -0
- package/src/web/components/ui/context-menu.tsx +250 -0
- package/src/web/components/ui/dialog.tsx +156 -0
- package/src/web/components/ui/dropdown-menu.tsx +257 -0
- package/src/web/components/ui/input.tsx +21 -0
- package/src/web/components/ui/scroll-area.tsx +56 -0
- package/src/web/components/ui/separator.tsx +26 -0
- package/src/web/components/ui/sonner.tsx +40 -0
- package/src/web/components/ui/tabs.tsx +91 -0
- package/src/web/components/ui/tooltip.tsx +57 -0
- package/src/web/hooks/use-chat.ts +420 -0
- package/src/web/hooks/use-terminal.ts +182 -0
- package/src/web/hooks/use-url-sync.ts +66 -0
- package/src/web/hooks/use-websocket.ts +48 -0
- package/src/web/index.html +16 -0
- package/src/web/lib/api-client.ts +90 -0
- package/src/web/lib/file-support.ts +68 -0
- package/src/web/lib/utils.ts +6 -0
- package/src/web/lib/ws-client.ts +100 -0
- package/src/web/main.tsx +10 -0
- package/src/web/public/icon-192.svg +5 -0
- package/src/web/public/icon-512.svg +5 -0
- package/src/web/stores/file-store.ts +81 -0
- package/src/web/stores/project-store.ts +50 -0
- package/src/web/stores/settings-store.ts +65 -0
- package/src/web/stores/tab-store.ts +187 -0
- package/src/web/styles/globals.css +227 -0
- package/src/web/vite-env.d.ts +1 -0
- package/tests/integration/api/chat-routes.test.ts +95 -0
- package/tests/integration/claude-agent-sdk-integration.test.ts +228 -0
- package/tests/integration/ws/chat-websocket.test.ts +312 -0
- package/tests/test-setup.ts +5 -0
- package/tests/unit/providers/claude-agent-sdk.test.ts +339 -0
- package/tests/unit/providers/mock-provider.test.ts +143 -0
- package/tests/unit/services/chat-service.test.ts +100 -0
- package/tsconfig.json +32 -0
- package/vite.config.ts +62 -0
|
@@ -0,0 +1,838 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, useMemo, useCallback } from "react";
|
|
2
|
+
import { marked } from "marked";
|
|
3
|
+
import { getAuthToken } from "@/lib/api-client";
|
|
4
|
+
import type { ChatMessage, ChatEvent } from "../../../types/chat";
|
|
5
|
+
import {
|
|
6
|
+
ChevronDown,
|
|
7
|
+
ChevronRight,
|
|
8
|
+
AlertCircle,
|
|
9
|
+
Wrench,
|
|
10
|
+
CheckCircle2,
|
|
11
|
+
XCircle,
|
|
12
|
+
ShieldAlert,
|
|
13
|
+
Bot,
|
|
14
|
+
FileText,
|
|
15
|
+
Image as ImageIcon,
|
|
16
|
+
} from "lucide-react";
|
|
17
|
+
|
|
18
|
+
interface MessageListProps {
|
|
19
|
+
messages: ChatMessage[];
|
|
20
|
+
messagesLoading?: boolean;
|
|
21
|
+
pendingApproval: { requestId: string; tool: string; input: unknown } | null;
|
|
22
|
+
onApprovalResponse: (requestId: string, approved: boolean, data?: unknown) => void;
|
|
23
|
+
isStreaming: boolean;
|
|
24
|
+
projectName?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function MessageList({
|
|
28
|
+
messages,
|
|
29
|
+
messagesLoading,
|
|
30
|
+
pendingApproval,
|
|
31
|
+
onApprovalResponse,
|
|
32
|
+
isStreaming,
|
|
33
|
+
projectName,
|
|
34
|
+
}: MessageListProps) {
|
|
35
|
+
const bottomRef = useRef<HTMLDivElement>(null);
|
|
36
|
+
|
|
37
|
+
const initialLoadRef = useRef(true);
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
// First load: jump instantly. Subsequent updates: smooth scroll.
|
|
41
|
+
const behavior = initialLoadRef.current ? "instant" : "smooth";
|
|
42
|
+
bottomRef.current?.scrollIntoView({ behavior: behavior as ScrollBehavior });
|
|
43
|
+
if (initialLoadRef.current && messages.length > 0) {
|
|
44
|
+
initialLoadRef.current = false;
|
|
45
|
+
}
|
|
46
|
+
}, [messages, pendingApproval]);
|
|
47
|
+
|
|
48
|
+
if (messagesLoading) {
|
|
49
|
+
return (
|
|
50
|
+
<div className="flex flex-col items-center justify-center h-full gap-3 text-text-secondary">
|
|
51
|
+
<Bot className="size-10 text-text-subtle animate-pulse" />
|
|
52
|
+
<p className="text-sm">Loading messages...</p>
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (messages.length === 0 && !isStreaming) {
|
|
58
|
+
return (
|
|
59
|
+
<div className="flex flex-col items-center justify-center h-full gap-3 text-text-secondary">
|
|
60
|
+
<Bot className="size-10 text-text-subtle" />
|
|
61
|
+
<p className="text-sm">Send a message to start the conversation</p>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
|
68
|
+
{messages
|
|
69
|
+
.filter((msg) => {
|
|
70
|
+
// Skip empty messages: no text content AND no events
|
|
71
|
+
const hasContent = msg.content && msg.content.trim().length > 0;
|
|
72
|
+
const hasEvents = msg.events && msg.events.length > 0;
|
|
73
|
+
return hasContent || hasEvents;
|
|
74
|
+
})
|
|
75
|
+
.map((msg) => (
|
|
76
|
+
<MessageBubble
|
|
77
|
+
key={msg.id}
|
|
78
|
+
message={msg}
|
|
79
|
+
isStreaming={isStreaming && msg.id.startsWith("streaming-")}
|
|
80
|
+
projectName={projectName}
|
|
81
|
+
/>
|
|
82
|
+
))}
|
|
83
|
+
|
|
84
|
+
{pendingApproval && (
|
|
85
|
+
pendingApproval.tool === "AskUserQuestion"
|
|
86
|
+
? <AskUserQuestionCard approval={pendingApproval} onRespond={onApprovalResponse} />
|
|
87
|
+
: <ApprovalCard approval={pendingApproval} onRespond={onApprovalResponse} />
|
|
88
|
+
)}
|
|
89
|
+
|
|
90
|
+
{isStreaming && <ThinkingIndicator lastMessage={messages[messages.length - 1]} />}
|
|
91
|
+
|
|
92
|
+
<div ref={bottomRef} />
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function MessageBubble({ message, isStreaming, projectName }: { message: ChatMessage; isStreaming: boolean; projectName?: string }) {
|
|
98
|
+
if (message.role === "user") {
|
|
99
|
+
return <UserBubble content={message.content} projectName={projectName} />;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (message.role === "system") {
|
|
103
|
+
return (
|
|
104
|
+
<div className="flex items-center gap-2 rounded-lg bg-red-500/10 border border-red-500/20 px-3 py-2 text-sm text-red-400">
|
|
105
|
+
<AlertCircle className="size-4 shrink-0" />
|
|
106
|
+
<p>{message.content}</p>
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Assistant message — render events in order (text interleaved with tool calls)
|
|
112
|
+
return (
|
|
113
|
+
<div className="flex flex-col gap-2">
|
|
114
|
+
{message.events && message.events.length > 0
|
|
115
|
+
? <InterleavedEvents events={message.events} isStreaming={isStreaming} />
|
|
116
|
+
: message.content && (
|
|
117
|
+
<div className="text-sm text-text-primary">
|
|
118
|
+
<MarkdownContent content={message.content} />
|
|
119
|
+
</div>
|
|
120
|
+
)}
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Image extensions that can be previewed inline */
|
|
126
|
+
const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
|
|
127
|
+
|
|
128
|
+
/** Parse user message content, extracting attached file paths and the actual text */
|
|
129
|
+
function parseUserAttachments(content: string): { files: string[]; text: string } {
|
|
130
|
+
// Match: [Attached file: /path] or [Attached files:\n/path1\n/path2\n]
|
|
131
|
+
const singleMatch = content.match(/^\[Attached file: (.+?)\]\n\n?/);
|
|
132
|
+
if (singleMatch) {
|
|
133
|
+
return { files: [singleMatch[1]!], text: content.slice(singleMatch[0].length) };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const multiMatch = content.match(/^\[Attached files:\n([\s\S]+?)\]\n\n?/);
|
|
137
|
+
if (multiMatch) {
|
|
138
|
+
const files = multiMatch[1]!.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
139
|
+
return { files, text: content.slice(multiMatch[0].length) };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return { files: [], text: content };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Build a preview URL for an uploaded file (served from /chat/uploads/:filename) */
|
|
146
|
+
function uploadPreviewUrl(filePath: string, projectName?: string): string {
|
|
147
|
+
const filename = filePath.split("/").pop() ?? "";
|
|
148
|
+
// Use a generic project name — the upload route is project-scoped but files are global
|
|
149
|
+
return `/api/project/${encodeURIComponent(projectName ?? "_")}/chat/uploads/${encodeURIComponent(filename)}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Check if a file path is an image based on extension */
|
|
153
|
+
function isImagePath(path: string): boolean {
|
|
154
|
+
const dot = path.lastIndexOf(".");
|
|
155
|
+
if (dot === -1) return false;
|
|
156
|
+
return IMAGE_EXTS.has(path.slice(dot).toLowerCase());
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isPdfPath(path: string): boolean {
|
|
160
|
+
return path.toLowerCase().endsWith(".pdf");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** User message bubble with attachment rendering */
|
|
164
|
+
function UserBubble({ content, projectName }: { content: string; projectName?: string }) {
|
|
165
|
+
const { files, text } = useMemo(() => parseUserAttachments(content), [content]);
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<div className="flex justify-end">
|
|
169
|
+
<div className="rounded-lg bg-primary/10 px-3 py-2 text-sm text-text-primary max-w-[85%] space-y-2">
|
|
170
|
+
{/* Attached files */}
|
|
171
|
+
{files.length > 0 && (
|
|
172
|
+
<div className="flex flex-wrap gap-2">
|
|
173
|
+
{files.map((filePath, i) =>
|
|
174
|
+
isImagePath(filePath) ? (
|
|
175
|
+
<AuthImage
|
|
176
|
+
key={i}
|
|
177
|
+
src={uploadPreviewUrl(filePath, projectName)}
|
|
178
|
+
alt={filePath.split("/").pop() ?? "image"}
|
|
179
|
+
/>
|
|
180
|
+
) : isPdfPath(filePath) ? (
|
|
181
|
+
<AuthFileLink
|
|
182
|
+
key={i}
|
|
183
|
+
src={uploadPreviewUrl(filePath, projectName)}
|
|
184
|
+
filename={filePath.split("/").pop() ?? "document.pdf"}
|
|
185
|
+
mimeType="application/pdf"
|
|
186
|
+
/>
|
|
187
|
+
) : (
|
|
188
|
+
<div
|
|
189
|
+
key={i}
|
|
190
|
+
className="flex items-center gap-1.5 rounded-md border border-border bg-background/50 px-2 py-1 text-xs text-text-secondary"
|
|
191
|
+
>
|
|
192
|
+
<FileText className="size-3.5 shrink-0" />
|
|
193
|
+
<span className="truncate max-w-40">{filePath.split("/").pop()}</span>
|
|
194
|
+
</div>
|
|
195
|
+
),
|
|
196
|
+
)}
|
|
197
|
+
</div>
|
|
198
|
+
)}
|
|
199
|
+
|
|
200
|
+
{/* Text content */}
|
|
201
|
+
{text && <p className="whitespace-pre-wrap break-words">{text}</p>}
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Fetches image with auth header, renders as blob URL */
|
|
208
|
+
function AuthImage({ src, alt }: { src: string; alt: string }) {
|
|
209
|
+
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
|
210
|
+
const [error, setError] = useState(false);
|
|
211
|
+
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
let revoke: string | undefined;
|
|
214
|
+
const token = getAuthToken();
|
|
215
|
+
fetch(src, { headers: token ? { Authorization: `Bearer ${token}` } : {} })
|
|
216
|
+
.then((r) => {
|
|
217
|
+
if (!r.ok) throw new Error("Failed to load");
|
|
218
|
+
return r.blob();
|
|
219
|
+
})
|
|
220
|
+
.then((blob) => {
|
|
221
|
+
const url = URL.createObjectURL(blob);
|
|
222
|
+
revoke = url;
|
|
223
|
+
setBlobUrl(url);
|
|
224
|
+
})
|
|
225
|
+
.catch(() => setError(true));
|
|
226
|
+
|
|
227
|
+
return () => { if (revoke) URL.revokeObjectURL(revoke); };
|
|
228
|
+
}, [src]);
|
|
229
|
+
|
|
230
|
+
if (error) {
|
|
231
|
+
return (
|
|
232
|
+
<div className="flex items-center gap-1.5 rounded-md border border-border bg-background/50 px-2 py-1 text-xs text-text-secondary">
|
|
233
|
+
<ImageIcon className="size-3.5 shrink-0" />
|
|
234
|
+
<span className="truncate max-w-40">{alt}</span>
|
|
235
|
+
</div>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (!blobUrl) {
|
|
240
|
+
return <div className="rounded-md bg-surface border border-border h-24 w-32 animate-pulse" />;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return (
|
|
244
|
+
<a href={blobUrl} target="_blank" rel="noopener noreferrer" className="block">
|
|
245
|
+
<img
|
|
246
|
+
src={blobUrl}
|
|
247
|
+
alt={alt}
|
|
248
|
+
className="rounded-md max-h-48 max-w-full object-contain border border-border"
|
|
249
|
+
/>
|
|
250
|
+
</a>
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Fetches file with auth, opens in new browser tab (for PDFs, etc.) */
|
|
255
|
+
function AuthFileLink({ src, filename, mimeType }: { src: string; filename: string; mimeType: string }) {
|
|
256
|
+
const [loading, setLoading] = useState(false);
|
|
257
|
+
|
|
258
|
+
const handleClick = useCallback(async () => {
|
|
259
|
+
setLoading(true);
|
|
260
|
+
try {
|
|
261
|
+
const token = getAuthToken();
|
|
262
|
+
const res = await fetch(src, { headers: token ? { Authorization: `Bearer ${token}` } : {} });
|
|
263
|
+
if (!res.ok) throw new Error("Failed to load");
|
|
264
|
+
const blob = await res.blob();
|
|
265
|
+
const url = URL.createObjectURL(new Blob([blob], { type: mimeType }));
|
|
266
|
+
window.open(url, "_blank");
|
|
267
|
+
// Revoke after a delay to let the new tab load
|
|
268
|
+
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
|
269
|
+
} catch {
|
|
270
|
+
// Fallback: try direct link
|
|
271
|
+
window.open(src, "_blank");
|
|
272
|
+
} finally {
|
|
273
|
+
setLoading(false);
|
|
274
|
+
}
|
|
275
|
+
}, [src, mimeType]);
|
|
276
|
+
|
|
277
|
+
return (
|
|
278
|
+
<button
|
|
279
|
+
type="button"
|
|
280
|
+
onClick={handleClick}
|
|
281
|
+
disabled={loading}
|
|
282
|
+
className="flex items-center gap-1.5 rounded-md border border-border bg-background/50 px-2 py-1 text-xs text-text-secondary hover:bg-surface hover:text-text-primary transition-colors cursor-pointer disabled:opacity-50"
|
|
283
|
+
>
|
|
284
|
+
<FileText className="size-3.5 shrink-0 text-red-400" />
|
|
285
|
+
<span className="truncate max-w-40">{filename}</span>
|
|
286
|
+
{loading && <span className="animate-spin text-[10px]">...</span>}
|
|
287
|
+
</button>
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Renders events in order — consecutive text events merged into one bubble,
|
|
293
|
+
* tool_use/tool_result render as cards between text sections.
|
|
294
|
+
* Last text group shows streaming cursor when actively streaming.
|
|
295
|
+
*/
|
|
296
|
+
type EventGroup =
|
|
297
|
+
| { kind: "text"; content: string }
|
|
298
|
+
| { kind: "tool"; tool: ChatEvent; result?: ChatEvent; completed?: boolean };
|
|
299
|
+
|
|
300
|
+
function InterleavedEvents({ events, isStreaming }: { events: ChatEvent[]; isStreaming: boolean }) {
|
|
301
|
+
// Group: consecutive text → merged text block; tool_use + tool_result paired by toolUseId
|
|
302
|
+
const groups: EventGroup[] = [];
|
|
303
|
+
let textBuffer = "";
|
|
304
|
+
|
|
305
|
+
// First pass: create groups for text and tool_use events
|
|
306
|
+
for (let i = 0; i < events.length; i++) {
|
|
307
|
+
const event = events[i]!;
|
|
308
|
+
if (event.type === "text") {
|
|
309
|
+
textBuffer += event.content;
|
|
310
|
+
} else if (event.type === "tool_use") {
|
|
311
|
+
if (textBuffer) {
|
|
312
|
+
groups.push({ kind: "text", content: textBuffer });
|
|
313
|
+
textBuffer = "";
|
|
314
|
+
}
|
|
315
|
+
groups.push({ kind: "tool", tool: event });
|
|
316
|
+
} else if (event.type === "tool_result") {
|
|
317
|
+
// Skip tool_results in first pass — matched below
|
|
318
|
+
} else {
|
|
319
|
+
if (textBuffer) {
|
|
320
|
+
groups.push({ kind: "text", content: textBuffer });
|
|
321
|
+
textBuffer = "";
|
|
322
|
+
}
|
|
323
|
+
groups.push({ kind: "tool", tool: event });
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (textBuffer) {
|
|
327
|
+
groups.push({ kind: "text", content: textBuffer });
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Second pass: match tool_result events to their tool_use by toolUseId
|
|
331
|
+
const toolResults = events.filter((e) => e.type === "tool_result");
|
|
332
|
+
for (const tr of toolResults) {
|
|
333
|
+
const trId = (tr as any).toolUseId;
|
|
334
|
+
// Match by ID if available
|
|
335
|
+
if (trId) {
|
|
336
|
+
const match = groups.find(
|
|
337
|
+
(g) => g.kind === "tool" && g.tool.type === "tool_use" && (g.tool as any).toolUseId === trId,
|
|
338
|
+
) as (EventGroup & { kind: "tool" }) | undefined;
|
|
339
|
+
if (match) {
|
|
340
|
+
match.result = tr;
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
// Fallback: attach to first tool group without a result
|
|
345
|
+
const unmatched = groups.find(
|
|
346
|
+
(g) => g.kind === "tool" && !g.result,
|
|
347
|
+
) as (EventGroup & { kind: "tool" }) | undefined;
|
|
348
|
+
if (unmatched) {
|
|
349
|
+
unmatched.result = tr;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Mark tool groups as completed: if there are events after the tool group,
|
|
354
|
+
// or streaming is finished, the tool has completed (even without explicit tool_result).
|
|
355
|
+
// The SDK doesn't emit tool_result during streaming — only in REST history.
|
|
356
|
+
for (let gi = 0; gi < groups.length; gi++) {
|
|
357
|
+
const g = groups[gi]!;
|
|
358
|
+
if (g.kind === "tool" && !g.result) {
|
|
359
|
+
const hasEventsAfter = gi < groups.length - 1;
|
|
360
|
+
g.completed = hasEventsAfter || !isStreaming;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return (
|
|
365
|
+
<>
|
|
366
|
+
{groups.map((group, i) => {
|
|
367
|
+
if (group.kind === "text") {
|
|
368
|
+
const isLast = isStreaming && i === groups.length - 1;
|
|
369
|
+
return (
|
|
370
|
+
<div key={`text-${i}`} className="text-sm text-text-primary">
|
|
371
|
+
<StreamingText content={group.content} animate={isLast} />
|
|
372
|
+
</div>
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
return <ToolCard key={`tool-${i}`} tool={group.tool} result={group.result} completed={group.completed} />;
|
|
376
|
+
})}
|
|
377
|
+
</>
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Text component with typewriter effect.
|
|
383
|
+
* When `animate=true`, reveals content progressively.
|
|
384
|
+
* When `animate=false` (finalized), shows full content instantly.
|
|
385
|
+
*/
|
|
386
|
+
function StreamingText({ content, animate }: { content: string; animate: boolean }) {
|
|
387
|
+
const [displayed, setDisplayed] = useState(content);
|
|
388
|
+
const prevLenRef = useRef(0);
|
|
389
|
+
const rafRef = useRef<number>(0);
|
|
390
|
+
|
|
391
|
+
useEffect(() => {
|
|
392
|
+
if (!animate) {
|
|
393
|
+
// Not streaming — show everything immediately
|
|
394
|
+
setDisplayed(content);
|
|
395
|
+
prevLenRef.current = content.length;
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// If content grew, animate from where we left off
|
|
400
|
+
const prevLen = prevLenRef.current;
|
|
401
|
+
if (content.length <= prevLen) {
|
|
402
|
+
setDisplayed(content);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
let cursor = prevLen;
|
|
407
|
+
const target = content.length;
|
|
408
|
+
// Reveal ~20 chars per frame (~60fps = ~1200 chars/sec)
|
|
409
|
+
const charsPerFrame = Math.max(3, Math.ceil((target - cursor) / 30));
|
|
410
|
+
|
|
411
|
+
const step = () => {
|
|
412
|
+
cursor = Math.min(cursor + charsPerFrame, target);
|
|
413
|
+
setDisplayed(content.slice(0, cursor));
|
|
414
|
+
if (cursor < target) {
|
|
415
|
+
rafRef.current = requestAnimationFrame(step);
|
|
416
|
+
} else {
|
|
417
|
+
prevLenRef.current = target;
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
rafRef.current = requestAnimationFrame(step);
|
|
422
|
+
return () => cancelAnimationFrame(rafRef.current);
|
|
423
|
+
}, [content, animate]);
|
|
424
|
+
|
|
425
|
+
// When streaming finishes, sync to full content
|
|
426
|
+
useEffect(() => {
|
|
427
|
+
if (!animate) {
|
|
428
|
+
setDisplayed(content);
|
|
429
|
+
prevLenRef.current = content.length;
|
|
430
|
+
}
|
|
431
|
+
}, [animate, content]);
|
|
432
|
+
|
|
433
|
+
return (
|
|
434
|
+
<>
|
|
435
|
+
<MarkdownContent content={displayed} />
|
|
436
|
+
{animate && <StreamingCursor />}
|
|
437
|
+
</>
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Blinking cursor shown at the end of streaming text */
|
|
442
|
+
function StreamingCursor() {
|
|
443
|
+
return (
|
|
444
|
+
<span className="inline-block w-[2px] h-[1em] bg-accent ml-0.5 align-text-bottom animate-blink" />
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Shows "Thinking..." when:
|
|
450
|
+
* - No assistant message yet (waiting for first response)
|
|
451
|
+
* - Last event is tool_use/tool_result (waiting for Claude after tool execution)
|
|
452
|
+
*/
|
|
453
|
+
function ThinkingIndicator({ lastMessage }: { lastMessage?: ChatMessage }) {
|
|
454
|
+
// No assistant message yet
|
|
455
|
+
if (!lastMessage || lastMessage.role !== "assistant") {
|
|
456
|
+
return (
|
|
457
|
+
<div className="flex items-center gap-2 text-text-subtle text-sm">
|
|
458
|
+
<span className="animate-pulse">Thinking...</span>
|
|
459
|
+
</div>
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Check if last event is non-text (tool_use, tool_result) → waiting for next response
|
|
464
|
+
const events = lastMessage.events;
|
|
465
|
+
if (events && events.length > 0) {
|
|
466
|
+
const lastEvent = events[events.length - 1]!;
|
|
467
|
+
if (lastEvent?.type === "tool_use" || lastEvent?.type === "tool_result") {
|
|
468
|
+
return (
|
|
469
|
+
<div className="flex items-center gap-2 text-text-subtle text-sm">
|
|
470
|
+
<span className="animate-pulse">Thinking...</span>
|
|
471
|
+
</div>
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Configure marked for safe rendering */
|
|
480
|
+
marked.setOptions({
|
|
481
|
+
gfm: true,
|
|
482
|
+
breaks: true,
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
/** Renders markdown content using `marked` → HTML string */
|
|
486
|
+
function MarkdownContent({ content }: { content: string }) {
|
|
487
|
+
const html = useMemo(() => {
|
|
488
|
+
try {
|
|
489
|
+
return marked.parse(content) as string;
|
|
490
|
+
} catch {
|
|
491
|
+
return content;
|
|
492
|
+
}
|
|
493
|
+
}, [content]);
|
|
494
|
+
|
|
495
|
+
return (
|
|
496
|
+
<div
|
|
497
|
+
className="markdown-content prose-sm"
|
|
498
|
+
dangerouslySetInnerHTML={{ __html: html }}
|
|
499
|
+
/>
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** Unified tool card: shows tool-specific summary + expandable details */
|
|
504
|
+
function ToolCard({ tool, result, completed }: { tool: ChatEvent; result?: ChatEvent; completed?: boolean }) {
|
|
505
|
+
const [expanded, setExpanded] = useState(false);
|
|
506
|
+
|
|
507
|
+
if (tool.type === "error") {
|
|
508
|
+
return (
|
|
509
|
+
<div className="flex items-center gap-2 rounded bg-red-500/10 border border-red-500/20 px-2 py-1.5 text-xs text-red-400">
|
|
510
|
+
<AlertCircle className="size-3" />
|
|
511
|
+
<span>{tool.message}</span>
|
|
512
|
+
</div>
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const isApproval = tool.type === "approval_request";
|
|
517
|
+
const toolName = tool.type === "tool_use"
|
|
518
|
+
? tool.tool
|
|
519
|
+
: isApproval
|
|
520
|
+
? (tool as any).tool ?? "Tool"
|
|
521
|
+
: "Tool";
|
|
522
|
+
const input = tool.type === "tool_use"
|
|
523
|
+
? (tool.input as Record<string, unknown>)
|
|
524
|
+
: isApproval
|
|
525
|
+
? ((tool as any).input as Record<string, unknown>) ?? {}
|
|
526
|
+
: {};
|
|
527
|
+
const hasResult = result?.type === "tool_result";
|
|
528
|
+
const isError = hasResult && !!(result as any).isError;
|
|
529
|
+
// AskUserQuestion with answers already submitted → show as completed
|
|
530
|
+
const hasAnswers = toolName === "AskUserQuestion" && !!(input as any)?.answers;
|
|
531
|
+
// Determine icon: error (red X) > success (green check) > pending (yellow wrench)
|
|
532
|
+
const isDone = hasResult || hasAnswers || completed;
|
|
533
|
+
|
|
534
|
+
return (
|
|
535
|
+
<div className="rounded border border-border bg-background text-xs">
|
|
536
|
+
<button
|
|
537
|
+
onClick={() => setExpanded(!expanded)}
|
|
538
|
+
className="flex items-center gap-2 px-2 py-1.5 w-full text-left hover:bg-surface transition-colors min-w-0"
|
|
539
|
+
>
|
|
540
|
+
{expanded ? <ChevronDown className="size-3 shrink-0" /> : <ChevronRight className="size-3 shrink-0" />}
|
|
541
|
+
{isError
|
|
542
|
+
? <XCircle className="size-3 text-red-400 shrink-0" />
|
|
543
|
+
: isDone
|
|
544
|
+
? <CheckCircle2 className="size-3 text-green-400 shrink-0" />
|
|
545
|
+
: <Wrench className="size-3 text-yellow-400 shrink-0" />
|
|
546
|
+
}
|
|
547
|
+
<span className="truncate text-text-primary">
|
|
548
|
+
<ToolSummary name={toolName} input={input} />
|
|
549
|
+
</span>
|
|
550
|
+
</button>
|
|
551
|
+
{expanded && (
|
|
552
|
+
<div className="px-2 pb-2 space-y-1.5">
|
|
553
|
+
{(tool.type === "tool_use" || isApproval) && (
|
|
554
|
+
<ToolDetails name={toolName} input={input} />
|
|
555
|
+
)}
|
|
556
|
+
{hasResult && (
|
|
557
|
+
<pre className="overflow-x-auto text-text-subtle font-mono max-h-40 border-t border-border pt-1.5 whitespace-pre-wrap break-all">
|
|
558
|
+
{(result as any).output}
|
|
559
|
+
</pre>
|
|
560
|
+
)}
|
|
561
|
+
</div>
|
|
562
|
+
)}
|
|
563
|
+
</div>
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** Render one-line summary per tool type */
|
|
568
|
+
function ToolSummary({ name, input }: { name: string; input: Record<string, unknown> }) {
|
|
569
|
+
const s = (v: unknown) => String(v ?? "");
|
|
570
|
+
switch (name) {
|
|
571
|
+
case "Read":
|
|
572
|
+
case "Write":
|
|
573
|
+
case "Edit":
|
|
574
|
+
return <>{name} <span className="text-text-subtle">{basename(s(input.file_path))}</span></>;
|
|
575
|
+
case "Bash":
|
|
576
|
+
return <>{name} <span className="font-mono text-text-subtle">{truncate(s(input.command), 60)}</span></>;
|
|
577
|
+
case "Glob":
|
|
578
|
+
return <>{name} <span className="font-mono text-text-subtle">{s(input.pattern)}</span></>;
|
|
579
|
+
case "Grep":
|
|
580
|
+
return <>{name} <span className="font-mono text-text-subtle">{truncate(s(input.pattern), 40)}</span></>;
|
|
581
|
+
case "WebSearch":
|
|
582
|
+
return <>{name} <span className="text-text-subtle">{truncate(s(input.query), 50)}</span></>;
|
|
583
|
+
case "WebFetch":
|
|
584
|
+
return <>{name} <span className="text-text-subtle">{truncate(s(input.url), 50)}</span></>;
|
|
585
|
+
case "AskUserQuestion": {
|
|
586
|
+
const qs = (input.questions as Array<{ question: string }>) ?? [];
|
|
587
|
+
const hasAns = !!(input.answers);
|
|
588
|
+
return <>{name} <span className="text-text-subtle">{qs.length} question{qs.length !== 1 ? "s" : ""}{hasAns ? " ✓" : ""}</span></>;
|
|
589
|
+
}
|
|
590
|
+
default:
|
|
591
|
+
return <>{name}</>;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** Render expanded details per tool type */
|
|
596
|
+
function ToolDetails({ name, input }: { name: string; input: Record<string, unknown> }) {
|
|
597
|
+
const s = (v: unknown) => String(v ?? "");
|
|
598
|
+
switch (name) {
|
|
599
|
+
case "Bash":
|
|
600
|
+
return (
|
|
601
|
+
<div className="space-y-1">
|
|
602
|
+
{!!input.description && <p className="text-text-subtle italic">{s(input.description)}</p>}
|
|
603
|
+
<pre className="font-mono text-text-secondary overflow-x-auto whitespace-pre-wrap break-all">{s(input.command)}</pre>
|
|
604
|
+
</div>
|
|
605
|
+
);
|
|
606
|
+
case "Read":
|
|
607
|
+
case "Write":
|
|
608
|
+
case "Edit":
|
|
609
|
+
return (
|
|
610
|
+
<div className="space-y-1">
|
|
611
|
+
<p className="font-mono text-text-secondary break-all">{s(input.file_path)}</p>
|
|
612
|
+
{name === "Edit" && !!input.old_string && (
|
|
613
|
+
<div className="border-l-2 border-red-400/40 pl-2">
|
|
614
|
+
<pre className="font-mono text-red-400/70 overflow-x-auto whitespace-pre-wrap">{truncate(s(input.old_string), 200)}</pre>
|
|
615
|
+
</div>
|
|
616
|
+
)}
|
|
617
|
+
{name === "Edit" && !!input.new_string && (
|
|
618
|
+
<div className="border-l-2 border-green-400/40 pl-2">
|
|
619
|
+
<pre className="font-mono text-green-400/70 overflow-x-auto whitespace-pre-wrap">{truncate(s(input.new_string), 200)}</pre>
|
|
620
|
+
</div>
|
|
621
|
+
)}
|
|
622
|
+
{name === "Write" && !!input.content && (
|
|
623
|
+
<pre className="font-mono text-text-subtle overflow-x-auto max-h-32 whitespace-pre-wrap">{truncate(s(input.content), 300)}</pre>
|
|
624
|
+
)}
|
|
625
|
+
</div>
|
|
626
|
+
);
|
|
627
|
+
case "Glob":
|
|
628
|
+
return <p className="font-mono text-text-secondary">{s(input.pattern)}{input.path ? ` in ${s(input.path)}` : ""}</p>;
|
|
629
|
+
case "Grep":
|
|
630
|
+
return (
|
|
631
|
+
<div className="space-y-0.5">
|
|
632
|
+
<p className="font-mono text-text-secondary">/{s(input.pattern)}/</p>
|
|
633
|
+
{!!input.path && <p className="text-text-subtle">in {s(input.path)}</p>}
|
|
634
|
+
</div>
|
|
635
|
+
);
|
|
636
|
+
case "AskUserQuestion": {
|
|
637
|
+
const qs = (input.questions as Array<{ question: string; header?: string; options: Array<{ label: string; description?: string }>; multiSelect?: boolean }>) ?? [];
|
|
638
|
+
const answers = (input.answers as Record<string, string>) ?? {};
|
|
639
|
+
return (
|
|
640
|
+
<div className="space-y-2">
|
|
641
|
+
{qs.map((q, i) => (
|
|
642
|
+
<div key={i} className="space-y-0.5">
|
|
643
|
+
<p className="text-text-primary font-medium">{q.header ? `${q.header}: ` : ""}{q.question}</p>
|
|
644
|
+
<div className="flex flex-wrap gap-1">
|
|
645
|
+
{q.options.map((opt, oi) => {
|
|
646
|
+
const answer = answers[q.question] ?? "";
|
|
647
|
+
const isSelected = answer.split(", ").includes(opt.label);
|
|
648
|
+
return (
|
|
649
|
+
<span key={oi} className={`inline-block rounded px-1.5 py-0.5 text-xs border ${
|
|
650
|
+
isSelected ? "border-accent bg-accent/20 text-text-primary" : "border-border text-text-subtle"
|
|
651
|
+
}`}>
|
|
652
|
+
{opt.label}
|
|
653
|
+
</span>
|
|
654
|
+
);
|
|
655
|
+
})}
|
|
656
|
+
</div>
|
|
657
|
+
{answers[q.question] && (
|
|
658
|
+
<p className="text-accent text-xs">Answer: {answers[q.question]}</p>
|
|
659
|
+
)}
|
|
660
|
+
</div>
|
|
661
|
+
))}
|
|
662
|
+
</div>
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
default:
|
|
666
|
+
return (
|
|
667
|
+
<pre className="overflow-x-auto text-text-secondary font-mono whitespace-pre-wrap break-all">
|
|
668
|
+
{JSON.stringify(input, null, 2)}
|
|
669
|
+
</pre>
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function basename(path?: string): string {
|
|
675
|
+
if (!path) return "";
|
|
676
|
+
return path.split("/").pop() ?? path;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function truncate(str?: string, max = 50): string {
|
|
680
|
+
if (!str) return "";
|
|
681
|
+
return str.length > max ? str.slice(0, max) + "…" : str;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function ApprovalCard({
|
|
685
|
+
approval,
|
|
686
|
+
onRespond,
|
|
687
|
+
}: {
|
|
688
|
+
approval: { requestId: string; tool: string; input: unknown };
|
|
689
|
+
onRespond: (requestId: string, approved: boolean, data?: unknown) => void;
|
|
690
|
+
}) {
|
|
691
|
+
return (
|
|
692
|
+
<div className="rounded-lg border-2 border-yellow-500/40 bg-yellow-500/10 p-3 space-y-2">
|
|
693
|
+
<div className="flex items-center gap-2 text-yellow-400 text-sm font-medium">
|
|
694
|
+
<ShieldAlert className="size-4" />
|
|
695
|
+
<span>Tool Approval Required</span>
|
|
696
|
+
</div>
|
|
697
|
+
<div className="text-xs text-text-primary">
|
|
698
|
+
<span className="font-medium">{approval.tool}</span>
|
|
699
|
+
</div>
|
|
700
|
+
<pre className="text-xs font-mono text-text-secondary overflow-x-auto bg-background rounded p-2 border border-border">
|
|
701
|
+
{JSON.stringify(approval.input, null, 2)}
|
|
702
|
+
</pre>
|
|
703
|
+
<div className="flex gap-2">
|
|
704
|
+
<button
|
|
705
|
+
onClick={() => onRespond(approval.requestId, true)}
|
|
706
|
+
className="px-4 py-1.5 rounded bg-green-600 text-white text-xs font-medium hover:bg-green-500 transition-colors"
|
|
707
|
+
>
|
|
708
|
+
Allow
|
|
709
|
+
</button>
|
|
710
|
+
<button
|
|
711
|
+
onClick={() => onRespond(approval.requestId, false)}
|
|
712
|
+
className="px-4 py-1.5 rounded bg-red-600 text-white text-xs font-medium hover:bg-red-500 transition-colors"
|
|
713
|
+
>
|
|
714
|
+
Deny
|
|
715
|
+
</button>
|
|
716
|
+
</div>
|
|
717
|
+
</div>
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/** Interactive quiz form for AskUserQuestion — renders questions with selectable options + Other */
|
|
722
|
+
function AskUserQuestionCard({
|
|
723
|
+
approval,
|
|
724
|
+
onRespond,
|
|
725
|
+
}: {
|
|
726
|
+
approval: { requestId: string; tool: string; input: unknown };
|
|
727
|
+
onRespond: (requestId: string, approved: boolean, data?: unknown) => void;
|
|
728
|
+
}) {
|
|
729
|
+
const input = approval.input as {
|
|
730
|
+
questions?: Array<{
|
|
731
|
+
question: string;
|
|
732
|
+
header?: string;
|
|
733
|
+
options: Array<{ label: string; description?: string }>;
|
|
734
|
+
multiSelect?: boolean;
|
|
735
|
+
}>;
|
|
736
|
+
};
|
|
737
|
+
const questions = input.questions ?? [];
|
|
738
|
+
|
|
739
|
+
const [answers, setAnswers] = useState<Record<string, string>>({});
|
|
740
|
+
// Track which questions have "Other" active
|
|
741
|
+
const [otherActive, setOtherActive] = useState<Record<string, boolean>>({});
|
|
742
|
+
|
|
743
|
+
const handleSelect = (question: string, label: string, multiSelect?: boolean) => {
|
|
744
|
+
// Deactivate "Other" when selecting a predefined option
|
|
745
|
+
setOtherActive((prev) => ({ ...prev, [question]: false }));
|
|
746
|
+
setAnswers((prev) => {
|
|
747
|
+
if (!multiSelect) return { ...prev, [question]: label };
|
|
748
|
+
const current = prev[question] ?? "";
|
|
749
|
+
const labels = current ? current.split(", ") : [];
|
|
750
|
+
const idx = labels.indexOf(label);
|
|
751
|
+
if (idx >= 0) labels.splice(idx, 1);
|
|
752
|
+
else labels.push(label);
|
|
753
|
+
return { ...prev, [question]: labels.join(", ") };
|
|
754
|
+
});
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
const handleOtherToggle = (question: string) => {
|
|
758
|
+
setOtherActive((prev) => ({ ...prev, [question]: true }));
|
|
759
|
+
setAnswers((prev) => ({ ...prev, [question]: "" }));
|
|
760
|
+
};
|
|
761
|
+
|
|
762
|
+
const handleOtherText = (question: string, text: string) => {
|
|
763
|
+
setAnswers((prev) => ({ ...prev, [question]: text }));
|
|
764
|
+
};
|
|
765
|
+
|
|
766
|
+
const allAnswered = questions.every((q) => answers[q.question]?.trim());
|
|
767
|
+
|
|
768
|
+
return (
|
|
769
|
+
<div className="rounded-lg border-2 border-accent/40 bg-accent/5 p-3 space-y-3">
|
|
770
|
+
{questions.map((q, qi) => (
|
|
771
|
+
<div key={qi} className="space-y-1.5">
|
|
772
|
+
<p className="text-sm text-text-primary font-medium">
|
|
773
|
+
{q.header ? `${q.header}: ` : ""}{q.question}
|
|
774
|
+
</p>
|
|
775
|
+
{q.multiSelect && (
|
|
776
|
+
<p className="text-xs text-text-subtle">Select multiple</p>
|
|
777
|
+
)}
|
|
778
|
+
<div className="flex flex-col gap-1">
|
|
779
|
+
{q.options.map((opt, oi) => {
|
|
780
|
+
const isOther = otherActive[q.question];
|
|
781
|
+
const selected = !isOther && (answers[q.question] ?? "").split(", ").includes(opt.label);
|
|
782
|
+
return (
|
|
783
|
+
<button
|
|
784
|
+
key={oi}
|
|
785
|
+
onClick={() => handleSelect(q.question, opt.label, q.multiSelect)}
|
|
786
|
+
className={`text-left rounded px-2.5 py-1.5 text-xs border transition-colors ${
|
|
787
|
+
selected
|
|
788
|
+
? "border-accent bg-accent/20 text-text-primary"
|
|
789
|
+
: "border-border bg-background text-text-secondary hover:bg-surface-elevated"
|
|
790
|
+
}`}
|
|
791
|
+
>
|
|
792
|
+
<span className="font-medium">{opt.label}</span>
|
|
793
|
+
{opt.description && (
|
|
794
|
+
<span className="text-text-subtle ml-1.5">— {opt.description}</span>
|
|
795
|
+
)}
|
|
796
|
+
</button>
|
|
797
|
+
);
|
|
798
|
+
})}
|
|
799
|
+
{/* Other option */}
|
|
800
|
+
{otherActive[q.question] ? (
|
|
801
|
+
<input
|
|
802
|
+
type="text"
|
|
803
|
+
autoFocus
|
|
804
|
+
placeholder="Type your answer..."
|
|
805
|
+
value={answers[q.question] ?? ""}
|
|
806
|
+
onChange={(e) => handleOtherText(q.question, e.target.value)}
|
|
807
|
+
className="rounded px-2.5 py-1.5 text-xs border border-accent bg-accent/10 text-text-primary outline-none placeholder:text-text-subtle"
|
|
808
|
+
/>
|
|
809
|
+
) : (
|
|
810
|
+
<button
|
|
811
|
+
onClick={() => handleOtherToggle(q.question)}
|
|
812
|
+
className="text-left rounded px-2.5 py-1.5 text-xs border border-dashed border-border text-text-subtle hover:bg-surface-elevated transition-colors"
|
|
813
|
+
>
|
|
814
|
+
Other — type your own answer
|
|
815
|
+
</button>
|
|
816
|
+
)}
|
|
817
|
+
</div>
|
|
818
|
+
</div>
|
|
819
|
+
))}
|
|
820
|
+
|
|
821
|
+
<div className="flex gap-2 pt-1">
|
|
822
|
+
<button
|
|
823
|
+
onClick={() => onRespond(approval.requestId, true, answers)}
|
|
824
|
+
disabled={!allAnswered}
|
|
825
|
+
className="px-4 py-1.5 rounded bg-accent text-white text-xs font-medium hover:bg-accent/80 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
|
826
|
+
>
|
|
827
|
+
Submit
|
|
828
|
+
</button>
|
|
829
|
+
<button
|
|
830
|
+
onClick={() => onRespond(approval.requestId, false)}
|
|
831
|
+
className="px-4 py-1.5 rounded bg-surface-elevated text-text-secondary text-xs hover:bg-surface transition-colors"
|
|
832
|
+
>
|
|
833
|
+
Skip
|
|
834
|
+
</button>
|
|
835
|
+
</div>
|
|
836
|
+
</div>
|
|
837
|
+
);
|
|
838
|
+
}
|