📦 NEW: workspace sidebar

This commit is contained in:
kumarabhirup 2026-02-13 15:16:36 -08:00
parent ac558d0a8e
commit f78cbbf563
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
9 changed files with 495 additions and 63 deletions

View File

@ -0,0 +1,96 @@
import { readFileSync, existsSync, statSync } from "node:fs";
import { resolve, normalize } from "node:path";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
/** MIME types for common file extensions. */
const MIME_MAP: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
svg: "image/svg+xml",
mp4: "video/mp4",
webm: "video/webm",
mp3: "audio/mpeg",
wav: "audio/wav",
ogg: "audio/ogg",
pdf: "application/pdf",
};
export async function GET(req: Request) {
const url = new URL(req.url);
const filePath = url.searchParams.get("path");
const raw = url.searchParams.get("raw") === "true";
if (!filePath) {
return Response.json(
{ error: "Missing 'path' query parameter" },
{ status: 400 },
);
}
// Normalize and resolve to prevent traversal
const resolved = resolve(normalize(filePath));
if (!existsSync(resolved)) {
return Response.json(
{ error: "File not found" },
{ status: 404 },
);
}
try {
const stat = statSync(resolved);
if (!stat.isFile()) {
return Response.json(
{ error: "Path is not a file" },
{ status: 400 },
);
}
} catch {
return Response.json(
{ error: "Cannot stat file" },
{ status: 500 },
);
}
// Raw mode: return binary content with appropriate MIME type
if (raw) {
try {
const buffer = readFileSync(resolved);
const ext = resolved.split(".").pop()?.toLowerCase() ?? "";
const mime = MIME_MAP[ext] ?? "application/octet-stream";
return new Response(buffer, {
headers: {
"Content-Type": mime,
"Content-Length": String(buffer.length),
},
});
} catch {
return Response.json(
{ error: "Cannot read file" },
{ status: 500 },
);
}
}
// Text mode: return content and type metadata (same shape as /api/workspace/file)
try {
const content = readFileSync(resolved, "utf-8");
const ext = resolved.split(".").pop()?.toLowerCase();
let type: "markdown" | "yaml" | "text" = "text";
if (ext === "md" || ext === "mdx") {type = "markdown";}
else if (ext === "yaml" || ext === "yml") {type = "yaml";}
return Response.json({ content, type });
} catch {
return Response.json(
{ error: "Cannot read file" },
{ status: 500 },
);
}
}

View File

@ -0,0 +1,97 @@
import { readdirSync, type Dirent } from "node:fs";
import { join, dirname, resolve } from "node:path";
import { resolveDenchRoot } from "@/lib/workspace";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
type BrowseNode = {
name: string;
path: string; // absolute path
type: "folder" | "file" | "document" | "database";
children?: BrowseNode[];
};
/** Directories to skip when browsing the filesystem. */
const SKIP_DIRS = new Set(["node_modules", ".git", ".Trash", "__pycache__", ".cache"]);
/** Build a depth-limited tree from an absolute directory. */
function buildBrowseTree(
absDir: string,
maxDepth: number,
currentDepth = 0,
): BrowseNode[] {
if (currentDepth >= maxDepth) {return [];}
let entries: Dirent[];
try {
entries = readdirSync(absDir, { withFileTypes: true });
} catch {
return [];
}
const sorted = entries
.filter((e) => !e.name.startsWith("."))
.filter((e) => !(e.isDirectory() && SKIP_DIRS.has(e.name)))
.toSorted((a, b) => {
if (a.isDirectory() && !b.isDirectory()) {return -1;}
if (!a.isDirectory() && b.isDirectory()) {return 1;}
return a.name.localeCompare(b.name);
});
const nodes: BrowseNode[] = [];
for (const entry of sorted) {
const absPath = join(absDir, entry.name);
if (entry.isDirectory()) {
const children = buildBrowseTree(absPath, maxDepth, currentDepth + 1);
nodes.push({
name: entry.name,
path: absPath,
type: "folder",
children: children.length > 0 ? children : undefined,
});
} else if (entry.isFile()) {
const ext = entry.name.split(".").pop()?.toLowerCase();
const isDocument = ext === "md" || ext === "mdx";
const isDatabase = ext === "duckdb" || ext === "sqlite" || ext === "sqlite3" || ext === "db";
nodes.push({
name: entry.name,
path: absPath,
type: isDatabase ? "database" : isDocument ? "document" : "file",
});
}
}
return nodes;
}
export async function GET(req: Request) {
const url = new URL(req.url);
let dir = url.searchParams.get("dir");
// Default to the dench workspace root
if (!dir) {
dir = resolveDenchRoot();
}
if (!dir) {
return Response.json(
{ entries: [], currentDir: "/", parentDir: null },
);
}
// Resolve and normalize the directory path
const resolved = resolve(dir);
const entries = buildBrowseTree(resolved, 3);
const parentDir = resolved === "/" ? null : dirname(resolved);
return Response.json({
entries,
currentDir: resolved,
parentDir,
});
}

