groove-dev 0.27.153 → 0.27.154
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/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/journalist.js +52 -21
- package/node_modules/@groove-dev/daemon/src/keeper.js +37 -2
- package/node_modules/@groove-dev/daemon/src/routes/coordination.js +16 -0
- package/node_modules/@groove-dev/daemon/src/routes/files.js +71 -2
- package/node_modules/@groove-dev/gui/dist/assets/index-BTLb6zTD.js +1015 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-Diw6wDPU.css +1 -0
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +252 -44
- package/node_modules/@groove-dev/gui/src/components/agents/agent-file-tree.jsx +51 -3
- package/node_modules/@groove-dev/gui/src/components/editor/file-tree.jsx +40 -3
- package/node_modules/@groove-dev/gui/src/stores/groove.js +9 -1
- package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +24 -5
- package/node_modules/@groove-dev/gui/src/views/memory.jsx +87 -44
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/journalist.js +52 -21
- package/packages/daemon/src/keeper.js +37 -2
- package/packages/daemon/src/routes/coordination.js +16 -0
- package/packages/daemon/src/routes/files.js +71 -2
- package/packages/gui/dist/assets/index-BTLb6zTD.js +1015 -0
- package/packages/gui/dist/assets/index-Diw6wDPU.css +1 -0
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/components/agents/agent-feed.jsx +252 -44
- package/packages/gui/src/components/agents/agent-file-tree.jsx +51 -3
- package/packages/gui/src/components/editor/file-tree.jsx +40 -3
- package/packages/gui/src/stores/groove.js +9 -1
- package/packages/gui/src/stores/slices/agents-slice.js +24 -5
- package/packages/gui/src/views/memory.jsx +87 -44
- package/node_modules/@groove-dev/gui/dist/assets/index-BU_YTEZo.js +0 -1011
- package/node_modules/@groove-dev/gui/dist/assets/index-ChfYTsyc.css +0 -1
- package/packages/gui/dist/assets/index-BU_YTEZo.js +0 -1011
- package/packages/gui/dist/assets/index-ChfYTsyc.css +0 -1
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
FileEdit, Search, Terminal, CheckCircle2, AlertCircle,
|
|
6
6
|
RotateCw, Zap, Wrench, Eye, Code2, Bug,
|
|
7
7
|
ChevronDown, Paperclip, GripHorizontal,
|
|
8
|
-
FileCode, X,
|
|
8
|
+
FileCode, X, File, Image as ImageIcon, Film, Upload,
|
|
9
9
|
} from 'lucide-react';
|
|
10
10
|
import { AnimatePresence, motion } from 'framer-motion';
|
|
11
11
|
import { useGrooveStore } from '../../stores/groove';
|
|
@@ -231,6 +231,94 @@ function FormattedText({ text }) {
|
|
|
231
231
|
);
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
+
// ── Lightbox ────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
function Lightbox({ src, type, onClose }) {
|
|
237
|
+
useEffect(() => {
|
|
238
|
+
function onKey(e) { if (e.key === 'Escape') onClose(); }
|
|
239
|
+
window.addEventListener('keydown', onKey);
|
|
240
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
241
|
+
}, [onClose]);
|
|
242
|
+
|
|
243
|
+
return (
|
|
244
|
+
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/80 backdrop-blur-sm" onClick={onClose}>
|
|
245
|
+
<button onClick={onClose} className="absolute top-4 right-4 p-2 text-white/70 hover:text-white cursor-pointer z-10">
|
|
246
|
+
<X size={20} />
|
|
247
|
+
</button>
|
|
248
|
+
<div className="max-w-[90vw] max-h-[90vh]" onClick={(e) => e.stopPropagation()}>
|
|
249
|
+
{type === 'video' ? (
|
|
250
|
+
<video src={src} controls autoPlay className="max-w-full max-h-[85vh] rounded-lg" />
|
|
251
|
+
) : (
|
|
252
|
+
<img src={src} className="max-w-full max-h-[85vh] rounded-lg object-contain" alt="" />
|
|
253
|
+
)}
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ── Attachment display in messages ──────────────────────────
|
|
260
|
+
|
|
261
|
+
function AttachmentGrid({ attachments }) {
|
|
262
|
+
const [lightbox, setLightbox] = useState(null);
|
|
263
|
+
if (!attachments || attachments.length === 0) return null;
|
|
264
|
+
|
|
265
|
+
const images = attachments.filter((a) => a.type === 'image');
|
|
266
|
+
const videos = attachments.filter((a) => a.type === 'video');
|
|
267
|
+
const files = attachments.filter((a) => a.type === 'file');
|
|
268
|
+
|
|
269
|
+
return (
|
|
270
|
+
<>
|
|
271
|
+
{lightbox && <Lightbox src={lightbox.src} type={lightbox.type} onClose={() => setLightbox(null)} />}
|
|
272
|
+
{(images.length > 0 || videos.length > 0) && (
|
|
273
|
+
<div className="flex flex-wrap gap-1.5 mt-2">
|
|
274
|
+
{images.map((att, i) => (
|
|
275
|
+
<button
|
|
276
|
+
key={i}
|
|
277
|
+
onClick={() => setLightbox({ src: att.dataUrl, type: 'image' })}
|
|
278
|
+
className="relative group rounded-md overflow-hidden border border-border-subtle hover:border-accent/40 transition-colors cursor-pointer flex-shrink-0"
|
|
279
|
+
>
|
|
280
|
+
<img src={att.dataUrl} className="w-16 h-16 object-cover" alt={att.name} />
|
|
281
|
+
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
|
|
282
|
+
<Eye size={14} className="text-white opacity-0 group-hover:opacity-100 transition-opacity" />
|
|
283
|
+
</div>
|
|
284
|
+
</button>
|
|
285
|
+
))}
|
|
286
|
+
{videos.map((att, i) => (
|
|
287
|
+
<button
|
|
288
|
+
key={i}
|
|
289
|
+
onClick={() => setLightbox({ src: att.dataUrl, type: 'video' })}
|
|
290
|
+
className="relative group rounded-md overflow-hidden border border-border-subtle hover:border-accent/40 transition-colors cursor-pointer flex-shrink-0"
|
|
291
|
+
>
|
|
292
|
+
<video src={att.dataUrl} className="w-16 h-16 object-cover" muted />
|
|
293
|
+
<div className="absolute inset-0 bg-black/30 group-hover:bg-black/40 transition-colors flex items-center justify-center">
|
|
294
|
+
<Film size={14} className="text-white" />
|
|
295
|
+
</div>
|
|
296
|
+
</button>
|
|
297
|
+
))}
|
|
298
|
+
</div>
|
|
299
|
+
)}
|
|
300
|
+
{files.length > 0 && (
|
|
301
|
+
<div className="flex flex-wrap gap-1.5 mt-2">
|
|
302
|
+
{files.map((att, i) => (
|
|
303
|
+
<div key={i} className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-surface-3 border border-border-subtle">
|
|
304
|
+
<File size={11} className="text-text-3 flex-shrink-0" />
|
|
305
|
+
<span className="text-[11px] font-sans text-text-1 truncate max-w-[140px]">{att.name}</span>
|
|
306
|
+
<span className="text-[10px] font-mono text-text-4">{formatFileSize(att.size)}</span>
|
|
307
|
+
</div>
|
|
308
|
+
))}
|
|
309
|
+
</div>
|
|
310
|
+
)}
|
|
311
|
+
</>
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function formatFileSize(bytes) {
|
|
316
|
+
if (!bytes) return '';
|
|
317
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
318
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`;
|
|
319
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
320
|
+
}
|
|
321
|
+
|
|
234
322
|
// ── Message components ───────────────────────────────────────
|
|
235
323
|
|
|
236
324
|
function UserMessage({ msg }) {
|
|
@@ -238,9 +326,12 @@ function UserMessage({ msg }) {
|
|
|
238
326
|
<div className="flex justify-end pl-8">
|
|
239
327
|
<div className="max-w-[90%]">
|
|
240
328
|
<div className="px-3.5 py-2.5 rounded-lg border bg-info/10 border-info/25">
|
|
241
|
-
|
|
242
|
-
<
|
|
243
|
-
|
|
329
|
+
{msg.text && (
|
|
330
|
+
<div className="text-[12px] font-sans whitespace-pre-wrap break-words leading-relaxed text-text-0">
|
|
331
|
+
<FormattedText text={msg.text} />
|
|
332
|
+
</div>
|
|
333
|
+
)}
|
|
334
|
+
<AttachmentGrid attachments={msg.attachments} />
|
|
244
335
|
</div>
|
|
245
336
|
<div className="text-[10px] text-text-4 font-sans mt-1 text-right">{timeAgo(msg.timestamp)}</div>
|
|
246
337
|
</div>
|
|
@@ -536,6 +627,9 @@ export function AgentFeed({ agent }) {
|
|
|
536
627
|
const [sending, setSending] = useState(false);
|
|
537
628
|
const [inputHeight, setInputHeight] = useState(88);
|
|
538
629
|
const [providerModels, setProviderModels] = useState([]);
|
|
630
|
+
const [pendingFiles, setPendingFiles] = useState([]);
|
|
631
|
+
const [uploading, setUploading] = useState(false);
|
|
632
|
+
const [dragOver, setDragOver] = useState(false);
|
|
539
633
|
const dragRef = useRef(null);
|
|
540
634
|
const scrollRef = useRef(null);
|
|
541
635
|
const inputRef = useRef(null);
|
|
@@ -630,40 +724,75 @@ export function AgentFeed({ agent }) {
|
|
|
630
724
|
}
|
|
631
725
|
}, [timeline.length, sending, isThinking]);
|
|
632
726
|
|
|
727
|
+
function getFileType(file) {
|
|
728
|
+
if (file.type.startsWith('image/')) return 'image';
|
|
729
|
+
if (file.type.startsWith('video/')) return 'video';
|
|
730
|
+
return 'file';
|
|
731
|
+
}
|
|
732
|
+
|
|
633
733
|
async function handleFileSelect(e) {
|
|
634
|
-
const files = Array.from(e.target.files || []);
|
|
734
|
+
const files = Array.from(e.target.files || e.dataTransfer?.files || []);
|
|
635
735
|
if (files.length === 0) return;
|
|
636
|
-
const addToast = useGrooveStore.getState().addToast;
|
|
637
736
|
|
|
638
|
-
const
|
|
737
|
+
const newPending = [];
|
|
639
738
|
for (const file of files) {
|
|
739
|
+
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
740
|
+
const type = getFileType(file);
|
|
741
|
+
const entry = { id, name: file.name, size: file.size, type, status: 'reading', file };
|
|
742
|
+
|
|
743
|
+
if (type === 'image' || type === 'video') {
|
|
744
|
+
const dataUrl = await new Promise((resolve) => {
|
|
745
|
+
const reader = new FileReader();
|
|
746
|
+
reader.onload = () => resolve(reader.result);
|
|
747
|
+
reader.onerror = () => resolve(null);
|
|
748
|
+
reader.readAsDataURL(file);
|
|
749
|
+
});
|
|
750
|
+
entry.dataUrl = dataUrl;
|
|
751
|
+
}
|
|
752
|
+
entry.status = 'pending';
|
|
753
|
+
newPending.push(entry);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
setPendingFiles((prev) => [...prev, ...newPending]);
|
|
757
|
+
if (e.target?.value != null) e.target.value = '';
|
|
758
|
+
inputRef.current?.focus();
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function removePendingFile(id) {
|
|
762
|
+
setPendingFiles((prev) => prev.filter((f) => f.id !== id));
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
async function uploadPendingFiles() {
|
|
766
|
+
if (pendingFiles.length === 0) return [];
|
|
767
|
+
setUploading(true);
|
|
768
|
+
const addToast = useGrooveStore.getState().addToast;
|
|
769
|
+
const results = [];
|
|
770
|
+
|
|
771
|
+
for (const pf of pendingFiles) {
|
|
772
|
+
setPendingFiles((prev) => prev.map((f) => f.id === pf.id ? { ...f, status: 'uploading' } : f));
|
|
640
773
|
try {
|
|
641
774
|
const base64 = await new Promise((resolve, reject) => {
|
|
642
775
|
const reader = new FileReader();
|
|
643
|
-
reader.onload = () => resolve(reader.result.split(',')[1]);
|
|
776
|
+
reader.onload = () => resolve(reader.result.split(',')[1]);
|
|
644
777
|
reader.onerror = reject;
|
|
645
|
-
reader.readAsDataURL(file);
|
|
778
|
+
reader.readAsDataURL(pf.file);
|
|
646
779
|
});
|
|
647
|
-
await api.post(`/agents/${agent.id}/upload`, { filename:
|
|
648
|
-
|
|
780
|
+
await api.post(`/agents/${agent.id}/upload`, { filename: pf.name, content: base64 });
|
|
781
|
+
setPendingFiles((prev) => prev.map((f) => f.id === pf.id ? { ...f, status: 'done' } : f));
|
|
782
|
+
results.push({ name: pf.name, size: pf.size, type: pf.type, dataUrl: pf.dataUrl || null });
|
|
649
783
|
} catch (err) {
|
|
650
|
-
|
|
784
|
+
setPendingFiles((prev) => prev.map((f) => f.id === pf.id ? { ...f, status: 'error' } : f));
|
|
785
|
+
addToast('error', `Upload failed: ${pf.name}`, err.message);
|
|
651
786
|
}
|
|
652
787
|
}
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
const names = uploaded.join(', ');
|
|
656
|
-
setInput((prev) => (prev ? prev + '\n' : '') + `[Uploaded: ${names}] — I've uploaded these files to your working directory. Read them and use their content.`);
|
|
657
|
-
addToast('success', `Uploaded ${uploaded.length} file${uploaded.length > 1 ? 's' : ''}`);
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
e.target.value = '';
|
|
661
|
-
inputRef.current?.focus();
|
|
788
|
+
setUploading(false);
|
|
789
|
+
return results;
|
|
662
790
|
}
|
|
663
791
|
|
|
664
792
|
async function handleSend() {
|
|
665
793
|
const text = input.trim();
|
|
666
|
-
|
|
794
|
+
const hasFiles = pendingFiles.length > 0;
|
|
795
|
+
if ((!text && !pendingSnippet && !hasFiles) || sending) return;
|
|
667
796
|
|
|
668
797
|
if (text === '/rotate') {
|
|
669
798
|
const rotateAgent = useGrooveStore.getState().rotateAgent;
|
|
@@ -672,6 +801,14 @@ export function AgentFeed({ agent }) {
|
|
|
672
801
|
return;
|
|
673
802
|
}
|
|
674
803
|
|
|
804
|
+
setSending(true);
|
|
805
|
+
isAtBottomRef.current = true;
|
|
806
|
+
|
|
807
|
+
let uploadedAttachments = [];
|
|
808
|
+
if (hasFiles) {
|
|
809
|
+
uploadedAttachments = await uploadPendingFiles();
|
|
810
|
+
}
|
|
811
|
+
|
|
675
812
|
const parts = [];
|
|
676
813
|
if (text) parts.push(text);
|
|
677
814
|
if (pendingSnippet) {
|
|
@@ -684,17 +821,24 @@ export function AgentFeed({ agent }) {
|
|
|
684
821
|
parts.push('```\n' + s.code + '\n```');
|
|
685
822
|
}
|
|
686
823
|
}
|
|
824
|
+
|
|
825
|
+
if (uploadedAttachments.length > 0) {
|
|
826
|
+
const names = uploadedAttachments.map((a) => a.name).join(', ');
|
|
827
|
+
parts.push(`[Uploaded: ${names}] — I've uploaded these files to your working directory. Read them and use their content.`);
|
|
828
|
+
}
|
|
829
|
+
|
|
687
830
|
const message = parts.join('\n\n');
|
|
688
831
|
|
|
832
|
+
const attachments = uploadedAttachments.length > 0 ? uploadedAttachments : undefined;
|
|
689
833
|
setInput('');
|
|
690
834
|
clearSnippet();
|
|
691
|
-
|
|
692
|
-
|
|
835
|
+
setPendingFiles([]);
|
|
836
|
+
|
|
693
837
|
requestAnimationFrame(() => {
|
|
694
838
|
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
695
839
|
});
|
|
696
840
|
try {
|
|
697
|
-
await instructAgent(agent.id, message);
|
|
841
|
+
await instructAgent(agent.id, message, attachments);
|
|
698
842
|
} catch { /* toast handles */ }
|
|
699
843
|
setSending(false);
|
|
700
844
|
inputRef.current?.focus();
|
|
@@ -785,22 +929,89 @@ export function AgentFeed({ agent }) {
|
|
|
785
929
|
);
|
|
786
930
|
})()}
|
|
787
931
|
|
|
788
|
-
<div
|
|
932
|
+
<div
|
|
933
|
+
className={cn(
|
|
934
|
+
'flex flex-col rounded-lg border bg-surface-0 transition-colors overflow-hidden focus-within:border-text-4/40',
|
|
935
|
+
dragOver ? 'border-accent border-dashed bg-accent/[0.03]' : 'border-border-subtle',
|
|
936
|
+
)}
|
|
937
|
+
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
|
938
|
+
onDragLeave={() => setDragOver(false)}
|
|
939
|
+
onDrop={(e) => {
|
|
940
|
+
e.preventDefault();
|
|
941
|
+
setDragOver(false);
|
|
942
|
+
if (e.dataTransfer?.files?.length) handleFileSelect(e);
|
|
943
|
+
}}
|
|
944
|
+
>
|
|
789
945
|
<input
|
|
790
946
|
ref={fileInputRef}
|
|
791
947
|
type="file"
|
|
792
948
|
multiple
|
|
793
|
-
accept="
|
|
949
|
+
accept="image/*,video/*,.pdf,.csv,.txt,.md,.json,.yaml,.yml,.docx,.pptx,.xlsx,.xml,.html,.py,.js,.ts,.jsx,.tsx,.go,.rs,.c,.cpp,.h,.java,.rb,.sh,.sql,.log"
|
|
794
950
|
onChange={handleFileSelect}
|
|
795
951
|
className="hidden"
|
|
796
952
|
/>
|
|
797
|
-
|
|
953
|
+
|
|
954
|
+
{/* Drag overlay */}
|
|
955
|
+
{dragOver && (
|
|
956
|
+
<div className="flex items-center justify-center gap-2 py-3 text-accent">
|
|
957
|
+
<Upload size={16} />
|
|
958
|
+
<span className="text-xs font-sans font-medium">Drop files here</span>
|
|
959
|
+
</div>
|
|
960
|
+
)}
|
|
961
|
+
|
|
962
|
+
{/* Pending file previews */}
|
|
963
|
+
{pendingFiles.length > 0 && (
|
|
964
|
+
<div className="flex flex-wrap gap-1.5 px-3 pt-2.5 pb-1">
|
|
965
|
+
{pendingFiles.map((pf) => (
|
|
966
|
+
<div key={pf.id} className={cn(
|
|
967
|
+
'relative group flex items-center gap-1.5 rounded-md border overflow-hidden',
|
|
968
|
+
pf.status === 'error' ? 'border-danger/30 bg-danger/5' : 'border-border-subtle bg-surface-2',
|
|
969
|
+
pf.status === 'uploading' && 'opacity-70',
|
|
970
|
+
)}>
|
|
971
|
+
{pf.type === 'image' && pf.dataUrl ? (
|
|
972
|
+
<img src={pf.dataUrl} className="w-12 h-12 object-cover" alt={pf.name} />
|
|
973
|
+
) : pf.type === 'video' && pf.dataUrl ? (
|
|
974
|
+
<div className="relative w-12 h-12">
|
|
975
|
+
<video src={pf.dataUrl} className="w-12 h-12 object-cover" muted />
|
|
976
|
+
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
|
|
977
|
+
<Film size={12} className="text-white" />
|
|
978
|
+
</div>
|
|
979
|
+
</div>
|
|
980
|
+
) : (
|
|
981
|
+
<div className="flex items-center gap-1.5 px-2 py-1.5">
|
|
982
|
+
<File size={12} className="text-text-3 flex-shrink-0" />
|
|
983
|
+
<span className="text-[11px] font-sans text-text-1 truncate max-w-[100px]">{pf.name}</span>
|
|
984
|
+
<span className="text-[10px] font-mono text-text-4">{formatFileSize(pf.size)}</span>
|
|
985
|
+
</div>
|
|
986
|
+
)}
|
|
987
|
+
{pf.status === 'uploading' && (
|
|
988
|
+
<div className="absolute inset-0 flex items-center justify-center bg-surface-0/60">
|
|
989
|
+
<Loader2 size={14} className="text-accent animate-spin" />
|
|
990
|
+
</div>
|
|
991
|
+
)}
|
|
992
|
+
{pf.status === 'error' && (
|
|
993
|
+
<div className="absolute inset-0 flex items-center justify-center bg-danger/10">
|
|
994
|
+
<AlertCircle size={14} className="text-danger" />
|
|
995
|
+
</div>
|
|
996
|
+
)}
|
|
997
|
+
<button
|
|
998
|
+
onClick={() => removePendingFile(pf.id)}
|
|
999
|
+
className="absolute top-0.5 right-0.5 w-4 h-4 flex items-center justify-center rounded-full bg-surface-0/80 text-text-4 hover:text-danger opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
|
|
1000
|
+
>
|
|
1001
|
+
<X size={9} />
|
|
1002
|
+
</button>
|
|
1003
|
+
</div>
|
|
1004
|
+
))}
|
|
1005
|
+
</div>
|
|
1006
|
+
)}
|
|
1007
|
+
|
|
1008
|
+
{/* Textarea */}
|
|
798
1009
|
<div className="relative px-1">
|
|
799
1010
|
{input && KEEPER_DETECT_RE.test(input) && (
|
|
800
1011
|
<div
|
|
801
1012
|
ref={highlightRef}
|
|
802
1013
|
aria-hidden
|
|
803
|
-
className="absolute inset-0 px-3 py-2.5 text-[13px] leading-[20px] font-sans pointer-events-none whitespace-pre-wrap break-words overflow-
|
|
1014
|
+
className="absolute inset-y-0 left-1 right-1 px-3 py-2.5 text-[13px] leading-[20px] font-sans pointer-events-none whitespace-pre-wrap break-words overflow-hidden"
|
|
804
1015
|
style={{ height: inputHeight }}
|
|
805
1016
|
>
|
|
806
1017
|
{highlightKeeperInput(input)}
|
|
@@ -812,22 +1023,13 @@ export function AgentFeed({ agent }) {
|
|
|
812
1023
|
onChange={(e) => setInput(e.target.value)}
|
|
813
1024
|
onKeyDown={onKeyDown}
|
|
814
1025
|
onScroll={(e) => { if (highlightRef.current) highlightRef.current.scrollTop = e.target.scrollTop; }}
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
e.preventDefault();
|
|
818
|
-
if (e.dataTransfer?.files?.length) {
|
|
819
|
-
const dt = new DataTransfer();
|
|
820
|
-
for (const f of e.dataTransfer.files) dt.items.add(f);
|
|
821
|
-
fileInputRef.current.files = dt.files;
|
|
822
|
-
fileInputRef.current.dispatchEvent(new Event('change', { bubbles: true }));
|
|
823
|
-
}
|
|
824
|
-
}}
|
|
825
|
-
placeholder={pendingSnippet ? 'Add a message (optional)...'
|
|
1026
|
+
placeholder={pendingFiles.length > 0 ? 'Add a message (optional)...'
|
|
1027
|
+
: pendingSnippet ? 'Add a message (optional)...'
|
|
826
1028
|
: isAlive ? 'Send an instruction...' : 'Continue this session...'}
|
|
827
1029
|
rows={1}
|
|
828
1030
|
className={cn(
|
|
829
1031
|
'w-full resize-none px-3 py-2.5 text-[13px] leading-[20px]',
|
|
830
|
-
'bg-transparent font-sans relative z-10',
|
|
1032
|
+
'bg-transparent font-sans relative z-10 whitespace-pre-wrap break-words',
|
|
831
1033
|
'placeholder:text-text-4',
|
|
832
1034
|
'focus:outline-none',
|
|
833
1035
|
input && KEEPER_DETECT_RE.test(input)
|
|
@@ -842,11 +1044,17 @@ export function AgentFeed({ agent }) {
|
|
|
842
1044
|
{/* Left: attach */}
|
|
843
1045
|
<button
|
|
844
1046
|
onClick={() => fileInputRef.current?.click()}
|
|
845
|
-
className=
|
|
1047
|
+
className={cn(
|
|
1048
|
+
'w-7 h-7 flex items-center justify-center rounded-md transition-colors cursor-pointer',
|
|
1049
|
+
pendingFiles.length > 0 ? 'text-accent hover:text-accent/80' : 'text-text-4 hover:text-text-1',
|
|
1050
|
+
)}
|
|
846
1051
|
title="Attach file"
|
|
847
1052
|
>
|
|
848
1053
|
<Paperclip size={14} />
|
|
849
1054
|
</button>
|
|
1055
|
+
{pendingFiles.length > 0 && (
|
|
1056
|
+
<span className="text-[10px] font-mono text-accent">{pendingFiles.length} file{pendingFiles.length > 1 ? 's' : ''}</span>
|
|
1057
|
+
)}
|
|
850
1058
|
{/* Model selector */}
|
|
851
1059
|
{providerModels.length > 1 && (
|
|
852
1060
|
<div className="relative flex items-center">
|
|
@@ -886,11 +1094,11 @@ export function AgentFeed({ agent }) {
|
|
|
886
1094
|
) : (
|
|
887
1095
|
<button
|
|
888
1096
|
onClick={handleSend}
|
|
889
|
-
disabled={(!input.trim() && !pendingSnippet) || sending}
|
|
1097
|
+
disabled={(!input.trim() && !pendingSnippet && pendingFiles.length === 0) || sending}
|
|
890
1098
|
className={cn(
|
|
891
1099
|
'w-7 h-7 flex items-center justify-center rounded-md transition-colors cursor-pointer',
|
|
892
1100
|
'disabled:opacity-15 disabled:cursor-not-allowed',
|
|
893
|
-
(input.trim() || pendingSnippet)
|
|
1101
|
+
(input.trim() || pendingSnippet || pendingFiles.length > 0)
|
|
894
1102
|
? 'text-text-0 hover:text-text-1'
|
|
895
1103
|
: 'text-text-4',
|
|
896
1104
|
)}
|
|
@@ -3,7 +3,7 @@ import { useState, useEffect, useRef, useCallback } from 'react';
|
|
|
3
3
|
import { useGrooveStore } from '../../stores/groove';
|
|
4
4
|
import { cn } from '../../lib/cn';
|
|
5
5
|
import { api } from '../../lib/api';
|
|
6
|
-
import { ChevronRight, ChevronDown, File, Folder, FolderOpen, FileEdit, Eye, FilePlus, FolderPlus, RefreshCw, ChevronsDownUp, PanelLeftClose, Pencil, Trash2 } from 'lucide-react';
|
|
6
|
+
import { ChevronRight, ChevronDown, File, Folder, FolderOpen, FileEdit, Eye, FilePlus, FolderPlus, RefreshCw, ChevronsDownUp, PanelLeftClose, Pencil, Trash2, Download } from 'lucide-react';
|
|
7
7
|
import { ScrollArea } from '../ui/scroll-area';
|
|
8
8
|
|
|
9
9
|
const FILE_COLORS = {
|
|
@@ -106,6 +106,15 @@ function ContextMenu({ x, y, items, onClose }) {
|
|
|
106
106
|
);
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
function downloadFile(path) {
|
|
110
|
+
const a = document.createElement('a');
|
|
111
|
+
a.href = `/api/files/download?path=${encodeURIComponent(path)}`;
|
|
112
|
+
a.download = path.split('/').pop();
|
|
113
|
+
document.body.appendChild(a);
|
|
114
|
+
a.click();
|
|
115
|
+
a.remove();
|
|
116
|
+
}
|
|
117
|
+
|
|
109
118
|
function TreeEntry({ entry, depth, onOpen, expandedDirs, onToggleDir, onContextMenu, dragState, onDragStartEntry, onDragEndEntry, onSetDragOver, onDropOnDir }) {
|
|
110
119
|
const isDir = entry.type === 'dir';
|
|
111
120
|
const isExpanded = expandedDirs.has(entry.path);
|
|
@@ -336,11 +345,37 @@ export function AgentFileTree({ agentId, onCollapse }) {
|
|
|
336
345
|
setDragState(prev => prev.dragOverPath === path ? prev : { ...prev, dragOverPath: path });
|
|
337
346
|
}
|
|
338
347
|
|
|
348
|
+
async function handleExternalDrop(targetDir, nativeFiles) {
|
|
349
|
+
const toUpload = [];
|
|
350
|
+
for (const file of nativeFiles) {
|
|
351
|
+
const base64 = await new Promise((resolve, reject) => {
|
|
352
|
+
const reader = new FileReader();
|
|
353
|
+
reader.onload = () => resolve(reader.result.split(',')[1]);
|
|
354
|
+
reader.onerror = reject;
|
|
355
|
+
reader.readAsDataURL(file);
|
|
356
|
+
});
|
|
357
|
+
toUpload.push({ name: file.name, content: base64 });
|
|
358
|
+
}
|
|
359
|
+
try {
|
|
360
|
+
const result = await api.post('/files/upload', { dir: targetDir, files: toUpload });
|
|
361
|
+
addToast('success', `Uploaded ${result.total} file${result.total !== 1 ? 's' : ''}`);
|
|
362
|
+
handleRefresh();
|
|
363
|
+
} catch (err) {
|
|
364
|
+
addToast('error', 'Upload failed', err.message);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
339
368
|
async function handleDropOnDir(targetDirPath, e) {
|
|
340
369
|
e.preventDefault();
|
|
341
370
|
e.stopPropagation();
|
|
342
371
|
setDragState({ draggingPath: null, dragOverPath: null });
|
|
343
372
|
|
|
373
|
+
// External files from desktop
|
|
374
|
+
if (e.dataTransfer?.files?.length > 0) {
|
|
375
|
+
handleExternalDrop(targetDirPath, Array.from(e.dataTransfer.files));
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
344
379
|
let data;
|
|
345
380
|
try { data = JSON.parse(e.dataTransfer.getData('application/json')); } catch { return; }
|
|
346
381
|
if (!data?.path) return;
|
|
@@ -466,6 +501,9 @@ export function AgentFileTree({ agentId, onCollapse }) {
|
|
|
466
501
|
items.push({ icon: FilePlus, label: 'New File', action: () => handleNewFileIn(entry.path) });
|
|
467
502
|
items.push({ icon: FolderPlus, label: 'New Folder', action: () => handleNewFolderIn(entry.path) });
|
|
468
503
|
items.push({ separator: true });
|
|
504
|
+
} else {
|
|
505
|
+
items.push({ icon: Download, label: 'Download', action: () => downloadFile(entry.path) });
|
|
506
|
+
items.push({ separator: true });
|
|
469
507
|
}
|
|
470
508
|
items.push({ icon: Pencil, label: 'Rename', action: () => handleRename(entry) });
|
|
471
509
|
items.push({ icon: Trash2, label: 'Delete', danger: true, action: () => handleDelete(entry) });
|
|
@@ -530,7 +568,17 @@ export function AgentFileTree({ agentId, onCollapse }) {
|
|
|
530
568
|
: <Eye size={12} className="text-info flex-shrink-0" />
|
|
531
569
|
}
|
|
532
570
|
<span className="truncate text-text-1 flex-1">{name}</span>
|
|
533
|
-
{hasWrites &&
|
|
571
|
+
{hasWrites && (
|
|
572
|
+
f.additions != null || f.deletions != null ? (
|
|
573
|
+
<span className="flex items-center gap-1 text-2xs flex-shrink-0 font-mono">
|
|
574
|
+
{f.additions > 0 && <span className="text-success">+{f.additions}</span>}
|
|
575
|
+
{f.deletions > 0 && <span className="text-danger">-{f.deletions}</span>}
|
|
576
|
+
{!f.additions && !f.deletions && <span className="text-warning/60">new</span>}
|
|
577
|
+
</span>
|
|
578
|
+
) : (
|
|
579
|
+
<span className="text-2xs text-warning/60 flex-shrink-0">{f.writes} {f.writes === 1 ? 'write' : 'writes'}</span>
|
|
580
|
+
)
|
|
581
|
+
)}
|
|
534
582
|
</button>
|
|
535
583
|
);
|
|
536
584
|
})}
|
|
@@ -549,7 +597,7 @@ export function AgentFileTree({ agentId, onCollapse }) {
|
|
|
549
597
|
) : (
|
|
550
598
|
<div
|
|
551
599
|
className="px-1"
|
|
552
|
-
onDragOver={(e) => { if (
|
|
600
|
+
onDragOver={(e) => { e.preventDefault(); if (dragState.draggingPath) setDragOverDir(null); }}
|
|
553
601
|
onDrop={(e) => handleDropOnDir('', e)}
|
|
554
602
|
>
|
|
555
603
|
<div className="flex items-center gap-1.5 px-2 py-1.5 text-2xs font-semibold text-text-3 uppercase tracking-wider">
|
|
@@ -6,7 +6,7 @@ import { api } from '../../lib/api';
|
|
|
6
6
|
import {
|
|
7
7
|
ChevronRight, ChevronDown, File, Folder, FolderOpen,
|
|
8
8
|
FolderPlus, Search, RefreshCw, Trash2, Pencil, FilePlus,
|
|
9
|
-
ChevronsDownUp, PanelLeftClose,
|
|
9
|
+
ChevronsDownUp, PanelLeftClose, Download,
|
|
10
10
|
} from 'lucide-react';
|
|
11
11
|
import { ScrollArea } from '../ui/scroll-area';
|
|
12
12
|
|
|
@@ -107,6 +107,15 @@ function GitDot({ status }) {
|
|
|
107
107
|
return <span className={cn('w-1.5 h-1.5 rounded-full flex-shrink-0', color)} />;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
function downloadFile(path) {
|
|
111
|
+
const a = document.createElement('a');
|
|
112
|
+
a.href = `/api/files/download?path=${encodeURIComponent(path)}`;
|
|
113
|
+
a.download = path.split('/').pop();
|
|
114
|
+
document.body.appendChild(a);
|
|
115
|
+
a.click();
|
|
116
|
+
a.remove();
|
|
117
|
+
}
|
|
118
|
+
|
|
110
119
|
function TreeNode({ entry, depth = 0, activePath, onFileClick, onDirToggle, expanded, onContextMenu, dragState, onDragStartEntry, onDragEndEntry, onSetDragOver, onDropOnDir, gitStatusMap }) {
|
|
111
120
|
const isDir = entry.type === 'dir';
|
|
112
121
|
const isActive = activePath === entry.path;
|
|
@@ -298,6 +307,12 @@ export function FileTree({ rootDir, onCollapse }) {
|
|
|
298
307
|
e.stopPropagation();
|
|
299
308
|
setDragState({ draggingPath: null, dragOverPath: null });
|
|
300
309
|
|
|
310
|
+
// External files from desktop
|
|
311
|
+
if (e.dataTransfer?.files?.length > 0) {
|
|
312
|
+
handleExternalDrop(targetDirPath, Array.from(e.dataTransfer.files));
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
301
316
|
let data;
|
|
302
317
|
try { data = JSON.parse(e.dataTransfer.getData('application/json')); } catch { return; }
|
|
303
318
|
if (!data?.path) return;
|
|
@@ -410,6 +425,26 @@ export function FileTree({ rootDir, onCollapse }) {
|
|
|
410
425
|
}
|
|
411
426
|
}
|
|
412
427
|
|
|
428
|
+
async function handleExternalDrop(targetDir, nativeFiles) {
|
|
429
|
+
const toUpload = [];
|
|
430
|
+
for (const file of nativeFiles) {
|
|
431
|
+
const base64 = await new Promise((resolve, reject) => {
|
|
432
|
+
const reader = new FileReader();
|
|
433
|
+
reader.onload = () => resolve(reader.result.split(',')[1]);
|
|
434
|
+
reader.onerror = reject;
|
|
435
|
+
reader.readAsDataURL(file);
|
|
436
|
+
});
|
|
437
|
+
toUpload.push({ name: file.name, content: base64 });
|
|
438
|
+
}
|
|
439
|
+
try {
|
|
440
|
+
const result = await api.post('/files/upload', { dir: targetDir, files: toUpload });
|
|
441
|
+
addToast('success', `Uploaded ${result.total} file${result.total !== 1 ? 's' : ''}`);
|
|
442
|
+
fetchTreeDir(targetDir);
|
|
443
|
+
} catch (err) {
|
|
444
|
+
addToast('error', 'Upload failed', err.message);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
413
448
|
function buildContextMenuItems(entry) {
|
|
414
449
|
const isDir = entry.type === 'dir';
|
|
415
450
|
const items = [];
|
|
@@ -420,11 +455,13 @@ export function FileTree({ rootDir, onCollapse }) {
|
|
|
420
455
|
}
|
|
421
456
|
|
|
422
457
|
if (entry.name !== 'root') {
|
|
458
|
+
if (!isDir) {
|
|
459
|
+
items.push({ icon: Download, label: 'Download', action: () => downloadFile(entry.path) });
|
|
460
|
+
}
|
|
423
461
|
if (items.length > 0) items.push({ separator: true });
|
|
424
462
|
items.push({ icon: Pencil, label: 'Rename', action: () => handleRename(entry) });
|
|
425
463
|
items.push({ icon: Trash2, label: 'Delete', danger: true, action: () => handleDelete(entry) });
|
|
426
464
|
} else {
|
|
427
|
-
// Root context — only new file/folder
|
|
428
465
|
items.length = 0;
|
|
429
466
|
items.push({ icon: FilePlus, label: 'New File', action: () => handleNewFile('') });
|
|
430
467
|
items.push({ icon: FolderPlus, label: 'New Folder', action: () => handleNewFolder('') });
|
|
@@ -491,7 +528,7 @@ export function FileTree({ rootDir, onCollapse }) {
|
|
|
491
528
|
<ScrollArea className="flex-1">
|
|
492
529
|
<div
|
|
493
530
|
className="py-1"
|
|
494
|
-
onDragOver={(e) => { if (
|
|
531
|
+
onDragOver={(e) => { e.preventDefault(); if (dragState.draggingPath) setDragOverDir(null); }}
|
|
495
532
|
onDrop={(e) => handleDropOnDir('', e)}
|
|
496
533
|
>
|
|
497
534
|
{/* Inline input at root level */}
|
|
@@ -306,7 +306,15 @@ export const useGrooveStore = create((set, get) => ({
|
|
|
306
306
|
const filePath = block.input?.file_path || block.input?.path;
|
|
307
307
|
if (filePath && agentId === get().workspaceAgentId) {
|
|
308
308
|
const relPath = filePath.replace(/^\/[^/]+.*?\/groove\//, '');
|
|
309
|
-
get().
|
|
309
|
+
const hasActiveFile = get().editorActiveFile;
|
|
310
|
+
if (hasActiveFile) {
|
|
311
|
+
// Add to tabs without switching away from the user's current file
|
|
312
|
+
set((s) => ({
|
|
313
|
+
editorOpenTabs: s.editorOpenTabs.includes(relPath) ? s.editorOpenTabs : [...s.editorOpenTabs, relPath],
|
|
314
|
+
}));
|
|
315
|
+
} else {
|
|
316
|
+
get().openFile(relPath);
|
|
317
|
+
}
|
|
310
318
|
}
|
|
311
319
|
}
|
|
312
320
|
}
|