@effectnode/media 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 (133) hide show
  1. package/bin/effectnode-media +2 -0
  2. package/dist/backend/index.d.ts +8 -0
  3. package/dist/backend/index.js +13 -0
  4. package/dist/backend/movie-backend/agent/agent-backend.d.ts +16 -0
  5. package/dist/backend/movie-backend/agent/agent-backend.js +703 -0
  6. package/dist/backend/movie-backend/agent/storyprompt.d.ts +1 -0
  7. package/dist/backend/movie-backend/agent/storyprompt.js +90 -0
  8. package/dist/backend/movie-backend/agent/tools/edit-image.d.ts +3 -0
  9. package/dist/backend/movie-backend/agent/tools/edit-image.js +192 -0
  10. package/dist/backend/movie-backend/agent/tools/get-time.d.ts +3 -0
  11. package/dist/backend/movie-backend/agent/tools/get-time.js +7 -0
  12. package/dist/backend/movie-backend/agent/tools/grep-files.d.ts +3 -0
  13. package/dist/backend/movie-backend/agent/tools/grep-files.js +59 -0
  14. package/dist/backend/movie-backend/agent/tools/image-to-video-generation.d.ts +3 -0
  15. package/dist/backend/movie-backend/agent/tools/image-to-video-generation.js +160 -0
  16. package/dist/backend/movie-backend/agent/tools/index.d.ts +7 -0
  17. package/dist/backend/movie-backend/agent/tools/index.js +59 -0
  18. package/dist/backend/movie-backend/agent/tools/list-files.d.ts +3 -0
  19. package/dist/backend/movie-backend/agent/tools/list-files.js +17 -0
  20. package/dist/backend/movie-backend/agent/tools/read-file.d.ts +3 -0
  21. package/dist/backend/movie-backend/agent/tools/read-file.js +28 -0
  22. package/dist/backend/movie-backend/agent/tools/remove-file.d.ts +3 -0
  23. package/dist/backend/movie-backend/agent/tools/remove-file.js +22 -0
  24. package/dist/backend/movie-backend/agent/tools/rename-file.d.ts +3 -0
  25. package/dist/backend/movie-backend/agent/tools/rename-file.js +37 -0
  26. package/dist/backend/movie-backend/agent/tools/show-image.d.ts +3 -0
  27. package/dist/backend/movie-backend/agent/tools/show-image.js +28 -0
  28. package/dist/backend/movie-backend/agent/tools/stitch-videos.d.ts +3 -0
  29. package/dist/backend/movie-backend/agent/tools/stitch-videos.js +96 -0
  30. package/dist/backend/movie-backend/agent/tools/text-to-video-generation.d.ts +3 -0
  31. package/dist/backend/movie-backend/agent/tools/text-to-video-generation.js +125 -0
  32. package/dist/backend/movie-backend/agent/tools/types.d.ts +15 -0
  33. package/dist/backend/movie-backend/agent/tools/types.js +1 -0
  34. package/dist/backend/movie-backend/agent/tools/update-file.d.ts +3 -0
  35. package/dist/backend/movie-backend/agent/tools/update-file.js +27 -0
  36. package/dist/backend/movie-backend/agent/tools/write-file.d.ts +3 -0
  37. package/dist/backend/movie-backend/agent/tools/write-file.js +26 -0
  38. package/dist/backend/movie-backend/agent/workspace.d.ts +25 -0
  39. package/dist/backend/movie-backend/agent/workspace.js +124 -0
  40. package/dist/backend/movie-backend/core.d.ts +4 -0
  41. package/dist/backend/movie-backend/core.js +1167 -0
  42. package/dist/backend/movie-backend/generation-queue.d.ts +24 -0
  43. package/dist/backend/movie-backend/generation-queue.js +766 -0
  44. package/dist/backend/movie-backend/process.d.ts +21 -0
  45. package/dist/backend/movie-backend/process.js +98 -0
  46. package/dist/backend/movie-backend/render-media.d.ts +48 -0
  47. package/dist/backend/movie-backend/render-media.js +3311 -0
  48. package/dist/backend/routes/health.d.ts +1 -0
  49. package/dist/backend/routes/health.js +9 -0
  50. package/dist/backend/routes/index.d.ts +1 -0
  51. package/dist/backend/routes/index.js +11 -0
  52. package/dist/backend/routes/media.d.ts +1 -0
  53. package/dist/backend/routes/media.js +13 -0
  54. package/dist/backend/routes/projects.d.ts +8 -0
  55. package/dist/backend/routes/projects.js +68 -0
  56. package/dist/backend/routes/voice.d.ts +1 -0
  57. package/dist/backend/routes/voice.js +39 -0
  58. package/dist/backend/routes.d.ts +1 -0
  59. package/dist/backend/routes.js +20 -0
  60. package/dist/backend/server.d.ts +1 -0
  61. package/dist/backend/server.js +23 -0
  62. package/dist/backend/workspace.d.ts +5 -0
  63. package/dist/backend/workspace.js +27 -0
  64. package/dist/backend/ws/index.d.ts +3 -0
  65. package/dist/backend/ws/index.js +27 -0
  66. package/dist/backend/ws/messages.d.ts +8 -0
  67. package/dist/backend/ws/messages.js +21 -0
  68. package/dist/backend/ws.d.ts +3 -0
  69. package/dist/backend/ws.js +29 -0
  70. package/dist/index.d.ts +2 -0
  71. package/dist/index.js +80 -0
  72. package/frontend/index.html +22 -0
  73. package/frontend/public/ffmpeg/ffmpeg-core.js +16 -0
  74. package/frontend/public/ffmpeg/ffmpeg-core.wasm +0 -0
  75. package/frontend/src/index.css +46 -0
  76. package/frontend/src/main.tsx +13 -0
  77. package/frontend/src/movie-app/AppRouter.tsx +14 -0
  78. package/frontend/src/movie-app/MediaStudio.tsx +9 -0
  79. package/frontend/src/movie-app/SetupPage.tsx +293 -0
  80. package/frontend/src/movie-app/components/AgentWorkspace.tsx +574 -0
  81. package/frontend/src/movie-app/components/ChatUI/ChatUI.tsx +655 -0
  82. package/frontend/src/movie-app/components/ChatUI/MarkdownMessage.tsx +267 -0
  83. package/frontend/src/movie-app/components/EditorTabs/AgentTab.tsx +61 -0
  84. package/frontend/src/movie-app/components/EditorTabs/BatchImageToVideoTab.tsx +927 -0
  85. package/frontend/src/movie-app/components/EditorTabs/BatchVideoTab.tsx +777 -0
  86. package/frontend/src/movie-app/components/EditorTabs/BatchVoiceVideoTab.tsx +913 -0
  87. package/frontend/src/movie-app/components/EditorTabs/CharacterSheet.tsx +234 -0
  88. package/frontend/src/movie-app/components/EditorTabs/CharactersTab.tsx +664 -0
  89. package/frontend/src/movie-app/components/EditorTabs/CropTool.tsx +251 -0
  90. package/frontend/src/movie-app/components/EditorTabs/ExtendVideoTab.tsx +305 -0
  91. package/frontend/src/movie-app/components/EditorTabs/ExtractImageTab.tsx +249 -0
  92. package/frontend/src/movie-app/components/EditorTabs/FastImageEditTab.tsx +555 -0
  93. package/frontend/src/movie-app/components/EditorTabs/GenerateVideoTab.tsx +722 -0
  94. package/frontend/src/movie-app/components/EditorTabs/LlmServerTab.tsx +40 -0
  95. package/frontend/src/movie-app/components/EditorTabs/MlxVlmServerPanel.tsx +490 -0
  96. package/frontend/src/movie-app/components/EditorTabs/MovieStudioTab.tsx +938 -0
  97. package/frontend/src/movie-app/components/EditorTabs/ReferencesToVideoTab.tsx +881 -0
  98. package/frontend/src/movie-app/components/EditorTabs/SceneVisualTab.tsx +267 -0
  99. package/frontend/src/movie-app/components/EditorTabs/StoryWriterTab.tsx +51 -0
  100. package/frontend/src/movie-app/components/EditorTabs/TaskQueuePanel.tsx +246 -0
  101. package/frontend/src/movie-app/components/EditorTabs/TerminalLogPanel.tsx +49 -0
  102. package/frontend/src/movie-app/components/EditorTabs/TextToImageTab.tsx +574 -0
  103. package/frontend/src/movie-app/components/EditorTabs/VoiceChatPanel.tsx +320 -0
  104. package/frontend/src/movie-app/components/ProjectEditorPage.tsx +636 -0
  105. package/frontend/src/movie-app/components/ProjectManager.tsx +381 -0
  106. package/frontend/src/movie-app/index.css +46 -0
  107. package/frontend/src/movie-app/index.html +16 -0
  108. package/frontend/src/movie-app/lambobo.png +0 -0
  109. package/frontend/src/movie-app/lib/batchImageToVideoStorage.ts +69 -0
  110. package/frontend/src/movie-app/lib/batchVideoStorage.ts +68 -0
  111. package/frontend/src/movie-app/lib/batchVoiceStorage.ts +75 -0
  112. package/frontend/src/movie-app/lib/ffmpeg.ts +42 -0
  113. package/frontend/src/movie-app/lib/referencesToVideoStorage.ts +64 -0
  114. package/frontend/src/movie-app/lib/textToImageStorage.ts +61 -0
  115. package/frontend/src/movie-app/public/ffmpeg/ffmpeg-core.js +16 -0
  116. package/frontend/src/movie-app/public/ffmpeg/ffmpeg-core.wasm +0 -0
  117. package/frontend/src/movie-app/stores/batchImageToVideoStore.ts +736 -0
  118. package/frontend/src/movie-app/stores/batchVideoStore.ts +670 -0
  119. package/frontend/src/movie-app/stores/batchVoiceStore.ts +990 -0
  120. package/frontend/src/movie-app/stores/characterStore.ts +109 -0
  121. package/frontend/src/movie-app/stores/chatStore.ts +459 -0
  122. package/frontend/src/movie-app/stores/generationStore.ts +2140 -0
  123. package/frontend/src/movie-app/stores/logStore.ts +25 -0
  124. package/frontend/src/movie-app/stores/movieStudioStore.ts +709 -0
  125. package/frontend/src/movie-app/stores/projectStore.ts +124 -0
  126. package/frontend/src/movie-app/stores/queueStore.ts +214 -0
  127. package/frontend/src/movie-app/stores/referencesToVideoStore.ts +467 -0
  128. package/frontend/src/movie-app/stores/sceneVisualStore.ts +251 -0
  129. package/frontend/src/movie-app/stores/voiceChatStore.ts +170 -0
  130. package/frontend/src/movie-app/stores/workspaceStore.ts +158 -0
  131. package/frontend/src/vite-env.d.ts +1 -0
  132. package/frontend/tsconfig.json +19 -0
  133. package/package.json +65 -0