View File

@ -311,7 +311,7 @@ export async function GET() {
if (skillsFolder) {tree.push(skillsFolder);}
const memoriesFolder = buildMemoriesVirtualFolder();
if (memoriesFolder) {tree.push(memoriesFolder);}
return Response.json({ tree, exists: false });
return Response.json({ tree, exists: false, workspaceRoot: null });
}
// Load objects from DuckDB for smart directory detection
@ -332,5 +332,5 @@ export async function GET() {
const memoriesFolder = buildMemoriesVirtualFolder();
if (memoriesFolder) {tree.push(memoriesFolder);}
return Response.json({ tree, exists: true });
return Response.json({ tree, exists: true, workspaceRoot: root });
}

View File

@ -1,6 +1,8 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { CronJob, CronRunLogEntry, CronRunsResponse } from "../../types/cron";
import { CronRunChat } from "./cron-run-chat";
@ -377,21 +379,31 @@ function RunCard({
</div>
)}
{/* Summary */}
{run.summary && (
<div className="mt-3 text-sm" style={{ color: "var(--color-text)" }}>
{run.summary}
</div>
)}
{/* Session transcript */}
{/* Session transcript (full chat) or summary fallback */}
{run.sessionId ? (
<div className="mt-4">
<CronRunChat sessionId={run.sessionId} />
</div>
) : run.summary ? (
<div className="mt-3">
<div
className="text-[11px] uppercase tracking-wider font-medium mb-2"
style={{ color: "var(--color-text-muted)" }}
>
Run Output
</div>
<div
className="chat-prose text-sm"
style={{ color: "var(--color-text)" }}
>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{run.summary}
</ReactMarkdown>
</div>
</div>
) : (
<div className="mt-3 text-xs" style={{ color: "var(--color-text-muted)" }}>
No session transcript available for this run.
No output recorded for this run.
</div>
)}
</div>

View File

