@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,420 @@
|
|
|
1
|
+
import { useState, useRef, useCallback, useEffect, type KeyboardEvent, type DragEvent, type ClipboardEvent } from "react";
|
|
2
|
+
import { Send, Square, Paperclip } from "lucide-react";
|
|
3
|
+
import { api, projectUrl, getAuthToken } from "@/lib/api-client";
|
|
4
|
+
import { isSupportedFile, isImageFile } from "@/lib/file-support";
|
|
5
|
+
import { AttachmentChips } from "./attachment-chips";
|
|
6
|
+
import type { SlashItem } from "./slash-command-picker";
|
|
7
|
+
import type { FileNode } from "../../../types/project";
|
|
8
|
+
import { flattenFileTree } from "./file-picker";
|
|
9
|
+
|
|
10
|
+
export interface ChatAttachment {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
file: File;
|
|
14
|
+
isImage: boolean;
|
|
15
|
+
previewUrl?: string;
|
|
16
|
+
/** Server-side path after upload */
|
|
17
|
+
serverPath?: string;
|
|
18
|
+
status: "uploading" | "ready" | "error";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface MessageInputProps {
|
|
22
|
+
onSend: (content: string, attachments: ChatAttachment[]) => void;
|
|
23
|
+
isStreaming?: boolean;
|
|
24
|
+
onCancel?: () => void;
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
projectName?: string;
|
|
27
|
+
/** Slash picker state change */
|
|
28
|
+
onSlashStateChange?: (visible: boolean, filter: string) => void;
|
|
29
|
+
onSlashItemsLoaded?: (items: SlashItem[]) => void;
|
|
30
|
+
slashSelected?: SlashItem | null;
|
|
31
|
+
/** File picker state change */
|
|
32
|
+
onFileStateChange?: (visible: boolean, filter: string) => void;
|
|
33
|
+
onFileItemsLoaded?: (items: FileNode[]) => void;
|
|
34
|
+
fileSelected?: FileNode | null;
|
|
35
|
+
/** External files added via drag-drop on parent */
|
|
36
|
+
externalFiles?: File[] | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function MessageInput({
|
|
40
|
+
onSend,
|
|
41
|
+
isStreaming,
|
|
42
|
+
onCancel,
|
|
43
|
+
disabled,
|
|
44
|
+
projectName,
|
|
45
|
+
onSlashStateChange,
|
|
46
|
+
onSlashItemsLoaded,
|
|
47
|
+
slashSelected,
|
|
48
|
+
onFileStateChange,
|
|
49
|
+
onFileItemsLoaded,
|
|
50
|
+
fileSelected,
|
|
51
|
+
externalFiles,
|
|
52
|
+
}: MessageInputProps) {
|
|
53
|
+
const [value, setValue] = useState("");
|
|
54
|
+
const [attachments, setAttachments] = useState<ChatAttachment[]>([]);
|
|
55
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
56
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
57
|
+
const slashItemsRef = useRef<SlashItem[]>([]);
|
|
58
|
+
const fileItemsRef = useRef<FileNode[]>([]);
|
|
59
|
+
|
|
60
|
+
// Fetch slash items when projectName changes
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!projectName) {
|
|
63
|
+
slashItemsRef.current = [];
|
|
64
|
+
onSlashItemsLoaded?.([]);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
api
|
|
68
|
+
.get<SlashItem[]>(`${projectUrl(projectName)}/chat/slash-items`)
|
|
69
|
+
.then((items) => {
|
|
70
|
+
slashItemsRef.current = items;
|
|
71
|
+
onSlashItemsLoaded?.(items);
|
|
72
|
+
})
|
|
73
|
+
.catch(() => {
|
|
74
|
+
slashItemsRef.current = [];
|
|
75
|
+
onSlashItemsLoaded?.([]);
|
|
76
|
+
});
|
|
77
|
+
}, [projectName]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
78
|
+
|
|
79
|
+
// Fetch file tree when projectName changes
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (!projectName) {
|
|
82
|
+
fileItemsRef.current = [];
|
|
83
|
+
onFileItemsLoaded?.([]);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
api
|
|
87
|
+
.get<FileNode[]>(`${projectUrl(projectName)}/files/tree?depth=5`)
|
|
88
|
+
.then((tree) => {
|
|
89
|
+
const flat = flattenFileTree(tree);
|
|
90
|
+
fileItemsRef.current = flat;
|
|
91
|
+
onFileItemsLoaded?.(flat);
|
|
92
|
+
})
|
|
93
|
+
.catch(() => {
|
|
94
|
+
fileItemsRef.current = [];
|
|
95
|
+
onFileItemsLoaded?.([]);
|
|
96
|
+
});
|
|
97
|
+
}, [projectName]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
98
|
+
|
|
99
|
+
// Handle parent selecting a slash item
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (!slashSelected) return;
|
|
102
|
+
const commandText = `/${slashSelected.name} `;
|
|
103
|
+
setValue(commandText);
|
|
104
|
+
onSlashStateChange?.(false, "");
|
|
105
|
+
onFileStateChange?.(false, "");
|
|
106
|
+
const el = textareaRef.current;
|
|
107
|
+
if (el) {
|
|
108
|
+
el.focus();
|
|
109
|
+
setTimeout(() => {
|
|
110
|
+
el.selectionStart = el.selectionEnd = commandText.length;
|
|
111
|
+
}, 0);
|
|
112
|
+
}
|
|
113
|
+
}, [slashSelected]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
114
|
+
|
|
115
|
+
// Handle parent selecting a file
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
if (!fileSelected) return;
|
|
118
|
+
const el = textareaRef.current;
|
|
119
|
+
if (!el) return;
|
|
120
|
+
|
|
121
|
+
// Replace the @query with @path
|
|
122
|
+
const cursorPos = el.selectionStart;
|
|
123
|
+
const textBefore = value.slice(0, cursorPos);
|
|
124
|
+
const textAfter = value.slice(cursorPos);
|
|
125
|
+
// Find the @ trigger before cursor
|
|
126
|
+
const atMatch = textBefore.match(/@(\S*)$/);
|
|
127
|
+
if (atMatch) {
|
|
128
|
+
const start = textBefore.length - atMatch[0].length;
|
|
129
|
+
const newText = textBefore.slice(0, start) + `@${fileSelected.path} ` + textAfter;
|
|
130
|
+
setValue(newText);
|
|
131
|
+
const newCursorPos = start + fileSelected.path.length + 2; // +2 for @ and space
|
|
132
|
+
setTimeout(() => {
|
|
133
|
+
el.selectionStart = el.selectionEnd = newCursorPos;
|
|
134
|
+
el.focus();
|
|
135
|
+
}, 0);
|
|
136
|
+
} else {
|
|
137
|
+
// Fallback: append at end
|
|
138
|
+
const newText = value + `@${fileSelected.path} `;
|
|
139
|
+
setValue(newText);
|
|
140
|
+
setTimeout(() => {
|
|
141
|
+
el.selectionStart = el.selectionEnd = newText.length;
|
|
142
|
+
el.focus();
|
|
143
|
+
}, 0);
|
|
144
|
+
}
|
|
145
|
+
onFileStateChange?.(false, "");
|
|
146
|
+
}, [fileSelected]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
147
|
+
|
|
148
|
+
// Handle external files dropped on parent (ChatTab)
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
if (!externalFiles || externalFiles.length === 0) return;
|
|
151
|
+
processFiles(externalFiles);
|
|
152
|
+
}, [externalFiles]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
153
|
+
|
|
154
|
+
/** Upload a single file to the server, return server path */
|
|
155
|
+
const uploadFile = useCallback(
|
|
156
|
+
async (file: File): Promise<string | null> => {
|
|
157
|
+
if (!projectName) return null;
|
|
158
|
+
try {
|
|
159
|
+
const form = new FormData();
|
|
160
|
+
form.append("files", file);
|
|
161
|
+
const headers: HeadersInit = {};
|
|
162
|
+
const token = getAuthToken();
|
|
163
|
+
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
164
|
+
const res = await fetch(`${projectUrl(projectName)}/chat/upload`, {
|
|
165
|
+
method: "POST",
|
|
166
|
+
headers,
|
|
167
|
+
body: form,
|
|
168
|
+
});
|
|
169
|
+
const json = await res.json();
|
|
170
|
+
if (json.ok && Array.isArray(json.data) && json.data.length > 0) {
|
|
171
|
+
return json.data[0].path as string;
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
} catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
[projectName],
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
/** Process dropped/pasted/selected files */
|
|
182
|
+
const processFiles = useCallback(
|
|
183
|
+
(files: File[]) => {
|
|
184
|
+
for (const file of files) {
|
|
185
|
+
if (!isSupportedFile(file)) {
|
|
186
|
+
// Unsupported → insert file name as text
|
|
187
|
+
setValue((prev) => prev + (prev.length > 0 && !prev.endsWith(" ") ? " " : "") + file.name);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const id = crypto.randomUUID().slice(0, 8);
|
|
192
|
+
const isImg = isImageFile(file);
|
|
193
|
+
const previewUrl = isImg ? URL.createObjectURL(file) : undefined;
|
|
194
|
+
|
|
195
|
+
const att: ChatAttachment = {
|
|
196
|
+
id,
|
|
197
|
+
name: file.name,
|
|
198
|
+
file,
|
|
199
|
+
isImage: isImg,
|
|
200
|
+
previewUrl,
|
|
201
|
+
status: "uploading",
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
setAttachments((prev) => [...prev, att]);
|
|
205
|
+
|
|
206
|
+
// Upload in background
|
|
207
|
+
uploadFile(file).then((serverPath) => {
|
|
208
|
+
setAttachments((prev) =>
|
|
209
|
+
prev.map((a) =>
|
|
210
|
+
a.id === id
|
|
211
|
+
? { ...a, serverPath: serverPath ?? undefined, status: serverPath ? "ready" : "error" }
|
|
212
|
+
: a,
|
|
213
|
+
),
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
textareaRef.current?.focus();
|
|
218
|
+
},
|
|
219
|
+
[uploadFile],
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
const removeAttachment = useCallback((id: string) => {
|
|
223
|
+
setAttachments((prev) => {
|
|
224
|
+
const att = prev.find((a) => a.id === id);
|
|
225
|
+
if (att?.previewUrl) URL.revokeObjectURL(att.previewUrl);
|
|
226
|
+
return prev.filter((a) => a.id !== id);
|
|
227
|
+
});
|
|
228
|
+
}, []);
|
|
229
|
+
|
|
230
|
+
const handleSend = useCallback(() => {
|
|
231
|
+
const trimmed = value.trim();
|
|
232
|
+
const readyAttachments = attachments.filter((a) => a.status === "ready");
|
|
233
|
+
if (!trimmed && readyAttachments.length === 0) return;
|
|
234
|
+
if (disabled) return;
|
|
235
|
+
|
|
236
|
+
onSlashStateChange?.(false, "");
|
|
237
|
+
onFileStateChange?.(false, "");
|
|
238
|
+
onSend(trimmed, readyAttachments);
|
|
239
|
+
setValue("");
|
|
240
|
+
// Revoke preview URLs
|
|
241
|
+
for (const att of attachments) {
|
|
242
|
+
if (att.previewUrl) URL.revokeObjectURL(att.previewUrl);
|
|
243
|
+
}
|
|
244
|
+
setAttachments([]);
|
|
245
|
+
if (textareaRef.current) {
|
|
246
|
+
textareaRef.current.style.height = "auto";
|
|
247
|
+
}
|
|
248
|
+
}, [value, attachments, disabled, onSend, onSlashStateChange, onFileStateChange]);
|
|
249
|
+
|
|
250
|
+
const handleKeyDown = useCallback(
|
|
251
|
+
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
252
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
253
|
+
e.preventDefault();
|
|
254
|
+
handleSend();
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
[handleSend],
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
const updatePickerState = useCallback(
|
|
261
|
+
(text: string, cursorPos: number) => {
|
|
262
|
+
// Check for slash at start of input
|
|
263
|
+
const slashMatch = text.match(/^\/(\S*)$/);
|
|
264
|
+
if (slashMatch && slashItemsRef.current.length > 0) {
|
|
265
|
+
onSlashStateChange?.(true, slashMatch[1] ?? "");
|
|
266
|
+
onFileStateChange?.(false, "");
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Check for @ anywhere in text (look at text before cursor)
|
|
271
|
+
const textBefore = text.slice(0, cursorPos);
|
|
272
|
+
const atMatch = textBefore.match(/@(\S*)$/);
|
|
273
|
+
if (atMatch && fileItemsRef.current.length > 0) {
|
|
274
|
+
onFileStateChange?.(true, atMatch[1] ?? "");
|
|
275
|
+
onSlashStateChange?.(false, "");
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Nothing matched — close both pickers
|
|
280
|
+
onSlashStateChange?.(false, "");
|
|
281
|
+
onFileStateChange?.(false, "");
|
|
282
|
+
},
|
|
283
|
+
[onSlashStateChange, onFileStateChange],
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
const handleChange = useCallback(
|
|
287
|
+
(text: string) => {
|
|
288
|
+
setValue(text);
|
|
289
|
+
// Use setTimeout to read cursor position after React processes the change
|
|
290
|
+
setTimeout(() => {
|
|
291
|
+
const cursorPos = textareaRef.current?.selectionStart ?? text.length;
|
|
292
|
+
updatePickerState(text, cursorPos);
|
|
293
|
+
}, 0);
|
|
294
|
+
},
|
|
295
|
+
[updatePickerState],
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
const handleInput = useCallback(() => {
|
|
299
|
+
const el = textareaRef.current;
|
|
300
|
+
if (!el) return;
|
|
301
|
+
el.style.height = "auto";
|
|
302
|
+
el.style.height = Math.min(el.scrollHeight, 160) + "px";
|
|
303
|
+
}, []);
|
|
304
|
+
|
|
305
|
+
/** Handle paste — intercept images from clipboard */
|
|
306
|
+
const handlePaste = useCallback(
|
|
307
|
+
(e: ClipboardEvent<HTMLTextAreaElement>) => {
|
|
308
|
+
const items = e.clipboardData?.items;
|
|
309
|
+
if (!items) return;
|
|
310
|
+
|
|
311
|
+
const files: File[] = [];
|
|
312
|
+
for (const item of items) {
|
|
313
|
+
if (item.kind === "file") {
|
|
314
|
+
const file = item.getAsFile();
|
|
315
|
+
if (file) files.push(file);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (files.length > 0) {
|
|
319
|
+
e.preventDefault();
|
|
320
|
+
processFiles(files);
|
|
321
|
+
}
|
|
322
|
+
},
|
|
323
|
+
[processFiles],
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
/** Handle drop directly on textarea */
|
|
327
|
+
const handleDrop = useCallback(
|
|
328
|
+
(e: DragEvent<HTMLTextAreaElement>) => {
|
|
329
|
+
e.preventDefault();
|
|
330
|
+
const files = Array.from(e.dataTransfer.files);
|
|
331
|
+
if (files.length > 0) processFiles(files);
|
|
332
|
+
},
|
|
333
|
+
[processFiles],
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
const handleDragOver = useCallback((e: DragEvent<HTMLTextAreaElement>) => {
|
|
337
|
+
e.preventDefault();
|
|
338
|
+
}, []);
|
|
339
|
+
|
|
340
|
+
/** Open native file picker */
|
|
341
|
+
const handleAttachClick = useCallback(() => {
|
|
342
|
+
fileInputRef.current?.click();
|
|
343
|
+
}, []);
|
|
344
|
+
|
|
345
|
+
const handleFileInputChange = useCallback(
|
|
346
|
+
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
347
|
+
const files = Array.from(e.target.files ?? []);
|
|
348
|
+
if (files.length > 0) processFiles(files);
|
|
349
|
+
// Reset so same file can be selected again
|
|
350
|
+
e.target.value = "";
|
|
351
|
+
},
|
|
352
|
+
[processFiles],
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
const hasContent = value.trim().length > 0 || attachments.some((a) => a.status === "ready");
|
|
356
|
+
const showCancel = isStreaming && !hasContent;
|
|
357
|
+
|
|
358
|
+
return (
|
|
359
|
+
<div className="border-t border-border bg-background">
|
|
360
|
+
{/* Attachment chips */}
|
|
361
|
+
<AttachmentChips attachments={attachments} onRemove={removeAttachment} />
|
|
362
|
+
|
|
363
|
+
{/* Input row */}
|
|
364
|
+
<div className="flex items-end gap-2 p-3">
|
|
365
|
+
{/* Attach button */}
|
|
366
|
+
<button
|
|
367
|
+
type="button"
|
|
368
|
+
onClick={handleAttachClick}
|
|
369
|
+
disabled={disabled}
|
|
370
|
+
className="flex items-center justify-center rounded-lg p-2 text-text-subtle hover:text-text-primary hover:bg-surface transition-colors shrink-0 disabled:opacity-50"
|
|
371
|
+
aria-label="Attach file"
|
|
372
|
+
>
|
|
373
|
+
<Paperclip className="size-4" />
|
|
374
|
+
</button>
|
|
375
|
+
<input
|
|
376
|
+
ref={fileInputRef}
|
|
377
|
+
type="file"
|
|
378
|
+
multiple
|
|
379
|
+
className="hidden"
|
|
380
|
+
onChange={handleFileInputChange}
|
|
381
|
+
/>
|
|
382
|
+
|
|
383
|
+
<textarea
|
|
384
|
+
ref={textareaRef}
|
|
385
|
+
value={value}
|
|
386
|
+
onChange={(e) => {
|
|
387
|
+
handleChange(e.target.value);
|
|
388
|
+
handleInput();
|
|
389
|
+
}}
|
|
390
|
+
onKeyDown={handleKeyDown}
|
|
391
|
+
onPaste={handlePaste}
|
|
392
|
+
onDrop={handleDrop}
|
|
393
|
+
onDragOver={handleDragOver}
|
|
394
|
+
placeholder={isStreaming ? "Send follow-up or press Stop..." : "Type / for commands, @ for files, or drop files..."}
|
|
395
|
+
disabled={disabled}
|
|
396
|
+
rows={1}
|
|
397
|
+
className="flex-1 resize-none rounded-lg border border-border bg-surface px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-subtle focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-50 max-h-40"
|
|
398
|
+
/>
|
|
399
|
+
{showCancel ? (
|
|
400
|
+
<button
|
|
401
|
+
onClick={onCancel}
|
|
402
|
+
className="flex items-center justify-center rounded-lg bg-red-600 p-2 text-white hover:bg-red-500 transition-colors shrink-0"
|
|
403
|
+
aria-label="Stop response"
|
|
404
|
+
>
|
|
405
|
+
<Square className="size-4" />
|
|
406
|
+
</button>
|
|
407
|
+
) : (
|
|
408
|
+
<button
|
|
409
|
+
onClick={handleSend}
|
|
410
|
+
disabled={disabled || !hasContent}
|
|
411
|
+
className="flex items-center justify-center rounded-lg bg-primary p-2 text-white hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shrink-0"
|
|
412
|
+
aria-label="Send message"
|
|
413
|
+
>
|
|
414
|
+
<Send className="size-4" />
|
|
415
|
+
</button>
|
|
416
|
+
)}
|
|
417
|
+
</div>
|
|
418
|
+
</div>
|
|
419
|
+
);
|
|
420
|
+
}
|