@@ -0,0 +1,574 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import {
3
+ useWorkspaceStore,
4
+ workspacePreviewUrl,
5
+ type WorkspaceFile,
6
+ } from "../stores/workspaceStore";
7
+
8
+ interface Props {
9
+ projectId: string;
10
+ }
11
+
12
+ function formatSize(bytes: number): string {
13
+ if (bytes < 1024) return `${bytes} B`;
14
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
15
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
16
+ }
17
+
18
+ export default function AgentWorkspace({ projectId }: Props) {
19
+ const ws = useWorkspaceStore();
20
+ const fileInputRef = useRef<HTMLInputElement>(null);
21
+ const listRef = useRef<HTMLUListElement>(null);
22
+
23
+ const [preview, setPreview] = useState<WorkspaceFile | null>(null);
24
+ const [previewContent, setPreviewContent] = useState<string | null>(null);
25
+ const [editing, setEditing] = useState<WorkspaceFile | null>(null);
26
+ const [editContent, setEditContent] = useState("");
27
+ const [renaming, setRenaming] = useState<string | null>(null);
28
+ const [renameValue, setRenameValue] = useState("");
29
+ const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
30
+
31
+ useEffect(() => {
32
+ ws.fetchFiles(projectId);
33
+ // eslint-disable-next-line react-hooks/exhaustive-deps
34
+ }, [projectId]);
35
+
36
+ useEffect(() => {
37
+ if (!preview || !listRef.current) return;
38
+ const index = ws.files.findIndex((f) => f.path === preview.path);
39
+ if (index < 0) return;
40
+ const items = listRef.current.querySelectorAll("li");
41
+ if (items[index]) items[index].scrollIntoView({ block: "nearest" });
42
+ // eslint-disable-next-line react-hooks/exhaustive-deps
43
+ }, [preview]);
44
+
45
+ // ========== Handlers ==========
46
+
47
+ const handleUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
48
+ const file = e.target.files?.[0];
49
+ if (!file) return;
50
+ const reader = new FileReader();
51
+ reader.onload = () => {
52
+ ws.uploadFile(projectId, reader.result as string, file.name);
53
+ };
54
+ reader.readAsDataURL(file);
55
+ e.target.value = "";
56
+ };
57
+
58
+ const handleSelect = async (f: WorkspaceFile) => {
59
+ setEditing(null);
60
+ setPreview(f);
61
+ if (f.kind === "text") {
62
+ const content = await ws.readFileContent(projectId, f.path);
63
+ setPreviewContent(content ?? "");
64
+ } else {
65
+ setPreviewContent(null);
66
+ }
67
+ };
68
+
69
+ const handleListKeyDown = (e: React.KeyboardEvent<HTMLUListElement>) => {
70
+ if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
71
+ const target = e.target as HTMLElement;
72
+ if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") return;
73
+ if (ws.files.length === 0) return;
74
+
75
+ e.preventDefault();
76
+ const currentIndex = preview
77
+ ? ws.files.findIndex((f) => f.path === preview.path)
78
+ : -1;
79
+ let nextIndex = e.key === "ArrowDown" ? currentIndex + 1 : currentIndex - 1;
80
+ if (nextIndex < 0) nextIndex = 0;
81
+ if (nextIndex >= ws.files.length) nextIndex = ws.files.length - 1;
82
+
83
+ if (nextIndex !== currentIndex) handleSelect(ws.files[nextIndex]);
84
+ };
85
+
86
+ const handleEdit = async (f: WorkspaceFile) => {
87
+ setPreview(null);
88
+ const content = await ws.readFileContent(projectId, f.path);
89
+ setEditing(f);
90
+ setEditContent(content ?? "");
91
+ };
92
+
93
+ const handleSave = async () => {
94
+ if (!editing) return;
95
+ await ws.writeFileContent(projectId, editing.path, editContent);
96
+ setEditing(null);
97
+ };
98
+
99
+ const handleRename = (f: WorkspaceFile) => {
100
+ setRenaming(f.path);
101
+ setRenameValue(f.name);
102
+ };
103
+
104
+ const confirmRename = async (f: WorkspaceFile) => {
105
+ const name = renameValue.trim();
106
+ // Only bare filenames — no separators, `..`, or leading dots.
107
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) {
108
+ setRenaming(null);
109
+ return;
110
+ }
111
+ const dir = f.path.slice(0, f.path.lastIndexOf("/") + 1);
112
+ await ws.renameFile(projectId, f.path, dir + name);
113
+ setRenaming(null);
114
+ };
115
+
116
+ const handleDelete = (f: WorkspaceFile) => {
117
+ setConfirmDelete(f.path);
118
+ };
119
+
120
+ const confirmDeleteAction = async () => {
121
+ if (!confirmDelete) return;
122
+ const path = confirmDelete;
123
+ await ws.removeFile(projectId, path);
124
+ setConfirmDelete(null);
125
+ if (preview?.path === path) {
126
+ setPreview(null);
127
+ setPreviewContent(null);
128
+ }
129
+ if (editing?.path === path) setEditing(null);
130
+ };
131
+
132
+ useEffect(() => {
133
+ if (!confirmDelete) return;
134
+ const onKey = (e: KeyboardEvent) => {
135
+ if (e.key === "Escape") {
136
+ e.preventDefault();
137
+ setConfirmDelete(null);
138
+ } else if (e.key === "Enter") {
139
+ e.preventDefault();
140
+ confirmDeleteAction();
141
+ }
142
+ };
143
+ window.addEventListener("keydown", onKey);
144
+ return () => window.removeEventListener("keydown", onKey);
145
+ // eslint-disable-next-line react-hooks/exhaustive-deps
146
+ }, [confirmDelete]);
147
+
148
+ // ========== SVG Icons ==========
149
+
150
+ const UploadIcon = (
151
+ <svg
152
+ width="14"
153
+ height="14"
154
+ viewBox="0 0 24 24"
155
+ fill="none"
156
+ stroke="currentColor"
157
+ strokeWidth="2"
158
+ strokeLinecap="round"
159
+ strokeLinejoin="round"
160
+ >
161
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
162
+ <polyline points="17 8 12 3 7 8" />
163
+ <line x1="12" y1="3" x2="12" y2="15" />
164
+ </svg>
165
+ );
166
+
167
+ const RefreshIcon = (
168
+ <svg
169
+ width="14"
170
+ height="14"
171
+ viewBox="0 0 24 24"
172
+ fill="none"
173
+ stroke="currentColor"
174
+ strokeWidth="2"
175
+ strokeLinecap="round"
176
+ strokeLinejoin="round"
177
+ >
178
+ <polyline points="23 4 23 10 17 10" />
179
+ <path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
180
+ </svg>
181
+ );
182
+
183
+ const FolderIcon = (
184
+ <svg
185
+ width="14"
186
+ height="14"
187
+ viewBox="0 0 24 24"
188
+ fill="none"
189
+ stroke="currentColor"
190
+ strokeWidth="2"
191
+ strokeLinecap="round"
192
+ strokeLinejoin="round"
193
+ >
194
+ <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
195
+ </svg>
196
+ );
197
+
198
+ const TrashIcon = (
199
+ <svg
200
+ width="14"
201
+ height="14"
202
+ viewBox="0 0 24 24"
203
+ fill="none"
204
+ stroke="currentColor"
205
+ strokeWidth="2"
206
+ strokeLinecap="round"
207
+ strokeLinejoin="round"
208
+ >
209
+ <polyline points="3 6 5 6 21 6" />
210
+ <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
211
+ </svg>
212
+ );
213
+
214
+ const PencilIcon = (
215
+ <svg
216
+ width="14"
217
+ height="14"
218
+ viewBox="0 0 24 24"
219
+ fill="none"
220
+ stroke="currentColor"
221
+ strokeWidth="2"
222
+ strokeLinecap="round"
223
+ strokeLinejoin="round"
224
+ >
225
+ <path d="M12 20h9" />
226
+ <path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
227
+ </svg>
228
+ );
229
+
230
+ const FileIcon = (
231
+ <svg
232
+ width="14"
233
+ height="14"
234
+ viewBox="0 0 24 24"
235
+ fill="none"
236
+ stroke="currentColor"
237
+ strokeWidth="2"
238
+ strokeLinecap="round"
239
+ strokeLinejoin="round"
240
+ >
241
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
242
+ <polyline points="14 2 14 8 20 8" />
243
+ </svg>
244
+ );
245
+
246
+ const ImageIcon = (
247
+ <svg
248
+ width="14"
249
+ height="14"
250
+ viewBox="0 0 24 24"
251
+ fill="none"
252
+ stroke="currentColor"
253
+ strokeWidth="2"
254
+ strokeLinecap="round"
255
+ strokeLinejoin="round"
256
+ >
257
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
258
+ <circle cx="8.5" cy="8.5" r="1.5" />
259
+ <polyline points="21 15 16 10 5 21" />
260
+ </svg>
261
+ );
262
+
263
+ const VideoIcon = (
264
+ <svg
265
+ width="14"
266
+ height="14"
267
+ viewBox="0 0 24 24"
268
+ fill="none"
269
+ stroke="currentColor"
270
+ strokeWidth="2"
271
+ strokeLinecap="round"
272
+ strokeLinejoin="round"
273
+ >
274
+ <polygon points="23 7 16 12 23 17 23 7" />
275
+ <rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
276
+ </svg>
277
+ );
278
+
279
+ const CloseIcon = (
280
+ <svg
281
+ width="14"
282
+ height="14"
283
+ viewBox="0 0 24 24"
284
+ fill="none"
285
+ stroke="currentColor"
286
+ strokeWidth="2"
287
+ strokeLinecap="round"
288
+ strokeLinejoin="round"
289
+ >
290
+ <line x1="18" y1="6" x2="6" y2="18" />
291
+ <line x1="6" y1="6" x2="18" y2="18" />
292
+ </svg>
293
+ );
294
+
295
+ const kindIcon = (f: WorkspaceFile) => {
296
+ if (f.kind === "image")
297
+ return <span className="text-tiffany-600">{ImageIcon}</span>;
298
+ if (f.kind === "video")
299
+ return <span className="text-tiffany-600">{VideoIcon}</span>;
300
+ if (f.kind === "text")
301
+ return <span className="text-tiffany-600">{FileIcon}</span>;
302
+ return <span className="text-ink-500">{FileIcon}</span>;
303
+ };
304
+
305
+ return (
306
+ <div className="flex flex-col gap-3">
307
+ {/* Header + upload */}
308
+ <div className="flex items-center justify-between">
309
+ <label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider">
310
+ Agent Workspace
311
+ </label>
312
+ <div className="flex items-center gap-2">
313
+ <button
314
+ onClick={() => ws.fetchFiles(projectId)}
315
+ title="Refresh"
316
+ className="flex items-center justify-center w-8 h-8 rounded-xl border transition-all bg-white border-ink-200 text-ink-600 hover:border-ink-300"
317
+ >
318
+ {RefreshIcon}
319
+ </button>
320
+ <button
321
+ onClick={() => ws.openWorkspace(projectId)}
322
+ title="Open workspace folder"
323
+ className="flex items-center justify-center w-8 h-8 rounded-xl border transition-all bg-white border-ink-200 text-ink-600 hover:border-ink-300"
324
+ >
325
+ {FolderIcon}
326
+ </button>
327
+ <button
328
+ onClick={() => fileInputRef.current?.click()}
329
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-xl border transition-all bg-white border-ink-200 text-ink-600 hover:border-ink-300"
330
+ >
331
+ {UploadIcon}
332
+ Upload File
333
+ </button>
334
+ </div>
335
+ <input
336
+ ref={fileInputRef}
337
+ type="file"
338
+ accept="image/*,video/*,text/*,.csv,.md,.txt,.json,.log"
339
+ onChange={handleUpload}
340
+ className="hidden"
341
+ />
342
+ </div>
343
+
344
+ {ws.error && (
345
+ <div className="p-2 bg-red-50 border border-red-200 rounded-xl text-red-600 text-xs">
346
+ {ws.error}
347
+ </div>
348
+ )}
349
+
350
+ <div className="flex gap-3 items-start">
351
+ {/* Left column: file list + editor */}
352
+ <div className="flex-1 min-w-0 flex flex-col gap-3">
353
+ {/* File list */}
354
+ <div className="border border-ink-200 rounded-2xl overflow-hidden">
355
+ <div className="max-h-64 overflow-y-auto">
356
+ {ws.loading ? (
357
+ <p className="text-xs text-ink-500 italic text-center py-6">
358
+ Loading files...
359
+ </p>
360
+ ) : ws.files.length === 0 ? (
361
+ <p className="text-xs text-ink-500 italic text-center py-6">
362
+ No files yet. Upload a file or let the agent save a memory.
363
+ </p>
364
+ ) : (
365
+ <ul
366
+ ref={listRef}
367
+ tabIndex={0}
368
+ onKeyDown={handleListKeyDown}
369
+ className="divide-y divide-ink-200 outline-none focus:ring-2 focus:ring-inset focus:ring-tiffany-500"
370
+ >
371
+ {ws.files.map((f) => (
372
+ <li
373
+ key={f.path}
374
+ onClick={() => handleSelect(f)}
375
+ className={`flex items-center gap-2 px-3 py-2 hover:bg-ink-100/60 transition-colors cursor-pointer ${
376
+ preview?.path === f.path ? "bg-ink-100/60" : ""
377
+ }`}
378
+ >
379
+ {kindIcon(f)}
380
+ <div className="flex-1 min-w-0">
381
+ {renaming === f.path ? (
382
+ <input
383
+ type="text"
384
+ value={renameValue}
385
+ onChange={(e) => setRenameValue(e.target.value)}
386
+ onClick={(e) => e.stopPropagation()}
387
+ onKeyDown={(e) => {
388
+ if (e.key === "Enter") confirmRename(f);
389
+ if (e.key === "Escape") setRenaming(null);
390
+ }}
391
+ autoFocus
392
+ className="w-full px-2 py-1 bg-white border border-ink-300 rounded text-xs text-ink-900 focus:outline-none focus:ring-1 focus:ring-tiffany-500"
393
+ />
394
+ ) : (
395
+ <p
396
+ className="text-xs text-ink-800 truncate"
397
+ title={f.path}
398
+ >
399
+ {f.path}
400
+ </p>
401
+ )}
402
+ <p className="text-[10px] text-ink-500">
403
+ {f.kind} · {formatSize(f.size)}
404
+ </p>
405
+ </div>
406
+
407
+ {renaming === f.path ? (
408
+ <button
409
+ onClick={(e) => {
410
+ e.stopPropagation();
411
+ confirmRename(f);
412
+ }}
413
+ className="px-2 py-1 text-[10px] font-medium text-emerald-600 hover:bg-emerald-50 rounded transition-colors"
414
+ >
415
+ Save
416
+ </button>
417
+ ) : (
418
+ <>
419
+ {f.kind === "text" && (
420
+ <button
421
+ onClick={(e) => {
422
+ e.stopPropagation();
423
+ handleEdit(f);
424
+ }}
425
+ className="p-1.5 text-tiffany-600 hover:bg-ink-200 rounded transition-colors"
426
+ title="Edit"
427
+ >
428
+ {PencilIcon}
429
+ </button>
430
+ )}
431
+ <button
432
+ onClick={(e) => {
433
+ e.stopPropagation();
434
+ handleRename(f);
435
+ }}
436
+ className="p-1.5 text-tiffany-600 hover:bg-ink-200 rounded transition-colors"
437
+ title="Rename"
438
+ >
439
+ {PencilIcon}
440
+ </button>
441
+ <button
442
+ onClick={(e) => {
443
+ e.stopPropagation();
444
+ handleDelete(f);
445
+ }}
446
+ className="p-1.5 text-red-400 hover:bg-red-50 rounded transition-colors"
447
+ title="Delete"
448
+ >
449
+ {TrashIcon}
450
+ </button>
451
+ </>
452
+ )}
453
+ </li>
454
+ ))}
455
+ </ul>
456
+ )}
457
+ </div>
458
+ </div>
459
+
460
+ {/* Editor panel */}
461
+ {editing && (
462
+ <div className="border border-ink-200 rounded-2xl overflow-hidden">
463
+ <div className="flex items-center justify-between px-3 py-2 bg-ink-50 border-b border-ink-200">
464
+ <span className="text-xs font-medium text-ink-700 truncate">
465
+ Editing {editing.path}
466
+ </span>
467
+ <div className="flex items-center gap-1.5">
468
+ <button
469
+ onClick={handleSave}
470
+ className="px-2 py-1 text-[10px] font-medium text-emerald-600 hover:bg-emerald-50 rounded transition-colors"
471
+ >
472
+ Save
473
+ </button>
474
+ <button
475
+ onClick={() => setEditing(null)}
476
+ className="p-1 text-tiffany-600 hover:bg-ink-200 rounded transition-colors"
477
+ title="Close"
478
+ >
479
+ {CloseIcon}
480
+ </button>
481
+ </div>
482
+ </div>
483
+ <textarea
484
+ value={editContent}
485
+ onChange={(e) => setEditContent(e.target.value)}
486
+ rows={10}
487
+ className="w-full px-3 py-2 bg-white text-ink-900 text-xs font-mono focus:outline-none resize-y"
488
+ />
489
+ </div>
490
+ )}
491
+ </div>
492
+
493
+ {/* Right column: preview pane */}
494
+ <div className="w-72 shrink-0">
495
+ <div className="border border-ink-200 rounded-2xl overflow-hidden">
496
+ <div className="flex items-center justify-between px-3 py-2 bg-ink-50 border-b border-ink-200">
497
+ <span className="text-xs font-medium text-ink-700 truncate">
498
+ {preview ? preview.path : "Preview"}
499
+ </span>
500
+ {preview && (
501
+ <button
502
+ onClick={() => {
503
+ setPreview(null);
504
+ setPreviewContent(null);
505
+ }}
506
+ className="p-1 text-tiffany-600 hover:bg-ink-200 rounded transition-colors"
507
+ title="Close"
508
+ >
509
+ {CloseIcon}
510
+ </button>
511
+ )}
512
+ </div>
513
+ <div className="p-2 flex justify-center bg-ink-100/60">
514
+ {!preview ? (
515
+ <p className="text-xs text-ink-500 italic text-center py-10">
516
+ Select a file to preview
517
+ </p>
518
+ ) : preview.kind === "image" ? (
519
+ <img
520
+ src={workspacePreviewUrl(projectId, preview.path)}
521
+ alt={preview.name}
522
+ className="max-h-72 max-w-full object-contain"
523
+ />
524
+ ) : preview.kind === "video" ? (
525
+ <video
526
+ src={workspacePreviewUrl(projectId, preview.path)}
527
+ controls
528
+ className="max-h-72 max-w-full"
529
+ />
530
+ ) : preview.kind === "text" ? (
531
+ <pre className="w-full max-h-72 overflow-auto p-2 bg-white rounded text-[11px] text-ink-800 whitespace-pre-wrap break-words">
532
+ {previewContent ?? ""}
533
+ </pre>
534
+ ) : (
535
+ <p className="text-xs text-ink-500 italic text-center py-10">
536
+ No preview available
537
+ </p>
538
+ )}
539
+ </div>
540
+ </div>
541
+ </div>
542
+ </div>
543
+
544
+ {/* Delete confirmation modal */}
545
+ {confirmDelete && (
546
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
547
+ <div className="bg-white rounded-3xl shadow-card p-6 w-80">
548
+ <h3 className="text-sm font-semibold text-ink-900 mb-2">
549
+ Delete File
550
+ </h3>
551
+ <p className="text-xs text-ink-600 mb-4">
552
+ Are you sure you want to delete "{confirmDelete}"? This action
553
+ cannot be undone.
554
+ </p>
555
+ <div className="flex justify-end gap-2">
556
+ <button
557
+ onClick={() => setConfirmDelete(null)}
558
+ className="px-3 py-2 text-xs font-medium rounded-xl border border-ink-200 text-ink-600 hover:bg-ink-100 transition-colors"
559
+ >
560
+ Cancel
561
+ </button>
562
+ <button
563
+ onClick={confirmDeleteAction}
564
+ className="px-3 py-2 text-xs font-medium rounded-xl bg-red-500 hover:bg-red-600 text-white transition-colors"
565
+ >
566
+ Delete
567
+ </button>
568
+ </div>
569
+ </div>
570
+ </div>
571
+ )}
572
+ </div>
573
+ );
574
+ }