Skip to content

Commit d3c169f

Browse files
committed
Add support for directory uploads in sandbox API and UI
- Updated `sandbox_upload` endpoint to accept file paths, preserving directory structure during uploads. - Enhanced frontend components to handle both file and directory uploads via drag-and-drop and button interactions. - Modified relevant functions to accommodate new paths parameter for better file organization.
1 parent 2da0de0 commit d3c169f

6 files changed

Lines changed: 179 additions & 51 deletions

File tree

server.py

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from pathlib import Path
88

99
import yaml
10-
from fastapi import Body, HTTPException, Query, Request, UploadFile
10+
from fastapi import Body, Form, HTTPException, Query, Request, UploadFile
1111
from fastapi.responses import PlainTextResponse, StreamingResponse
1212
from google.adk.cli.fast_api import get_fast_api_app
1313

@@ -123,17 +123,29 @@ def build_tree(directory: Path, depth: int = 0) -> dict:
123123

124124

125125
@app.post("/sandbox/upload")
126-
async def sandbox_upload(files: list[UploadFile]):
127-
"""Upload files into sandbox/user_data."""
126+
async def sandbox_upload(
127+
files: list[UploadFile],
128+
paths: list[str] = Form(default=[]),
129+
):
130+
"""Upload files into sandbox/user_data, preserving directory structure."""
128131
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
129132
saved = []
130-
for f in files:
133+
for i, f in enumerate(files):
131134
if not f.filename:
132135
continue
133-
safe_name = Path(f.filename).name
134-
if not safe_name or safe_name.startswith("."):
135-
continue
136-
dest = UPLOAD_DIR / safe_name
136+
rel = paths[i].strip() if i < len(paths) else ""
137+
if rel:
138+
parts = Path(rel).parts
139+
safe_parts = [p for p in parts if p not in ("..", ".") and not p.startswith(".")]
140+
if not safe_parts:
141+
continue
142+
dest = UPLOAD_DIR / Path(*safe_parts)
143+
else:
144+
safe_name = Path(f.filename).name
145+
if not safe_name or safe_name.startswith("."):
146+
continue
147+
dest = UPLOAD_DIR / safe_name
148+
dest.parent.mkdir(parents=True, exist_ok=True)
137149
content = await f.read()
138150
dest.write_bytes(content)
139151
saved.append(str(dest.relative_to(SANDBOX_ROOT)))

