groove-dev 0.27.153 → 0.27.155

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 (40) hide show
  1. package/CLAUDE.md +7 -0
  2. package/node_modules/@groove-dev/cli/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/package.json +1 -1
  4. package/node_modules/@groove-dev/daemon/src/journalist.js +52 -21
  5. package/node_modules/@groove-dev/daemon/src/keeper.js +37 -2
  6. package/node_modules/@groove-dev/daemon/src/routes/coordination.js +16 -0
  7. package/node_modules/@groove-dev/daemon/src/routes/files.js +71 -2
  8. package/node_modules/@groove-dev/daemon/src/tunnel-manager.js +0 -2
  9. package/node_modules/@groove-dev/gui/dist/assets/index-BTLb6zTD.js +1015 -0
  10. package/node_modules/@groove-dev/gui/dist/assets/index-Diw6wDPU.css +1 -0
  11. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  12. package/node_modules/@groove-dev/gui/package.json +1 -1
  13. package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +252 -44
  14. package/node_modules/@groove-dev/gui/src/components/agents/agent-file-tree.jsx +51 -3
  15. package/node_modules/@groove-dev/gui/src/components/editor/file-tree.jsx +40 -3
  16. package/node_modules/@groove-dev/gui/src/stores/groove.js +9 -1
  17. package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +24 -5
  18. package/node_modules/@groove-dev/gui/src/views/memory.jsx +87 -44
  19. package/package.json +1 -1
  20. package/packages/cli/package.json +1 -1
  21. package/packages/daemon/package.json +1 -1
  22. package/packages/daemon/src/journalist.js +52 -21
  23. package/packages/daemon/src/keeper.js +37 -2
  24. package/packages/daemon/src/routes/coordination.js +16 -0
  25. package/packages/daemon/src/routes/files.js +71 -2
  26. package/packages/daemon/src/tunnel-manager.js +0 -2
  27. package/packages/gui/dist/assets/index-BTLb6zTD.js +1015 -0
  28. package/packages/gui/dist/assets/index-Diw6wDPU.css +1 -0
  29. package/packages/gui/dist/index.html +2 -2
  30. package/packages/gui/package.json +1 -1
  31. package/packages/gui/src/components/agents/agent-feed.jsx +252 -44
  32. package/packages/gui/src/components/agents/agent-file-tree.jsx +51 -3
  33. package/packages/gui/src/components/editor/file-tree.jsx +40 -3
  34. package/packages/gui/src/stores/groove.js +9 -1
  35. package/packages/gui/src/stores/slices/agents-slice.js +24 -5
  36. package/packages/gui/src/views/memory.jsx +87 -44
  37. package/node_modules/@groove-dev/gui/dist/assets/index-BU_YTEZo.js +0 -1011
  38. package/node_modules/@groove-dev/gui/dist/assets/index-ChfYTsyc.css +0 -1
  39. package/packages/gui/dist/assets/index-BU_YTEZo.js +0 -1011
  40. 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
- <div className="text-[12px] font-sans whitespace-pre-wrap break-words leading-relaxed text-text-0">
242
- <FormattedText text={msg.text} />
243
- </div>
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 uploaded = [];
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]); // strip data:...;base64,
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: file.name, content: base64 });
648
- uploaded.push(file.name);
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
- addToast('error', `Upload failed: ${file.name}`, err.message);
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
- if (uploaded.length > 0) {
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
- if ((!text && !pendingSnippet) || sending) return;
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
- setSending(true);
692
- isAtBottomRef.current = true;
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 className="flex flex-col rounded-lg border border-border-subtle bg-surface-0 transition-colors overflow-hidden focus-within:border-text-4/40">
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=".pdf,.png,.jpg,.jpeg,.gif,.svg,.csv,.txt,.md,.json,.yaml,.yml,.docx,.pptx,.xlsx"
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
- {/* Textarea — full width */}
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-y-hidden"
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
- onDragOver={(e) => e.preventDefault()}
816
- onDrop={(e) => {
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="w-7 h-7 flex items-center justify-center rounded-md text-text-4 hover:text-text-1 transition-colors cursor-pointer"
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 && <span className="text-2xs text-warning/60 flex-shrink-0">{f.writes}w</span>}
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 (!dragState.draggingPath) return; e.preventDefault(); setDragOverDir(null); }}
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 (!dragState.draggingPath) return; e.preventDefault(); setDragOverDir(null); }}
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().openFile(relPath);
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
  }