diff --git a/apps/web/app/api/workspace/path-info/route.ts b/apps/web/app/api/workspace/path-info/route.ts new file mode 100644 index 00000000000..3742d462e08 --- /dev/null +++ b/apps/web/app/api/workspace/path-info/route.ts @@ -0,0 +1,70 @@ +import { existsSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, normalize, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +/** + * GET /api/workspace/path-info?path=... + * Resolves and inspects a filesystem path for in-app preview routing. + */ +export async function GET(req: Request) { + const url = new URL(req.url); + const rawPath = url.searchParams.get("path"); + + if (!rawPath) { + return Response.json( + { error: "Missing 'path' query parameter" }, + { status: 400 }, + ); + } + + let candidatePath = rawPath; + + // Convert file:// URLs into local paths first. + if (candidatePath.startsWith("file://")) { + try { + candidatePath = fileURLToPath(candidatePath); + } catch { + return Response.json( + { error: "Invalid file URL" }, + { status: 400 }, + ); + } + } + + // Expand "~/..." to the current user's home directory. + const expandedPath = candidatePath.startsWith("~/") + ? candidatePath.replace(/^~/, homedir()) + : candidatePath; + const resolvedPath = resolve(normalize(expandedPath)); + + if (!existsSync(resolvedPath)) { + return Response.json( + { error: "Path not found", path: resolvedPath }, + { status: 404 }, + ); + } + + try { + const stat = statSync(resolvedPath); + const type = stat.isDirectory() + ? "directory" + : stat.isFile() + ? "file" + : "other"; + + return Response.json({ + path: resolvedPath, + name: basename(resolvedPath) || resolvedPath, + type, + }); + } catch { + return Response.json( + { error: "Cannot stat path", path: resolvedPath }, + { status: 500 }, + ); + } +} diff --git a/apps/web/app/components/chat-message.tsx b/apps/web/app/components/chat-message.tsx index 082b7b971e6..35b75847026 100644 --- a/apps/web/app/components/chat-message.tsx +++ b/apps/web/app/components/chat-message.tsx @@ -2,7 +2,7 @@ import dynamic from "next/dynamic"; import type { UIMessage } from "ai"; -import { memo, useState } from "react"; +import { memo, useMemo, useState } from "react"; import { motion, AnimatePresence } from "framer-motion"; import type { Components } from "react-markdown"; import ReactMarkdown from "react-markdown"; @@ -482,13 +482,41 @@ async function openFilePath(path: string, reveal = false) { } } +type FilePathClickHandler = ( + path: string, +) => Promise | boolean | void; + +/** Convert file:// URLs to local paths for in-app preview routing. */ +function normalizePathReference(value: string): string { + const trimmed = value.trim(); + if (!trimmed.startsWith("file://")) { + return trimmed; + } + try { + const url = new URL(trimmed); + if (url.protocol !== "file:") { + return trimmed; + } + const decoded = decodeURIComponent(url.pathname); + // Windows file URLs are /C:/... in URL form + if (/^\/[A-Za-z]:\//.test(decoded)) { + return decoded.slice(1); + } + return decoded; + } catch { + return trimmed; + } +} + /** Clickable file path inline code element */ function FilePathCode({ path, children, + onFilePathClick, }: { path: string; children: React.ReactNode; + onFilePathClick?: FilePathClickHandler; }) { const [status, setStatus] = useState<"idle" | "opening" | "error">("idle"); @@ -496,16 +524,26 @@ function FilePathCode({ e.preventDefault(); setStatus("opening"); try { - const res = await fetch("/api/workspace/open-file", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ path }), - }); - if (!res.ok) { - setStatus("error"); - setTimeout(() => setStatus("idle"), 2000); - } else { + if (onFilePathClick) { + const handled = await onFilePathClick(path); + if (handled === false) { + setStatus("error"); + setTimeout(() => setStatus("idle"), 2000); + return; + } setStatus("idle"); + } else { + const res = await fetch("/api/workspace/open-file", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path }), + }); + if (!res.ok) { + setStatus("error"); + setTimeout(() => setStatus("idle"), 2000); + } else { + setStatus("idle"); + } } } catch { setStatus("error"); @@ -524,7 +562,13 @@ function FilePathCode({ className={`inline-flex items-center gap-[0.2em] px-[0.3em] py-0 whitespace-nowrap max-w-full overflow-hidden text-ellipsis no-underline transition-colors duration-150 rounded-md text-[color:var(--color-accent)] border border-[color:var(--color-border)] bg-white/20 hover:bg-white/40 active:bg-white ${status === "opening" ? "cursor-wait opacity-70" : "cursor-pointer"}`} onClick={handleClick} onContextMenu={handleContextMenu} - title={status === "error" ? "File not found" : "Click to open · Right-click to reveal in Finder"} + title={ + status === "error" + ? "File not found" + : onFilePathClick + ? "Click to preview in workspace · Right-click to reveal in Finder" + : "Click to open · Right-click to reveal in Finder" + } > { - const isExternal = - href && (href.startsWith("http") || href.startsWith("//")); - return ( - - {children} - - ); - }, - // Render images with loading=lazy - img: ({ src, alt, ...props }) => ( - // eslint-disable-next-line @next/next/no-img-element - {alt - ), - // Syntax-highlighted fenced code blocks - pre: ({ children, ...props }) => { - // react-markdown wraps code blocks in
...
-		// Extract the code element to get lang + content
-		const child = Array.isArray(children) ? children[0] : children;
-		if (
-			child &&
-			typeof child === "object" &&
-			"type" in child &&
-			(child as { type?: string }).type === "code"
-		) {
-			const codeEl = child as {
-				props?: {
-					className?: string;
-					children?: string;
+function createMarkdownComponents(
+	onFilePathClick?: FilePathClickHandler,
+): Components {
+	return {
+		// Open external links in new tab
+		a: ({ href, children, ...props }) => {
+			const rawHref = typeof href === "string" ? href : "";
+			const normalizedHref = normalizePathReference(rawHref);
+			const isExternal =
+				rawHref && (rawHref.startsWith("http://") || rawHref.startsWith("https://") || rawHref.startsWith("//"));
+			const isWorkspaceAppLink = rawHref.startsWith("/workspace");
+			const isLocalPathLink =
+				!isWorkspaceAppLink &&
+				(Boolean(rawHref.startsWith("file://")) ||
+					looksLikeFilePath(normalizedHref));
+			return (
+				 {
+						if (!isLocalPathLink || !onFilePathClick) {return;}
+						e.preventDefault();
+						void onFilePathClick(normalizedHref);
+					}}
+				>
+					{children}
+				
+			);
+		},
+		// Render images with loading=lazy
+		img: ({ src, alt, ...props }) => (
+			// eslint-disable-next-line @next/next/no-img-element
+			{alt
+		),
+		// Syntax-highlighted fenced code blocks
+		pre: ({ children, ...props }) => {
+			// react-markdown wraps code blocks in 
...
+			// Extract the code element to get lang + content
+			const child = Array.isArray(children) ? children[0] : children;
+			if (
+				child &&
+				typeof child === "object" &&
+				"type" in child &&
+				(child as { type?: string }).type === "code"
+			) {
+				const codeEl = child as {
+					props?: {
+						className?: string;
+						children?: string;
+					};
 				};
-			};
-			const className = codeEl.props?.className ?? "";
-			const langMatch = className.match(/language-(\w+)/);
-			const lang = langMatch?.[1] ?? "";
-			const code =
-				typeof codeEl.props?.children === "string"
-					? codeEl.props.children.replace(/\n$/, "")
-					: "";
+				const className = codeEl.props?.className ?? "";
+				const langMatch = className.match(/language-(\w+)/);
+				const lang = langMatch?.[1] ?? "";
+				const code =
+					typeof codeEl.props?.children === "string"
+						? codeEl.props.children.replace(/\n$/, "")
+						: "";
 
-			// Diff language: render as DiffCard
-			if (lang === "diff") {
-				return ;
-			}
+				// Diff language: render as DiffCard
+				if (lang === "diff") {
+					return ;
+				}
 
-			// Known language: syntax-highlight with shiki
-			if (lang) {
-				return (
-					
-
- {lang} + // Known language: syntax-highlight with shiki + if (lang) { + return ( +
+
+ {lang} +
+
- -
+ ); + } + } + // Fallback: default pre rendering + return
{children}
; + }, + // Inline code — detect file paths and make them clickable + code: ({ children, className, ...props }) => { + // If this code has a language class, it's inside a
 and
+			// will be handled by the pre override above. Just return raw.
+			if (className?.startsWith("language-")) {
+				return (
+					
+						{children}
+					
 				);
 			}
-		}
-		// Fallback: default pre rendering
-		return 
{children}
; - }, - // Inline code — detect file paths and make them clickable - code: ({ children, className, ...props }) => { - // If this code has a language class, it's inside a
 and
-		// will be handled by the pre override above. Just return raw.
-		if (className?.startsWith("language-")) {
-			return (
-				
-					{children}
-				
-			);
-		}
 
-		// Check if the inline code content looks like a file path
-		const text = typeof children === "string" ? children : "";
-		if (text && looksLikeFilePath(text)) {
-			return {children};
-		}
+			// Check if the inline code content looks like a file path
+			const text = typeof children === "string" ? children : "";
+			const normalizedText = normalizePathReference(text);
+			if (normalizedText && looksLikeFilePath(normalizedText)) {
+				return (
+					
+						{children}
+					
+				);
+			}
 
-		// Regular inline code
-		return {children};
-	},
-};
+			// Regular inline code
+			return {children};
+		},
+	};
+}
 
 /* ─── Chat message ─── */
 