web/src/app/page.tsx

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { useAgent, type ActivityItem } from "@/lib/use-agent";
3636
import { useConfig } from "@/lib/use-config";
3737
import { useSkills } from "@/lib/use-skills";
3838
import type { TurnMeta } from "@/lib/provenance";
39+
import { hasDirectoryEntries, traverseDroppedEntries } from "@/lib/directory-upload";
3940
import { useSandbox, fileCategory, type TreeNode } from "@/lib/use-sandbox";
4041
import {
4142
CopyIcon,
@@ -91,7 +92,7 @@ function PromptDropZone({
9192
}: {
9293
children: React.ReactNode;
9394
onFileDrop?: (path: string) => void;
94-
onFilesUpload?: (files: FileList) => void;
95+
onFilesUpload?: (files: FileList | File[], paths?: string[]) => void;
9596
}) {
9697
const controller = usePromptInputController();
9798
const [isDragOver, setIsDragOver] = useState(false);
@@ -123,7 +124,7 @@ function PromptDropZone({
123124
}, []);
124125

125126
const handleDrop = useCallback(
126-
(e: React.DragEvent) => {
127+
async (e: React.DragEvent) => {
127128
e.preventDefault();
128129
dragCounter.current = 0;
129130
setIsDragOver(false);
@@ -141,12 +142,17 @@ function PromptDropZone({
141142
return;
142143
}
143144

144-
// OS file drop from outside the browser
145-
if (e.dataTransfer.files && e.dataTransfer.files.length > 0 && onFilesUpload) {
145+
if (!onFilesUpload) return;
146+
147+
// OS directory or file drop from outside the browser
148+
if (hasDirectoryEntries(e.dataTransfer.items)) {
149+
const { files, paths } = await traverseDroppedEntries(e.dataTransfer.items);
150+
if (files.length > 0) onFilesUpload(files, paths);
151+
} else if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
146152
onFilesUpload(e.dataTransfer.files);
147153
}
148154
},
149-
[controller, onFileDrop, onFilesUpload]
155+
[controller, onFileDrop, onFilesUpload],
150156
);
151157

152158
const isOsDrag = isDragOver;
@@ -358,17 +364,17 @@ function ChatInput({
358364
onComputeChange: (instance: ModalInstance | null) => void;
359365
selectedModel: Model;
360366
onModelChange: (model: Model) => void;
361-
onUploadFiles: (files: FileList | File[]) => Promise<string[]>;
367+
onUploadFiles: (files: FileList | File[], paths?: string[]) => Promise<string[]>;
362368
modalConfigured: boolean;
363369
allSkills: Skill[];
364370
selectedSkills: Skill[];
365371
onSkillsChange: (skills: Skill[]) => void;
366372
}) {
367373
const controller = usePromptInputController();
368374

369-
const handleFilesUpload = useCallback(async (files: FileList) => {
370-
const paths = await onUploadFiles(files);
371-
for (const p of paths) onAddFile(p);
375+
const handleFilesUpload = useCallback(async (files: FileList | File[], paths?: string[]) => {
376+
const uploaded = await onUploadFiles(files, paths);
377+
for (const p of uploaded) onAddFile(p);
372378
}, [onUploadFiles, onAddFile]);
373379

374380
// Wrap onSubmit to append attached file paths and database context, then clear chips

web/src/components/sandbox-panel.tsx

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,14 @@ import {
99
FileTreeActions,
1010
} from "@/components/ai-elements/file-tree";
1111
import { cn } from "@/lib/utils";
12+
import { hasDirectoryEntries, traverseDroppedEntries } from "@/lib/directory-upload";
1213
import { type TreeNode } from "@/lib/use-sandbox";
14+
import {
15+
DropdownMenu,
16+
DropdownMenuContent,
17+
DropdownMenuItem,
18+
DropdownMenuTrigger,
19+
} from "@/components/ui/dropdown-menu";
1320
import {
1421
FileIcon,
1522
FileTextIcon,
@@ -24,6 +31,7 @@ import {
2431
FolderIcon,
2532
FolderOpenIcon,
2633
FolderPlusIcon,
34+
FolderUpIcon,
2735
XIcon,
2836
RefreshCwIcon,
2937
UploadIcon,
@@ -418,7 +426,7 @@ interface FileTreePanelProps {
418426
onDownloadAll: () => void;
419427
onRefresh: () => void;
420428
onClose: () => void;
421-
onUpload: (files: FileList | File[]) => void;
429+
onUpload: (files: FileList | File[], paths?: string[]) => void;
422430
onOrganize?: () => void;
423431
onMove: (src: string, dest: string) => void;
424432
onRename: (path: string, newName: string) => void;
@@ -445,6 +453,7 @@ export function FileTreePanel({
445453
}: FileTreePanelProps) {
446454
const totalFiles = useMemo(() => (tree ? countFiles(tree) : 0), [tree]);
447455
const fileInputRef = useRef<HTMLInputElement>(null);
456+
const dirInputRef = useRef<HTMLInputElement>(null);
448457

449458
// OS file drag-and-drop
450459
const [isDragOver, setIsDragOver] = useState(false);
@@ -480,11 +489,15 @@ export function FileTreePanel({
480489
}
481490
}, []);
482491

483-
const handleDrop = useCallback((e: React.DragEvent) => {
492+
const handleDrop = useCallback(async (e: React.DragEvent) => {
484493
e.preventDefault();
485494
dragCounter.current = 0;
486495
setIsDragOver(false);
487-
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
496+
497+
if (hasDirectoryEntries(e.dataTransfer.items)) {
498+
const { files, paths } = await traverseDroppedEntries(e.dataTransfer.items);
499+
if (files.length > 0) onUpload(files, paths);
500+
} else if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
488501
onUpload(e.dataTransfer.files);
489502
}
490503
}, [onUpload]);
@@ -526,7 +539,7 @@ export function FileTreePanel({
526539
e.target.value = "";
527540
}
528541
},
529-
[onUpload]
542+
[onUpload],
530543
);
531544

532545
const handleRename = useCallback((path: string, newName: string) => {
@@ -552,7 +565,7 @@ export function FileTreePanel({
552565
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-primary/5 border-2 border-dashed border-primary rounded-md">
553566
<div className="flex flex-col items-center gap-1.5">
554567
<UploadIcon className="size-5 text-primary" />
555-
<span className="text-xs font-medium text-primary">Drop files to upload</span>
568+
<span className="text-xs font-medium text-primary">Drop files or folders to upload</span>
556569
</div>
557570
</div>
558571
)}
@@ -569,6 +582,8 @@ export function FileTreePanel({
569582
</div>
570583
<div className="flex items-center gap-0.5">
571584
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleFileChange} />
585+
{/* @ts-expect-error -- webkitdirectory is non-standard but supported in all major browsers */}
586+
<input ref={dirInputRef} type="file" webkitdirectory="" className="hidden" onChange={handleFileChange} />
572587
{totalFiles > 0 && onOrganize && (
573588
<button onClick={onOrganize} className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" title="Auto-organize files">
574589
<WandSparklesIcon className="size-3.5" />
@@ -582,9 +597,23 @@ export function FileTreePanel({
582597
<button onClick={() => setCreatingDirIn("")} className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" title="New folder">
583598
<FolderPlusIcon className="size-3.5" />
584599
</button>
585-
<button onClick={() => fileInputRef.current?.click()} disabled={uploading} className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50" title="Upload files">
586-
{uploading ? <LoaderIcon className="size-3.5 animate-spin" /> : <UploadIcon className="size-3.5" />}
587-
</button>
600+
<DropdownMenu>
601+
<DropdownMenuTrigger asChild>
602+
<button disabled={uploading} className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50" title="Upload files or folder">
603+
{uploading ? <LoaderIcon className="size-3.5 animate-spin" /> : <UploadIcon className="size-3.5" />}
604+
</button>
605+
</DropdownMenuTrigger>
606+
<DropdownMenuContent align="end" className="min-w-[140px]">
607+
<DropdownMenuItem onClick={() => fileInputRef.current?.click()}>
608+
<UploadIcon className="mr-2 size-3.5" />
609+
Upload files
610+
</DropdownMenuItem>
611+
<DropdownMenuItem onClick={() => dirInputRef.current?.click()}>
612+
<FolderUpIcon className="mr-2 size-3.5" />
613+
Upload folder
614+
</DropdownMenuItem>
615+
</DropdownMenuContent>
616+
</DropdownMenu>
588617
<button onClick={onRefresh} className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" title="Refresh">
589618
<RefreshCwIcon className="size-3.5" />
590619
</button>
@@ -603,7 +632,7 @@ export function FileTreePanel({
603632
</div>
604633
<div className="space-y-0.5">
605634
<p className="text-xs font-medium text-muted-foreground">No files yet</p>
606-
<p className="text-[11px] text-muted-foreground/60">Drop files here or use the upload button</p>
635+
<p className="text-[11px] text-muted-foreground/60">Drop files or folders here, or use the upload button</p>
607636
</div>
608637
</div>
609638
) : (

web/src/components/workflows-panel.tsx

Lines changed: 44 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,6 @@ import {
5353
NetworkIcon,
5454
NewspaperIcon,
5555
PaletteIcon,
56-
PaperclipIcon,
5756
PencilIcon,
5857
PieChartIcon,
5958
PlayIcon,
@@ -87,6 +86,7 @@ import {
8786
WrenchIcon,
8887
ZapIcon,
8988
CheckCircle2Icon,
89+
FolderUpIcon,
9090
type LucideIcon,
9191
} from "lucide-react";
9292
import { cn } from "@/lib/utils";
@@ -292,7 +292,7 @@ function LaunchDialog({
292292
open: boolean;
293293
onOpenChange: (open: boolean) => void;
294294
onLaunch: (prompt: string, model: Model, compute: ModalInstance | null, suggestedSkills: string[]) => void;
295-
onUploadFiles?: (files: FileList | File[]) => Promise<string[]>;
295+
onUploadFiles?: (files: FileList | File[], paths?: string[]) => Promise<string[]>;
296296
modalConfigured: boolean;
297297
}) {
298298
const [model, setModel] = useState<Model>(DEFAULT_MODEL);
@@ -303,6 +303,7 @@ function LaunchDialog({
303303
const [isEditingPrompt, setIsEditingPrompt] = useState(false);
304304
const [editedPrompt, setEditedPrompt] = useState<string | null>(null);
305305
const fileInputRef = useRef<HTMLInputElement>(null);
306+
const dirInputRef = useRef<HTMLInputElement>(null);
306307

307308
const updatePlaceholder = useCallback((key: string, value: string) => {
308309
setPlaceholderValues((prev) => ({ ...prev, [key]: value }));
@@ -373,23 +374,44 @@ function LaunchDialog({
373374
className="hidden"
374375
onChange={handleFileUpload}
375376
/>
376-
<button
377-
onClick={() => fileInputRef.current?.click()}
378-
disabled={uploading}
379-
className="flex w-full items-center justify-center gap-2 rounded-md border border-amber-500/20 bg-background px-3 py-2 text-xs font-medium transition-colors hover:bg-amber-500/10 disabled:opacity-50"
380-
>
381-
{uploading ? (
382-
<>
383-
<LoaderIcon className="size-3.5 animate-spin text-amber-500" />
384-
<span className="text-muted-foreground">Uploading...</span>
385-
</>
386-
) : (
387-
<>
388-
<UploadIcon className="size-3.5 text-amber-500" />
389-
<span className="text-muted-foreground">Choose files to upload</span>
390-
</>
391-
)}
392-
</button>
377+
{/* @ts-expect-error -- webkitdirectory is non-standard but supported in all major browsers */}
378+
<input ref={dirInputRef} type="file" webkitdirectory="" className="hidden" onChange={handleFileUpload} />
379+
<div className="flex gap-2">
380+
<button
381+
onClick={() => fileInputRef.current?.click()}
382+
disabled={uploading}
383+
className="flex flex-1 items-center justify-center gap-2 rounded-md border border-amber-500/20 bg-background px-3 py-2 text-xs font-medium transition-colors hover:bg-amber-500/10 disabled:opacity-50"
384+
>
385+
{uploading ? (
386+
<>
387+
<LoaderIcon className="size-3.5 animate-spin text-amber-500" />
388+
<span className="text-muted-foreground">Uploading...</span>
389+
</>
390+
) : (
391+
<>
392+
<UploadIcon className="size-3.5 text-amber-500" />
393+
<span className="text-muted-foreground">Files</span>
394+
</>
395+
)}
396+
</button>
397+
<button
398+
onClick={() => dirInputRef.current?.click()}
399+
disabled={uploading}
400+
className="flex flex-1 items-center justify-center gap-2 rounded-md border border-amber-500/20 bg-background px-3 py-2 text-xs font-medium transition-colors hover:bg-amber-500/10 disabled:opacity-50"
401+
>
402+
{uploading ? (
403+
<>
404+
<LoaderIcon className="size-3.5 animate-spin text-amber-500" />
405+
<span className="text-muted-foreground">Uploading...</span>
406+
</>
407+
) : (
408+
<>
409+
<FolderUpIcon className="size-3.5 text-amber-500" />
410+
<span className="text-muted-foreground">Folder</span>
411+
</>
412+
)}
413+
</button>
414+
</div>
393415
{uploadedFiles.length > 0 && (
394416
<div className="mt-2 space-y-1">
395417
{uploadedFiles.map((path) => (
@@ -498,7 +520,7 @@ export function WorkflowsPanel({
498520
modalConfigured,
499521
}: {
500522
onLaunch: (prompt: string, model: Model, compute: ModalInstance | null, suggestedSkills: string[]) => void;
501-
onUploadFiles?: (files: FileList | File[]) => Promise<string[]>;
523+
onUploadFiles?: (files: FileList | File[], paths?: string[]) => Promise<string[]>;
502524
modalConfigured: boolean;
503525
}) {
504526
const [selectedWorkflow, setSelectedWorkflow] = useState<Workflow | null>(null);
@@ -604,8 +626,8 @@ export function WorkflowsPanel({
604626
<span className="text-sm font-medium text-foreground">{w.name}</span>
605627
{w.requiresFiles && (
606628
<span className="inline-flex items-center gap-0.5 rounded-full bg-amber-500/10 px-1.5 py-px text-[9px] font-medium text-amber-600 dark:text-amber-400 border border-amber-500/20">
607-
<PaperclipIcon className="size-2.5" />
608-
Files
629+
<UploadIcon className="size-2.5" />
630+
Needs user data
609631
</span>
610632
)}
611633
</div>

0 commit comments

Comments
 (0)