openclaw/apps/web/app/components/workspace/directory-picker-modal.tsx
kumarabhirup 92fadd6700
fix: filter non-parent events in main NDJSON handler and fix workspace creation path
Bug 1: Subagent events from gateway broadcasts were processed as parent
events because the sessionKey filter was accidentally removed during the
subagent decoupling refactor. Re-add the filter in wireChildProcess.

Bug 2: Creating workspaces at custom paths failed because:
- mkdir API rejected absolute paths outside workspace root
- Directory picker started at workspace root, not home
- Error responses from mkdir were silently swallowed
Add absolute path support to mkdir, handle errors in picker UI,
start picker at home dir, and normalize init route paths.
2026-02-21 13:45:11 -08:00

480 lines
17 KiB
TypeScript

"use client";
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
type BrowseEntry = {
name: string;
path: string;
type: "folder" | "file" | "document" | "database";
};
type DirectoryPickerModalProps = {
open: boolean;
onClose: () => void;
onSelect: (path: string) => void;
/** Starting directory (absolute). Falls back to the workspace root / home. */
startDir?: string;
};
function buildBreadcrumbs(dir: string): { label: string; path: string }[] {
const segments: { label: string; path: string }[] = [];
const homeMatch = dir.match(/^(\/Users\/[^/]+|\/home\/[^/]+)/);
const homeDir = homeMatch?.[1];
if (homeDir) {
segments.push({ label: "~", path: homeDir });
const rest = dir.slice(homeDir.length);
const parts = rest.split("/").filter(Boolean);
let currentPath = homeDir;
for (const part of parts) {
currentPath += "/" + part;
segments.push({ label: part, path: currentPath });
}
} else if (dir === "/") {
segments.push({ label: "/", path: "/" });
} else {
segments.push({ label: "/", path: "/" });
const parts = dir.split("/").filter(Boolean);
let currentPath = "";
for (const part of parts) {
currentPath += "/" + part;
segments.push({ label: part, path: currentPath });
}
}
return segments;
}
const folderColors = { bg: "rgba(245, 158, 11, 0.12)", fg: "#f59e0b" };
function FolderIcon({ size = 16 }: { size?: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z" />
</svg>
);
}
export function DirectoryPickerModal({
open,
onClose,
onSelect,
startDir,
}: DirectoryPickerModalProps) {
const [currentDir, setCurrentDir] = useState<string | null>(startDir ?? null);
const [displayDir, setDisplayDir] = useState("");
const [entries, setEntries] = useState<BrowseEntry[]>([]);
const [parentDir, setParentDir] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState("");
const [creatingFolder, setCreatingFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
const [error, setError] = useState<string | null>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
if (open) {
requestAnimationFrame(() => requestAnimationFrame(() => setVisible(true)));
} else {
setVisible(false);
}
}, [open]);
useEffect(() => {
if (!open) {
setSearch("");
setCreatingFolder(false);
setNewFolderName("");
setError(null);
}
}, [open]);
// Reset to startDir when reopening
useEffect(() => {
if (open) {
setCurrentDir(startDir ?? null);
}
}, [open, startDir]);
const searchRef = useRef<HTMLInputElement>(null);
const newFolderRef = useRef<HTMLInputElement>(null);
const fetchDir = useCallback(async (dir: string | null) => {
setLoading(true);
setError(null);
try {
const url = dir
? `/api/workspace/browse?dir=${encodeURIComponent(dir)}`
: "/api/workspace/browse";
const res = await fetch(url);
if (!res.ok) {throw new Error("Failed to list directory");}
const data = await res.json();
setEntries(data.entries || []);
setDisplayDir(data.currentDir || "");
setParentDir(data.parentDir ?? null);
} catch {
setError("Could not load this directory");
setEntries([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (open) { void fetchDir(currentDir); }
}, [open, currentDir, fetchDir]);
useEffect(() => {
if (!open) {return;}
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") {onClose();}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, onClose]);
const navigateInto = useCallback((path: string) => {
setCurrentDir(path);
setSearch("");
setCreatingFolder(false);
}, []);
const handleCreateFolder = useCallback(async () => {
if (!newFolderName.trim() || !displayDir) {return;}
const folderPath = `${displayDir}/${newFolderName.trim()}`;
try {
const res = await fetch("/api/workspace/mkdir", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: folderPath, absolute: true }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError((data as { error?: string }).error || "Failed to create folder");
return;
}
setCreatingFolder(false);
setNewFolderName("");
void fetchDir(currentDir);
} catch {
setError("Failed to create folder");
}
}, [newFolderName, displayDir, currentDir, fetchDir]);
const handleSelectCurrent = useCallback(() => {
if (displayDir) {
onSelect(displayDir);
onClose();
}
}, [displayDir, onSelect, onClose]);
// Only show folders
const folders = entries
.filter((e) => e.type === "folder")
.filter((e) => !search || e.name.toLowerCase().includes(search.toLowerCase()))
.toSorted((a, b) => a.name.localeCompare(b.name));
const breadcrumbs = displayDir ? buildBreadcrumbs(displayDir) : [];
// Shorten display path for the footer
const shortDir = displayDir
.replace(/^\/Users\/[^/]+/, "~")
.replace(/^\/home\/[^/]+/, "~");
if (!open) {return null;}
return (
<div
className="fixed inset-0 z-[60] flex items-center justify-center"
style={{
opacity: visible ? 1 : 0,
transition: "opacity 150ms ease-out",
}}
>
<div
className="absolute inset-0"
style={{ background: "rgba(0,0,0,0.4)", backdropFilter: "blur(4px)" }}
onClick={onClose}
/>
<div
className="relative flex flex-col rounded-2xl shadow-2xl overflow-hidden w-[calc(100%-2rem)] max-w-[540px]"
style={{
maxHeight: "70vh",
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
transform: visible ? "scale(1)" : "scale(0.97)",
transition: "transform 150ms ease-out",
}}
>
{/* Header */}
<div
className="flex items-center justify-between px-5 py-3.5 border-b flex-shrink-0"
style={{ borderColor: "var(--color-border)" }}
>
<div className="flex items-center gap-2.5">
<div
className="w-8 h-8 rounded-lg flex items-center justify-center"
style={{ background: folderColors.bg, color: folderColors.fg }}
>
<FolderIcon size={18} />
</div>
<div>
<h2 className="text-sm font-semibold" style={{ color: "var(--color-text)" }}>
Choose Directory
</h2>
<p className="text-[11px]" style={{ color: "var(--color-text-muted)" }}>
Navigate to a folder for the workspace
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="w-7 h-7 rounded-lg flex items-center justify-center"
style={{ color: "var(--color-text-muted)", background: "var(--color-surface-hover)" }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
</svg>
</button>
</div>
{/* Breadcrumbs */}
{displayDir && (
<div
className="flex items-center gap-1 px-5 py-2 border-b overflow-x-auto flex-shrink-0"
style={{ borderColor: "var(--color-border)", scrollbarWidth: "thin" }}
>
{breadcrumbs.map((seg, i) => (
<Fragment key={seg.path}>
{i > 0 && (
<span
className="text-[10px] flex-shrink-0"
style={{ color: "var(--color-text-muted)", opacity: 0.5 }}
>
/
</span>
)}
<button
type="button"
onClick={() => navigateInto(seg.path)}
className="text-[12px] font-medium flex-shrink-0 rounded px-1 py-0.5 hover:underline"
style={{
color: i === breadcrumbs.length - 1
? "var(--color-text)"
: "var(--color-text-muted)",
}}
>
{seg.label}
</button>
</Fragment>
))}
</div>
)}
{/* Search + New Folder */}
<div
className="flex items-center gap-2 px-4 py-2 border-b flex-shrink-0"
style={{ borderColor: "var(--color-border)" }}
>
<div
className="flex-1 flex items-center gap-2 rounded-lg px-2.5 py-1.5"
style={{ background: "var(--color-bg)", border: "1px solid var(--color-border)" }}
>
<svg
width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
style={{ color: "var(--color-text-muted)", flexShrink: 0 }}
>
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />
</svg>
<input
ref={searchRef}
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Filter folders..."
className="flex-1 bg-transparent outline-none text-[13px] placeholder:text-[var(--color-text-muted)]"
style={{ color: "var(--color-text)" }}
/>
</div>
<button
type="button"
onClick={() => {
setCreatingFolder(true);
setTimeout(() => newFolderRef.current?.focus(), 50);
}}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-[12px] font-medium whitespace-nowrap"
style={{
color: "var(--color-text-muted)",
background: "var(--color-surface-hover)",
border: "1px solid var(--color-border)",
}}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M12 5v14" /><path d="M5 12h14" />
</svg>
New Folder
</button>
</div>
{/* Folder list */}
<div
className="flex-1 overflow-y-auto"
style={{ background: "var(--color-bg)", minHeight: 200 }}
>
{loading ? (
<div className="flex items-center justify-center py-16">
<div
className="w-5 h-5 border-2 rounded-full animate-spin"
style={{ borderColor: "var(--color-border)", borderTopColor: "var(--color-accent)" }}
/>
</div>
) : error ? (
<div className="flex items-center justify-center py-16 text-[13px]" style={{ color: "var(--color-text-muted)" }}>
{error}
</div>
) : (
<>
{/* Go up */}
{parentDir && (
<button
type="button"
onClick={() => navigateInto(parentDir)}
className="w-full flex items-center gap-3 px-4 py-2 text-left hover:bg-[var(--color-surface-hover)] transition-colors"
style={{ color: "var(--color-text-muted)" }}
>
<div
className="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0"
style={{ background: "var(--color-surface-hover)" }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m15 18-6-6 6-6" />
</svg>
</div>
<span className="text-[13px] font-medium">..</span>
</button>
)}
{/* New folder input */}
{creatingFolder && (
<div className="flex items-center gap-3 px-4 py-2">
<div
className="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0"
style={{ background: folderColors.bg, color: folderColors.fg }}
>
<FolderIcon />
</div>
<input
ref={newFolderRef}
type="text"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {void handleCreateFolder();}
if (e.key === "Escape") {
setCreatingFolder(false);
setNewFolderName("");
}
}}
placeholder="Folder name..."
className="flex-1 bg-transparent outline-none text-[13px] placeholder:text-[var(--color-text-muted)] rounded px-2 py-1"
style={{
color: "var(--color-text)",
background: "var(--color-surface)",
border: "1px solid var(--color-accent)",
}}
/>
</div>
)}
{/* Folder entries */}
{folders.length === 0 && !parentDir && (
<div className="flex items-center justify-center py-16 text-[13px]" style={{ color: "var(--color-text-muted)" }}>
No subfolders here
</div>
)}
{folders.map((entry) => (
<button
key={entry.path}
type="button"
onClick={() => navigateInto(entry.path)}
className="w-full flex items-center gap-3 px-4 py-1.5 group text-left hover:bg-[var(--color-surface-hover)] transition-colors"
>
<div
className="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0"
style={{ background: folderColors.bg, color: folderColors.fg }}
>
<FolderIcon />
</div>
<span
className="flex-1 text-[13px] font-medium truncate"
style={{ color: "var(--color-text)" }}
title={entry.path}
>
{entry.name}
</span>
<svg
width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
className="flex-shrink-0 opacity-0 group-hover:opacity-50 transition-opacity"
>
<path d="m9 18 6-6-6-6" />
</svg>
</button>
))}
</>
)}
</div>
{/* Footer */}
<div
className="flex items-center justify-between px-5 py-3 border-t flex-shrink-0"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<div className="min-w-0 flex-1 mr-3">
<p className="text-[11px] truncate" style={{ color: "var(--color-text-muted)" }} title={displayDir}>
{shortDir || "Loading..."}
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onClose}
className="px-3 py-1.5 rounded-lg text-[13px] font-medium"
style={{
color: "var(--color-text-muted)",
background: "var(--color-surface-hover)",
border: "1px solid var(--color-border)",
}}
>
Cancel
</button>
<button
type="button"
onClick={handleSelectCurrent}
disabled={!displayDir}
className="px-3 py-1.5 rounded-lg text-[13px] font-medium disabled:opacity-40 disabled:cursor-not-allowed"
style={{
color: "white",
background: displayDir ? "var(--color-accent)" : "var(--color-border-strong)",
}}
>
Select This Folder
</button>
</div>
</div>
</div>
</div>
);
}