-export const ChatMessage = memo(function ChatMessage({ message, isStreaming, onSubagentClick }: { message: UIMessage; isStreaming?: boolean; onSubagentClick?: (task: string) => void }) {
+export const ChatMessage = memo(function ChatMessage({ message, isStreaming, onSubagentClick, onFilePathClick }: { message: UIMessage; isStreaming?: boolean; onSubagentClick?: (task: string) => void; onFilePathClick?: FilePathClickHandler }) {
 	const isUser = message.role === "user";
 	const segments = groupParts(message.parts);
+	const markdownComponents = useMemo(
+		() => createMarkdownComponents(onFilePathClick),
+		[onFilePathClick],
+	);
 
 	if (isUser) {
 		// User: right-aligned subtle pill
@@ -797,7 +866,7 @@ export const ChatMessage = memo(function ChatMessage({ message, isStreaming, onS
 			>
 				
 					{segment.text}
 				
diff --git a/apps/web/app/components/chat-panel.tsx b/apps/web/app/components/chat-panel.tsx
index 3e31a48191e..23b4b236e18 100644
--- a/apps/web/app/components/chat-panel.tsx
+++ b/apps/web/app/components/chat-panel.tsx
@@ -17,6 +17,12 @@ import {
 	type SelectedFile,
 } from "./file-picker-modal";
 import { ChatEditor, type ChatEditorHandle } from "./tiptap/chat-editor";
+import {
+	DropdownMenu,
+	DropdownMenuContent,
+	DropdownMenuItem,
+	DropdownMenuTrigger,
+} from "./ui/dropdown-menu";
 import { UnicodeSpinner } from "./unicode-spinner";
 
 // ── Attachment types & helpers ──
@@ -487,6 +493,8 @@ type ChatPanelProps = {
 	onSubagentSpawned?: (info: SubagentSpawnInfo) => void;
 	/** Called when user clicks a subagent card in the chat to view its output. */
 	onSubagentClick?: (task: string) => void;
+	/** Called when user clicks an inline file path in chat output. */
+	onFilePathClick?: (path: string) => Promise | boolean | void;
 	/** Called when user deletes the current session (e.g. from header menu). */
 	onDeleteSession?: (sessionId: string) => void;
 };
@@ -503,6 +511,7 @@ export const ChatPanel = forwardRef(
 			onSessionsChange,
 			onSubagentSpawned,
 			onSubagentClick,
+			onFilePathClick,
 			onDeleteSession,
 		},
 		ref,
@@ -541,21 +550,6 @@ export const ChatPanel = forwardRef(
 		// ── Message queue (messages to send after current run completes) ──
 		const [queuedMessages, setQueuedMessages] = useState([]);
 
-		// ── Header menu (3-dots) ──
-		const [headerMenuOpen, setHeaderMenuOpen] = useState(false);
-		const headerMenuRef = useRef(null);
-		useEffect(() => {
-			function handleClickOutside(e: MouseEvent) {
-				if (headerMenuRef.current && !headerMenuRef.current.contains(e.target as Node)) {
-					setHeaderMenuOpen(false);
-				}
-			}
-			if (headerMenuOpen) {
-				document.addEventListener("mousedown", handleClickOutside);
-				return () => document.removeEventListener("mousedown", handleClickOutside);
-			}
-		}, [headerMenuOpen]);
-
 		const filePath = fileContext?.path ?? null;
 
 		// ── Ref-based session ID for transport ──
@@ -1401,12 +1395,10 @@ export const ChatPanel = forwardRef(
 							
 						)}
 					
-
+
{currentSessionId && onDeleteSession && ( -
- - {headerMenuOpen && ( -
+ + onDeleteSession(currentSessionId)} > - -
- )} -
+ Delete + + + )} {compact && ( {onDeleteSession && ( -
- - {isMenuOpen && ( -
+ + e.stopPropagation()} + className="flex items-center justify-center w-6 h-6 rounded-md" + style={{ color: "var(--color-text-muted)" }} + title="More options" + aria-label="More options" > - -
- )} + + +
)}
diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 900a84d7a53..94cd2797a01 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -136,6 +136,10 @@ --shadow-xl: 0 8px 32px rgba(0, 0, 0, 0.5); } +/* Disable iframe pointer events during sidebar resize so the + drag isn't swallowed by embedded content (e.g. PDF viewer). */ +body.resizing iframe { pointer-events: none; } + /* ============================================================ Fonts — Bookerly (local) ============================================================ */ diff --git a/apps/web/app/workspace/page.tsx b/apps/web/app/workspace/page.tsx index 93b99acc3c1..cbc5446d4f8 100644 --- a/apps/web/app/workspace/page.tsx +++ b/apps/web/app/workspace/page.tsx @@ -104,6 +104,19 @@ type ContentState = | { kind: "cron-job"; jobId: string; job: CronJob } | { kind: "duckdb-missing" }; +type SidebarPreviewContent = + | { kind: "document"; data: FileData; title: string } + | { kind: "file"; data: FileData; filename: string } + | { kind: "code"; data: FileData; filename: string } + | { kind: "media"; url: string; mediaType: MediaType; filename: string; filePath: string } + | { kind: "database"; dbPath: string; filename: string } + | { kind: "directory"; path: string; name: string }; + +type ChatSidebarPreviewState = + | { status: "loading"; path: string; filename: string } + | { status: "error"; path: string; filename: string; message: string } + | { status: "ready"; path: string; filename: string; content: SidebarPreviewContent }; + type WebSession = { id: string; title: string; @@ -116,7 +129,7 @@ type WebSession = { /** Detect virtual paths (skills, memories) that live outside the main workspace. */ function isVirtualPath(path: string): boolean { - return path.startsWith("~"); + return path.startsWith("~") && !path.startsWith("~/"); } /** Detect absolute filesystem paths (browse mode). */ @@ -124,12 +137,17 @@ function isAbsolutePath(path: string): boolean { return path.startsWith("/"); } +/** Detect home-relative filesystem paths (e.g. ~/Desktop/file.txt). */ +function isHomeRelativePath(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 { if (isVirtualPath(path)) { return `/api/workspace/virtual-file?path=${encodeURIComponent(path)}`; } - if (isAbsolutePath(path)) { + if (isAbsolutePath(path) || isHomeRelativePath(path)) { return `/api/workspace/browse-file?path=${encodeURIComponent(path)}`; } return `/api/workspace/file?path=${encodeURIComponent(path)}`; @@ -137,7 +155,7 @@ function fileApiUrl(path: string): string { /** Pick the right raw file URL for media preview. */ function rawFileUrl(path: string): string { - if (isAbsolutePath(path)) { + if (isAbsolutePath(path) || isHomeRelativePath(path)) { return `/api/workspace/browse-file?path=${encodeURIComponent(path)}&raw=true`; } return `/api/workspace/raw-file?path=${encodeURIComponent(path)}`; @@ -146,7 +164,7 @@ function rawFileUrl(path: string): string { const LEFT_SIDEBAR_MIN = 200; const LEFT_SIDEBAR_MAX = 480; const RIGHT_SIDEBAR_MIN = 260; -const RIGHT_SIDEBAR_MAX = 600; +const RIGHT_SIDEBAR_MAX = 900; const STORAGE_LEFT = "ironclaw-workspace-left-sidebar-width"; const STORAGE_RIGHT = "ironclaw-workspace-right-sidebar-width"; @@ -189,9 +207,11 @@ function ResizeHandle({ document.removeEventListener("mouseup", up); document.body.style.removeProperty("user-select"); document.body.style.removeProperty("cursor"); + document.body.classList.remove("resizing"); }; document.body.style.setProperty("user-select", "none"); document.body.style.setProperty("cursor", "col-resize"); + document.body.classList.add("resizing"); document.addEventListener("mousemove", move); document.addEventListener("mouseup", up); }, @@ -230,6 +250,36 @@ function objectNameFromPath(path: string): string { return segments[segments.length - 1]; } +/** Infer a tree node type from filename extension for ad-hoc path previews. */ +function inferNodeTypeFromFileName(fileName: string): TreeNode["type"] { + const ext = fileName.split(".").pop()?.toLowerCase() ?? ""; + if (ext === "md" || ext === "mdx") {return "document";} + if (ext === "duckdb" || ext === "sqlite" || ext === "sqlite3" || ext === "db") {return "database";} + return "file"; +} + +/** Normalize chat path references (supports file:// URLs). */ +function normalizeChatPath(path: string): string { + const trimmed = path.trim(); + if (!trimmed.startsWith("file://")) { + return trimmed; + } + try { + const url = new URL(trimmed); + if (url.protocol !== "file:") { + return trimmed; + } + const decoded = decodeURIComponent(url.pathname); + // Windows file URLs are /C:/... in URL form + if (/^\/[A-Za-z]:\//.test(decoded)) { + return decoded.slice(1); + } + return decoded; + } catch { + return trimmed; + } +} + /** * Resolve a path with fallback strategies: * 1. Exact match @@ -316,6 +366,7 @@ function WorkspacePageInner() { const [activePath, setActivePath] = useState(null); const [content, setContent] = useState({ kind: "none" }); const [showChatSidebar, setShowChatSidebar] = useState(true); + const [chatSidebarPreview, setChatSidebarPreview] = useState(null); // Chat session state const [activeSessionId, setActiveSessionId] = useState(null); @@ -686,6 +737,169 @@ function WorkspacePageInner() { [loadContent, router, cronJobs, browseDir, workspaceRoot, openclawDir, setBrowseDir], ); + const loadSidebarPreviewFromNode = useCallback( + async (node: TreeNode): Promise => { + if (node.type === "folder") { + return { kind: "directory", path: node.path, name: node.name }; + } + if (node.type === "database") { + return { kind: "database", dbPath: node.path, filename: node.name }; + } + + const mediaType = detectMediaType(node.name); + if (mediaType) { + return { + kind: "media", + url: rawFileUrl(node.path), + mediaType, + filename: node.name, + filePath: node.path, + }; + } + + const res = await fetch(fileApiUrl(node.path)); + if (!res.ok) {return null;} + const data: FileData = await res.json(); + + if (node.type === "document" || data.type === "markdown") { + return { + kind: "document", + data, + title: node.name.replace(/\.mdx?$/, ""), + }; + } + if (isCodeFile(node.name)) { + return { kind: "code", data, filename: node.name }; + } + return { kind: "file", data, filename: node.name }; + }, + [], + ); + + // Open inline file-path mentions from chat. + // In chat mode, render a Dropbox-style preview in the right sidebar. + const handleFilePathClickFromChat = useCallback( + async (rawPath: string) => { + const inputPath = normalizeChatPath(rawPath); + if (!inputPath) {return false;} + + // Desktop behavior: always use right-sidebar preview for chat path clicks. + const shouldPreviewInSidebar = !isMobile; + + const openNode = async (node: TreeNode) => { + if (!shouldPreviewInSidebar) { + handleNodeSelect(node); + setShowChatSidebar(true); + return true; + } + + // Ensure we are in main-chat layout so the preview panel is visible. + if (activePath || content.kind !== "none") { + setActivePath(null); + setContent({ kind: "none" }); + router.replace("/workspace", { scroll: false }); + } + + setChatSidebarPreview({ + status: "loading", + path: node.path, + filename: node.name, + }); + const previewContent = await loadSidebarPreviewFromNode(node); + if (!previewContent) { + setChatSidebarPreview({ + status: "error", + path: node.path, + filename: node.name, + message: "Could not preview this file.", + }); + return false; + } + setChatSidebarPreview({ + status: "ready", + path: node.path, + filename: node.name, + content: previewContent, + }); + return true; + }; + + // For workspace-relative paths, prefer the live tree so we preserve semantics. + if ( + !isAbsolutePath(inputPath) && + !isHomeRelativePath(inputPath) && + !inputPath.startsWith("./") && + !inputPath.startsWith("../") + ) { + const node = resolveNode(tree, inputPath); + if (node) { + return await openNode(node); + } + } + + try { + const res = await fetch(`/api/workspace/path-info?path=${encodeURIComponent(inputPath)}`); + if (!res.ok) {return false;} + const info = await res.json() as { + path?: string; + name?: string; + type?: "file" | "directory" | "other"; + }; + if (!info.path || !info.name || !info.type) {return false;} + + // If this absolute path is inside the current workspace, map it + // back to a workspace-relative node first. + if (workspaceRoot && (info.path === workspaceRoot || info.path.startsWith(`${workspaceRoot}/`))) { + const relPath = info.path === workspaceRoot ? "" : info.path.slice(workspaceRoot.length + 1); + if (relPath) { + const node = resolveNode(tree, relPath); + if (node) { + return await openNode(node); + } + } + } + + if (info.type === "directory") { + const dirNode: TreeNode = { name: info.name, path: info.path, type: "folder" }; + if (shouldPreviewInSidebar) { + return await openNode(dirNode); + } + setBrowseDir(info.path); + setActivePath(info.path); + setContent({ + kind: "directory", + node: { name: info.name, path: info.path, type: "folder" }, + }); + setShowChatSidebar(true); + return true; + } + + if (info.type === "file") { + const fileNode: TreeNode = { + name: info.name, + path: info.path, + type: inferNodeTypeFromFileName(info.name), + }; + if (shouldPreviewInSidebar) { + return await openNode(fileNode); + } + const parentDir = info.path.split("/").slice(0, -1).join("/") || "/"; + if (isAbsolutePath(info.path)) { + setBrowseDir(parentDir); + } + await loadContent(fileNode); + setShowChatSidebar(true); + return true; + } + } catch { + // Ignore -- chat message bubble shows inline error state. + } + + return false; + }, + [activePath, content.kind, isMobile, tree, handleNodeSelect, workspaceRoot, loadSidebarPreviewFromNode, setBrowseDir, loadContent, router], + ); + // Build the enhanced tree: real tree + Cron virtual folder at the bottom // (Chat sessions live in the right sidebar, not in the tree.) // In browse mode, skip virtual folders (they only apply to workspace mode) @@ -1232,6 +1446,7 @@ function WorkspacePageInner() { onSessionsChange={refreshSessions} onSubagentSpawned={handleSubagentSpawned} onSubagentClick={handleSubagentClickFromChat} + onFilePathClick={handleFilePathClickFromChat} onDeleteSession={handleDeleteSession} compact={isMobile} /> @@ -1279,29 +1494,36 @@ function WorkspacePageInner() { className="flex shrink-0 flex-col" style={{ width: rightSidebarWidth, minWidth: rightSidebarWidth }} > - { - setActiveSessionId(sessionId); - setActiveSubagentKey(null); - void chatRef.current?.loadSession(sessionId); - }} - onNewSession={() => { - setActiveSessionId(null); - setActiveSubagentKey(null); - void chatRef.current?.newSession(); - router.replace("/workspace", { scroll: false }); - }} - onSelectSubagent={handleSelectSubagent} - onDeleteSession={handleDeleteSession} - width={rightSidebarWidth} - /> + {chatSidebarPreview ? ( + setChatSidebarPreview(null)} + /> + ) : ( + { + setActiveSessionId(sessionId); + setActiveSubagentKey(null); + void chatRef.current?.loadSession(sessionId); + }} + onNewSession={() => { + setActiveSessionId(null); + setActiveSubagentKey(null); + void chatRef.current?.newSession(); + router.replace("/workspace", { scroll: false }); + }} + onSelectSubagent={handleSelectSubagent} + onDeleteSession={handleDeleteSession} + width={rightSidebarWidth} + /> + )}
)} @@ -1354,6 +1576,7 @@ function WorkspacePageInner() { compact fileContext={fileContext} onFileChanged={handleFileChanged} + onFilePathClick={handleFilePathClickFromChat} /> @@ -1382,6 +1605,309 @@ function WorkspacePageInner() { ); } +function previewFileTypeBadge(filename: string): { label: string; color: string } { + const ext = filename.split(".").pop()?.toLowerCase() ?? ""; + if (ext === "pdf") {return { label: "PDF", color: "#ef4444" };} + if (["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "heic", "avif"].includes(ext)) {return { label: "Image", color: "#3b82f6" };} + if (["mp4", "webm", "mov", "avi", "mkv"].includes(ext)) {return { label: "Video", color: "#8b5cf6" };} + if (["mp3", "wav", "ogg", "m4a", "aac", "flac"].includes(ext)) {return { label: "Audio", color: "#f59e0b" };} + if (["md", "mdx"].includes(ext)) {return { label: "Markdown", color: "#10b981" };} + if (["ts", "tsx", "js", "jsx", "py", "go", "rs", "java", "rb", "swift", "kt", "c", "cpp", "h"].includes(ext)) {return { label: ext.toUpperCase(), color: "#3b82f6" };} + if (["json", "yaml", "yml", "toml", "xml", "csv"].includes(ext)) {return { label: ext.toUpperCase(), color: "#6b7280" };} + if (["duckdb", "sqlite", "sqlite3", "db"].includes(ext)) {return { label: "Database", color: "#6366f1" };} + return { label: ext.toUpperCase() || "File", color: "#6b7280" }; +} + +function shortenPreviewPath(p: string): string { + return p.replace(/^\/Users\/[^/]+/, "~").replace(/^\/home\/[^/]+/, "~"); +} + +function ChatSidebarPreview({ + preview, + onClose, +}: { + preview: ChatSidebarPreviewState; + onClose: () => void; +}) { + const badge = previewFileTypeBadge(preview.filename); + + const openInFinder = useCallback(async () => { + try { + await fetch("/api/workspace/open-file", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: preview.path, reveal: true }), + }); + } catch { /* ignore */ } + }, [preview.path]); + + const openWithSystem = useCallback(async () => { + try { + await fetch("/api/workspace/open-file", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: preview.path }), + }); + } catch { /* ignore */ } + }, [preview.path]); + + const downloadUrl = preview.status === "ready" && preview.content.kind === "media" + ? preview.content.url + : null; + + let body: React.ReactNode; + + if (preview.status === "loading") { + body = ( +
+ +

+ Loading preview... +

+
+ ); + } else if (preview.status === "error") { + body = ( +
+
+ + + + + +
+
+

+ Preview unavailable +

+

+ {preview.message} +

+
+
+ ); + } else { + const c = preview.content; + switch (c.kind) { + case "media": + if (c.mediaType === "pdf") { + // Hide the browser's built-in PDF toolbar for a cleaner look + const pdfUrl = c.url + (c.url.includes("#") ? "&" : "#") + "toolbar=0&navpanes=0&scrollbar=1"; + body = ( +