@ -44,6 +44,12 @@ type FileManagerTreeProps = {
onSelect: (node: TreeNode) => void;
onRefresh: () => void;
compact?: boolean;
/** Parent directory path for ".." navigation. Null when at filesystem root or in workspace mode without browsing. */
parentDir?: string | null;
/** Callback when user clicks ".." to navigate up. */
onNavigateUp?: () => void;
/** Current browse directory (absolute path), or null when in workspace mode. */
browseDir?: string | null;
};
// --- System file detection (client-side mirror) ---
@ -600,7 +606,7 @@ function flattenVisible(tree: TreeNode[], expanded: Set<string>): TreeNode[] {
// --- Main Exported Component ---
export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact }: FileManagerTreeProps) {
export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact, parentDir, onNavigateUp, browseDir }: FileManagerTreeProps) {
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set());
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [renamingPath, setRenamingPath] = useState<string | null>(null);
@ -999,6 +1005,36 @@ export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact
onKeyDown={handleKeyDown}
onContextMenu={handleEmptyContextMenu}
>
{/* ".." navigation entry for browsing up */}
{parentDir != null && onNavigateUp && (
<div
role="treeitem"
tabIndex={-1}
onClick={onNavigateUp}
className="w-full flex items-center gap-1.5 py-1 px-2 rounded-md text-left text-sm transition-colors duration-100 cursor-pointer select-none"
style={{
paddingLeft: "8px",
color: "var(--color-text-muted)",
borderRadius: "6px",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background = "transparent";
}}
>
<span className="flex-shrink-0 w-4 h-4 flex items-center justify-center">
<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>
</span>
<span className="flex-shrink-0 flex items-center" style={{ color: "var(--color-text-muted)" }}>
<FolderIcon />
</span>
<span className="truncate flex-1">..</span>
</div>
)}
{tree.map((node) => (
<DraggableNode
key={node.path}

View File

@ -10,6 +10,14 @@ type WorkspaceSidebarProps = {
onRefresh: () => void;
orgName?: string;
loading?: boolean;
/** Current browse directory (absolute path), or null when in workspace mode. */
browseDir?: string | null;
/** Parent directory for ".." navigation. Null at filesystem root or when browsing is unavailable. */
parentDir?: string | null;
/** Navigate up one directory. */
onNavigateUp?: () => void;
/** Return to workspace mode from browse mode. */
onGoHome?: () => void;
};
function WorkspaceLogo() {
@ -50,6 +58,23 @@ function HomeIcon() {
);
}
function FolderOpenIcon() {
return (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2" />
</svg>
);
}
/* ─── Theme toggle ─── */
function ThemeToggle() {
@ -120,6 +145,12 @@ function ThemeToggle() {
);
}
/** Extract the directory name from an absolute path for display. */
function dirDisplayName(dir: string): string {
if (dir === "/") {return "/";}
return dir.split("/").pop() || dir;
}
export function WorkspaceSidebar({
tree,
activePath,
@ -127,7 +158,13 @@ export function WorkspaceSidebar({
onRefresh,
orgName,
loading,
browseDir,
parentDir,
onNavigateUp,
onGoHome,
}: WorkspaceSidebarProps) {
const isBrowsing = browseDir != null;
return (
<aside
className="flex flex-col h-screen border-r flex-shrink-0"
@ -142,31 +179,77 @@ export function WorkspaceSidebar({
className="flex items-center gap-2.5 px-4 py-3 border-b"
style={{ borderColor: "var(--color-border)" }}
>
<span
className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
style={{
background: "var(--color-accent-light)",
color: "var(--color-accent)",
}}
>
<WorkspaceLogo />
</span>
<div className="flex-1 min-w-0">
<div
className="text-sm font-medium truncate"
style={{ color: "var(--color-text)" }}
>
{orgName || "Workspace"}
</div>
<div
className="text-[11px]"
style={{
color: "var(--color-text-muted)",
}}
>
Ironclaw
</div>
</div>
{isBrowsing ? (
<>
<span
className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
style={{
background: "var(--color-surface-hover)",
color: "var(--color-text-muted)",
}}
>
<FolderOpenIcon />
</span>
<div className="flex-1 min-w-0">
<div
className="text-sm font-medium truncate"
style={{ color: "var(--color-text)" }}
title={browseDir}
>
{dirDisplayName(browseDir)}
</div>
<div
className="text-[11px] truncate"
style={{
color: "var(--color-text-muted)",
}}
title={browseDir}
>
{browseDir}
</div>
</div>
{/* Home button to return to workspace */}
{onGoHome && (
<button
type="button"
onClick={onGoHome}
className="p-1.5 rounded-lg flex-shrink-0"
style={{ color: "var(--color-text-muted)" }}
title="Return to workspace"
>
<HomeIcon />
</button>
)}
</>
) : (
<>
<span
className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
style={{
background: "var(--color-accent-light)",
color: "var(--color-accent)",
}}
>
<WorkspaceLogo />
</span>
<div className="flex-1 min-w-0">
<div
className="text-sm font-medium truncate"
style={{ color: "var(--color-text)" }}
>
{orgName || "Workspace"}
</div>
<div
className="text-[11px]"
style={{
color: "var(--color-text-muted)",
}}
>
Ironclaw
</div>
</div>
</>
)}
</div>
{/* Tree */}
@ -188,6 +271,9 @@ export function WorkspaceSidebar({
activePath={activePath}
onSelect={onSelect}
onRefresh={onRefresh}
parentDir={parentDir}
onNavigateUp={onNavigateUp}
browseDir={browseDir}
/>
)}
</div>
@ -197,14 +283,26 @@ export function WorkspaceSidebar({
className="px-3 py-2.5 border-t flex items-center justify-between"
style={{ borderColor: "var(--color-border)" }}
>
<a
href="/"
className="flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm"
style={{ color: "var(--color-text-muted)" }}
>
<HomeIcon />
Home
</a>
{isBrowsing && onGoHome ? (
<button
type="button"
onClick={onGoHome}
className="flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm"
style={{ color: "var(--color-text-muted)" }}
>
<WorkspaceLogo />
Workspace
</button>
) : (
<a
href="/"
className="flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm"
style={{ color: "var(--color-text-muted)" }}
>
<HomeIcon />
Home
</a>
)}
<ThemeToggle />
</div>
</aside>

View File

@ -14,23 +14,32 @@ export type TreeNode = {
/**
* Hook that fetches the workspace tree and subscribes to SSE file-change events
* for live reactivity. Falls back to polling if SSE is unavailable.
*
* Supports a browse mode: when `browseDir` is set, the tree is fetched from
* the browse API instead of the workspace tree API.
*/
export function useWorkspaceWatcher() {
const [tree, setTree] = useState<TreeNode[]>([]);
const [loading, setLoading] = useState(true);
const [exists, setExists] = useState(false);
// Browse mode state
const [browseDir, setBrowseDir] = useState<string | null>(null);
const [parentDir, setParentDir] = useState<string | null>(null);
const [workspaceRoot, setWorkspaceRoot] = useState<string | null>(null);
const mountedRef = useRef(true);
const retryDelayRef = useRef(1000);
// Fetch the tree from the API
const fetchTree = useCallback(async () => {
// Fetch the workspace tree from the tree API
const fetchWorkspaceTree = useCallback(async () => {
try {
const res = await fetch("/api/workspace/tree");
const data = await res.json();
if (mountedRef.current) {
setTree(data.tree ?? []);
setExists(data.exists ?? false);
setWorkspaceRoot(data.workspaceRoot ?? null);
setLoading(false);
}
} catch {
@ -38,12 +47,38 @@ export function useWorkspaceWatcher() {
}
}, []);
// Fetch a directory listing from the browse API
const fetchBrowseTree = useCallback(async (dir: string) => {
try {
setLoading(true);
const res = await fetch(`/api/workspace/browse?dir=${encodeURIComponent(dir)}`);
const data = await res.json();
if (mountedRef.current) {
setTree(data.entries ?? []);
setParentDir(data.parentDir ?? null);
setExists(true);
setLoading(false);
}
} catch {
if (mountedRef.current) {setLoading(false);}
}
}, []);
// Unified fetch based on current mode
const fetchTree = useCallback(async () => {
if (browseDir) {
await fetchBrowseTree(browseDir);
} else {
await fetchWorkspaceTree();
}
}, [browseDir, fetchBrowseTree, fetchWorkspaceTree]);
// Manual refresh for use after mutations
const refresh = useCallback(() => {
fetchTree();
}, [fetchTree]);
// Initial fetch
// Re-fetch when browseDir changes
useEffect(() => {
mountedRef.current = true;
fetchTree();
@ -52,8 +87,10 @@ export function useWorkspaceWatcher() {
};
}, [fetchTree]);
// SSE subscription with auto-reconnect and polling fallback
// SSE subscription -- only active in workspace mode (not browse mode)
useEffect(() => {
if (browseDir) {return;}
let eventSource: EventSource | null = null;
let pollInterval: ReturnType<typeof setInterval> | null = null;
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
@ -64,7 +101,7 @@ export function useWorkspaceWatcher() {
function debouncedRefetch() {
if (debounceTimer) {clearTimeout(debounceTimer);}
debounceTimer = setTimeout(() => {
if (alive) {fetchTree();}
if (alive) {fetchWorkspaceTree();}
}, 300);
}
@ -124,7 +161,7 @@ export function useWorkspaceWatcher() {
function startPolling() {
if (pollInterval || !alive) {return;}
pollInterval = setInterval(() => {
if (alive) {fetchTree();}
if (alive) {fetchWorkspaceTree();}
}, 5000);
}
@ -137,7 +174,7 @@ export function useWorkspaceWatcher() {
if (reconnectTimeout) {clearTimeout(reconnectTimeout);}
if (debounceTimer) {clearTimeout(debounceTimer);}
};
}, [fetchTree]);
}, [browseDir, fetchWorkspaceTree]);
return { tree, loading, exists, refresh };
return { tree, loading, exists, refresh, browseDir, setBrowseDir, parentDir, workspaceRoot };
}

View File

@ -104,11 +104,28 @@ function isVirtualPath(path: string): boolean {
return path.startsWith("~");
}
/** Pick the right file API endpoint based on virtual vs real paths. */
/** Detect absolute filesystem paths (browse mode). */
function isAbsolutePath(path: string): boolean {
return path.startsWith("/");
}
/** Pick the right file API endpoint based on virtual vs real vs absolute paths. */
function fileApiUrl(path: string): string {
return isVirtualPath(path)
? `/api/workspace/virtual-file?path=${encodeURIComponent(path)}`
: `/api/workspace/file?path=${encodeURIComponent(path)}`;
if (isVirtualPath(path)) {
return `/api/workspace/virtual-file?path=${encodeURIComponent(path)}`;
}
if (isAbsolutePath(path)) {
return `/api/workspace/browse-file?path=${encodeURIComponent(path)}`;
}
return `/api/workspace/file?path=${encodeURIComponent(path)}`;
}
/** Pick the right raw file URL for media preview. */
function rawFileUrl(path: string): string {
if (isAbsolutePath(path)) {
return `/api/workspace/browse-file?path=${encodeURIComponent(path)}&raw=true`;
}
return `/api/workspace/raw-file?path=${encodeURIComponent(path)}`;
}
/** Find a node in the tree by exact path. */
@ -197,8 +214,11 @@ function WorkspacePageInner() {
// Chat panel ref for session management
const chatRef = useRef<ChatPanelHandle>(null);
// Live-reactive tree via SSE watcher
const { tree, loading: treeLoading, exists: workspaceExists, refresh: refreshTree } = useWorkspaceWatcher();
// Live-reactive tree via SSE watcher (with browse-mode support)
const {
tree, loading: treeLoading, exists: workspaceExists, refresh: refreshTree,
browseDir, setBrowseDir, parentDir: browseParentDir, workspaceRoot,
} = useWorkspaceWatcher();
// Search index for @ mention fuzzy search (files + entries)
const { search: searchIndex } = useSearchIndex();
@ -333,8 +353,8 @@ function WorkspacePageInner() {
// Check if this is a media file (image/video/audio/pdf)
const mediaType = detectMediaType(node.name);
if (mediaType) {
const rawUrl = `/api/workspace/raw-file?path=${encodeURIComponent(node.path)}`;
setContent({ kind: "media", url: rawUrl, mediaType, filename: node.name, filePath: node.path });
const url = rawFileUrl(node.path);
setContent({ kind: "media", url, mediaType, filename: node.name, filePath: node.path });
return;
}
@ -399,7 +419,12 @@ function WorkspacePageInner() {
);
// Build the enhanced tree: real tree + Chats + Cron virtual folders at the bottom
// In browse mode, skip virtual folders (they only apply to workspace mode)
const enhancedTree = useMemo(() => {
if (browseDir) {
return tree;
}
const chatChildren: TreeNode[] = sessions.map((s) => ({
name: s.title || "Untitled chat",
path: `~chats/${s.id}`,
@ -439,7 +464,34 @@ function WorkspacePageInner() {
};
return [...tree, chatsFolder, cronFolder];
}, [tree, sessions, cronJobs]);
}, [tree, sessions, cronJobs, browseDir]);
// Compute the effective parentDir for ".." navigation.
// In browse mode: use browseParentDir from the API.
// In workspace mode: use the parent of the workspace root (allows escaping workspace).
const effectiveParentDir = useMemo(() => {
if (browseDir) {
return browseParentDir;
}
// In workspace mode, allow ".." to go up from workspace root
if (workspaceRoot) {
const parent = workspaceRoot === "/" ? null : workspaceRoot.split("/").slice(0, -1).join("/") || "/";
return parent;
}
return null;
}, [browseDir, browseParentDir, workspaceRoot]);
// Handle ".." navigation
const handleNavigateUp = useCallback(() => {
if (effectiveParentDir != null) {
setBrowseDir(effectiveParentDir);
}
}, [effectiveParentDir, setBrowseDir]);
// Return to workspace mode
const handleGoHome = useCallback(() => {
setBrowseDir(null);
}, [setBrowseDir]);
// Sync URL bar with active content / chat state.
// Uses window.location instead of searchParams in the comparison to
@ -647,6 +699,10 @@ function WorkspacePageInner() {
onRefresh={refreshTree}
orgName={context?.organization?.name}
loading={treeLoading}
browseDir={browseDir}
parentDir={effectiveParentDir}
onNavigateUp={handleNavigateUp}
onGoHome={handleGoHome}
/>
{/* Main content */}

File diff suppressed because one or more lines are too long