@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.
Files changed (159) hide show
  1. package/.claude/agent-memory/tester/MEMORY.md +3 -0
  2. package/.claude/agent-memory/tester/project-ppm-test-conventions.md +32 -0
  3. package/.env.example +1 -0
  4. package/.github/workflows/release.yml +46 -0
  5. package/README.md +349 -0
  6. package/bun.lock +1217 -0
  7. package/components.json +21 -0
  8. package/docs/code-standards.md +574 -0
  9. package/docs/codebase-summary.md +294 -0
  10. package/docs/deployment-guide.md +631 -0
  11. package/docs/design-guidelines.md +661 -0
  12. package/docs/project-overview-pdr.md +142 -0
  13. package/docs/project-roadmap.md +400 -0
  14. package/docs/system-architecture.md +459 -0
  15. package/package.json +68 -0
  16. package/plans/260314-2009-ppm-implementation/phase-01-project-skeleton.md +81 -0
  17. package/plans/260314-2009-ppm-implementation/phase-02-backend-core.md +148 -0
  18. package/plans/260314-2009-ppm-implementation/phase-03-frontend-shell.md +256 -0
  19. package/plans/260314-2009-ppm-implementation/phase-04-file-explorer-editor.md +120 -0
  20. package/plans/260314-2009-ppm-implementation/phase-05-web-terminal.md +174 -0
  21. package/plans/260314-2009-ppm-implementation/phase-06-git-integration.md +244 -0
  22. package/plans/260314-2009-ppm-implementation/phase-07-ai-chat.md +242 -0
  23. package/plans/260314-2009-ppm-implementation/phase-08-cli-commands.md +143 -0
  24. package/plans/260314-2009-ppm-implementation/phase-09-pwa-build-deploy.md +209 -0
  25. package/plans/260314-2009-ppm-implementation/phase-10-testing.md +311 -0
  26. package/plans/260314-2009-ppm-implementation/plan.md +202 -0
  27. package/plans/260315-0356-project-scoped-api-refactor/phase-01-backend-project-router.md +145 -0
  28. package/plans/260315-0356-project-scoped-api-refactor/phase-02-frontend-api-migration.md +107 -0
  29. package/plans/260315-0356-project-scoped-api-refactor/phase-03-per-project-tabs.md +100 -0
  30. package/plans/260315-0356-project-scoped-api-refactor/phase-04-websocket-migration.md +66 -0
  31. package/plans/260315-0356-project-scoped-api-refactor/plan.md +87 -0
  32. package/plans/reports/brainstorm-260314-1938-final-techstack.md +342 -0
  33. package/plans/reports/docs-manager-260315-1314-documentation-creation.md +386 -0
  34. package/plans/reports/fullstack-developer-260314-2252-phase-02-backend-core.md +57 -0
  35. package/plans/reports/fullstack-developer-260314-2253-phase-03-frontend-shell.md +70 -0
  36. package/plans/reports/fullstack-developer-260314-2300-phase-04-05-file-api-terminal-ws.md +49 -0
  37. package/plans/reports/fullstack-developer-260314-2300-phase-04-05-file-explorer-editor-terminal.md +52 -0
  38. package/plans/reports/fullstack-developer-260314-2307-ai-chat-phase7.md +58 -0
  39. package/plans/reports/fullstack-developer-260314-2307-phase-06-git-integration.md +33 -0
  40. package/plans/reports/research-260314-1911-ppm-tech-stack.md +318 -0
  41. package/plans/reports/research-260314-1930-claude-code-integration.md +293 -0
  42. package/plans/reports/researcher-260314-2232-node-pty-bun-crash-analysis.md +305 -0
  43. package/plans/reports/researcher-260314-2232-ui-style.md +942 -0
  44. package/plans/reports/researcher-260315-0300-opcode-claude-interaction.md +745 -0
  45. package/plans/reports/researcher-260315-0303-opcode-deep-analysis.md +742 -0
  46. package/plans/reports/researcher-260315-0305-claude-agent-sdk-github-research.md +423 -0
  47. package/plans/reports/tester-260314-2053-initial-test-suite.md +81 -0
  48. package/ppm.example.yaml +14 -0
  49. package/repomix-output.xml +23745 -0
  50. package/scripts/build.ts +13 -0
  51. package/src/cli/commands/chat-cmd.ts +259 -0
  52. package/src/cli/commands/config-cmd.ts +121 -0
  53. package/src/cli/commands/git-cmd.ts +315 -0
  54. package/src/cli/commands/init.ts +57 -0
  55. package/src/cli/commands/open.ts +19 -0
  56. package/src/cli/commands/projects.ts +100 -0
  57. package/src/cli/commands/start.ts +3 -0
  58. package/src/cli/commands/stop.ts +33 -0
  59. package/src/cli/utils/project-resolver.ts +27 -0
  60. package/src/index.ts +59 -0
  61. package/src/providers/claude-agent-sdk.ts +499 -0
  62. package/src/providers/claude-binary-finder.ts +256 -0
  63. package/src/providers/claude-code-cli.ts +413 -0
  64. package/src/providers/claude-process-registry.ts +106 -0
  65. package/src/providers/mock-provider.ts +171 -0
  66. package/src/providers/provider.interface.ts +10 -0
  67. package/src/providers/registry.ts +45 -0
  68. package/src/server/helpers/resolve-project.ts +22 -0
  69. package/src/server/index.ts +181 -0
  70. package/src/server/middleware/auth.ts +30 -0
  71. package/src/server/routes/chat.ts +153 -0
  72. package/src/server/routes/files.ts +168 -0
  73. package/src/server/routes/git.ts +261 -0
  74. package/src/server/routes/project-scoped.ts +27 -0
  75. package/src/server/routes/projects.ts +57 -0
  76. package/src/server/routes/static.ts +26 -0
  77. package/src/server/ws/chat.ts +130 -0
  78. package/src/server/ws/terminal.ts +89 -0
  79. package/src/services/chat.service.ts +110 -0
  80. package/src/services/claude-usage.service.ts +113 -0
  81. package/src/services/config.service.ts +90 -0
  82. package/src/services/file.service.ts +261 -0
  83. package/src/services/git-dirs.service.ts +112 -0
  84. package/src/services/git.service.ts +372 -0
  85. package/src/services/project.service.ts +107 -0
  86. package/src/services/slash-items.service.ts +184 -0
  87. package/src/services/terminal.service.ts +212 -0
  88. package/src/types/api.ts +37 -0
  89. package/src/types/chat.ts +92 -0
  90. package/src/types/config.ts +41 -0
  91. package/src/types/git.ts +50 -0
  92. package/src/types/project.ts +18 -0
  93. package/src/types/terminal.ts +20 -0
  94. package/src/web/app.tsx +168 -0
  95. package/src/web/components/auth/login-screen.tsx +88 -0
  96. package/src/web/components/chat/attachment-chips.tsx +55 -0
  97. package/src/web/components/chat/chat-placeholder.tsx +10 -0
  98. package/src/web/components/chat/chat-tab.tsx +301 -0
  99. package/src/web/components/chat/file-picker.tsx +126 -0
  100. package/src/web/components/chat/message-input.tsx +420 -0
  101. package/src/web/components/chat/message-list.tsx +838 -0
  102. package/src/web/components/chat/session-picker.tsx +139 -0
  103. package/src/web/components/chat/slash-command-picker.tsx +135 -0
  104. package/src/web/components/chat/usage-badge.tsx +186 -0
  105. package/src/web/components/editor/code-editor.tsx +329 -0
  106. package/src/web/components/editor/diff-viewer.tsx +276 -0
  107. package/src/web/components/editor/editor-placeholder.tsx +10 -0
  108. package/src/web/components/explorer/file-actions.tsx +191 -0
  109. package/src/web/components/explorer/file-tree.tsx +298 -0
  110. package/src/web/components/git/git-graph.tsx +727 -0
  111. package/src/web/components/git/git-placeholder.tsx +55 -0
  112. package/src/web/components/git/git-status-panel.tsx +850 -0
  113. package/src/web/components/layout/mobile-drawer.tsx +137 -0
  114. package/src/web/components/layout/mobile-nav.tsx +103 -0
  115. package/src/web/components/layout/sidebar.tsx +90 -0
  116. package/src/web/components/layout/tab-bar.tsx +152 -0
  117. package/src/web/components/layout/tab-content.tsx +85 -0
  118. package/src/web/components/projects/dir-suggest.tsx +152 -0
  119. package/src/web/components/projects/project-list.tsx +187 -0
  120. package/src/web/components/settings/settings-tab.tsx +57 -0
  121. package/src/web/components/terminal/terminal-placeholder.tsx +10 -0
  122. package/src/web/components/terminal/terminal-tab.tsx +133 -0
  123. package/src/web/components/ui/button.tsx +64 -0
  124. package/src/web/components/ui/context-menu.tsx +250 -0
  125. package/src/web/components/ui/dialog.tsx +156 -0
  126. package/src/web/components/ui/dropdown-menu.tsx +257 -0
  127. package/src/web/components/ui/input.tsx +21 -0
  128. package/src/web/components/ui/scroll-area.tsx +56 -0
  129. package/src/web/components/ui/separator.tsx +26 -0
  130. package/src/web/components/ui/sonner.tsx +40 -0
  131. package/src/web/components/ui/tabs.tsx +91 -0
  132. package/src/web/components/ui/tooltip.tsx +57 -0
  133. package/src/web/hooks/use-chat.ts +420 -0
  134. package/src/web/hooks/use-terminal.ts +182 -0
  135. package/src/web/hooks/use-url-sync.ts +66 -0
  136. package/src/web/hooks/use-websocket.ts +48 -0
  137. package/src/web/index.html +16 -0
  138. package/src/web/lib/api-client.ts +90 -0
  139. package/src/web/lib/file-support.ts +68 -0
  140. package/src/web/lib/utils.ts +6 -0
  141. package/src/web/lib/ws-client.ts +100 -0
  142. package/src/web/main.tsx +10 -0
  143. package/src/web/public/icon-192.svg +5 -0
  144. package/src/web/public/icon-512.svg +5 -0
  145. package/src/web/stores/file-store.ts +81 -0
  146. package/src/web/stores/project-store.ts +50 -0
  147. package/src/web/stores/settings-store.ts +65 -0
  148. package/src/web/stores/tab-store.ts +187 -0
  149. package/src/web/styles/globals.css +227 -0
  150. package/src/web/vite-env.d.ts +1 -0
  151. package/tests/integration/api/chat-routes.test.ts +95 -0
  152. package/tests/integration/claude-agent-sdk-integration.test.ts +228 -0
  153. package/tests/integration/ws/chat-websocket.test.ts +312 -0
  154. package/tests/test-setup.ts +5 -0
  155. package/tests/unit/providers/claude-agent-sdk.test.ts +339 -0
  156. package/tests/unit/providers/mock-provider.test.ts +143 -0
  157. package/tests/unit/services/chat-service.test.ts +100 -0
  158. package/tsconfig.json +32 -0
  159. 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
+ }