diff --git a/apps/web/app/api/workspace/open-file/route.ts b/apps/web/app/api/workspace/open-file/route.ts new file mode 100644 index 00000000000..6da26fc8351 --- /dev/null +++ b/apps/web/app/api/workspace/open-file/route.ts @@ -0,0 +1,80 @@ +import { exec } from "node:child_process"; +import { existsSync } from "node:fs"; +import { resolve, normalize } from "node:path"; +import { homedir } from "node:os"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +/** + * POST /api/workspace/open-file + * Opens a file or directory using the system's default application. + * On macOS this uses `open`, on Linux `xdg-open`. + */ +export async function POST(req: Request) { + let body: { path?: string; reveal?: boolean }; + try { + body = await req.json(); + } catch { + return Response.json( + { error: "Invalid JSON body" }, + { status: 400 }, + ); + } + + const rawPath = body.path; + if (!rawPath || typeof rawPath !== "string") { + return Response.json( + { error: "Missing 'path' in request body" }, + { status: 400 }, + ); + } + + // Expand ~ to home directory + const expanded = rawPath.startsWith("~/") + ? rawPath.replace(/^~/, homedir()) + : rawPath; + + const resolved = resolve(normalize(expanded)); + + if (!existsSync(resolved)) { + return Response.json( + { error: "File not found", path: resolved }, + { status: 404 }, + ); + } + + const platform = process.platform; + const reveal = body.reveal === true; + + let cmd: string; + if (platform === "darwin") { + // macOS: use `open` — `-R` reveals in Finder instead of opening + cmd = reveal + ? `open -R ${JSON.stringify(resolved)}` + : `open ${JSON.stringify(resolved)}`; + } else if (platform === "linux") { + // Linux: xdg-open (no reveal equivalent) + cmd = `xdg-open ${JSON.stringify(resolved)}`; + } else { + return Response.json( + { error: `Unsupported platform: ${platform}` }, + { status: 400 }, + ); + } + + return new Promise((res) => { + exec(cmd, (error) => { + if (error) { + res( + Response.json( + { error: `Failed to open file: ${error.message}` }, + { status: 500 }, + ), + ); + } else { + res(Response.json({ ok: true, path: resolved })); + } + }); + }); +} diff --git a/apps/web/app/components/chat-message.tsx b/apps/web/app/components/chat-message.tsx index 09c25867ca1..80a85205b46 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 } from "react"; +import { memo, useState } from "react"; import { motion, AnimatePresence } from "framer-motion"; import type { Components } from "react-markdown"; import ReactMarkdown from "react-markdown"; @@ -420,6 +420,120 @@ function AttachedFilesCard({ paths }: { paths: string[] }) { ); } +/* ─── File path detection for clickable inline code ─── */ + +/** + * Detect whether an inline code string looks like a local file/directory path. + * Matches patterns like: + * ~/Downloads/file.pdf + * /Users/name/Documents/file.txt + * /home/user/file.py + * ./relative/path + * ../parent/path + * /etc/config + */ +const FILE_PATH_RE = + /^(?:~\/|\.\.?\/|\/(?:Users|home|tmp|var|etc|opt|usr|Library|Applications|Downloads|Documents|Desktop)\b)[^\s]*$/; + +function looksLikeFilePath(text: string): boolean { + if (!text || text.length < 2 || text.length > 500) {return false;} + return FILE_PATH_RE.test(text.trim()); +} + +/** Open a file path using the system default application. */ +async function openFilePath(path: string, reveal = false) { + try { + const res = await fetch("/api/workspace/open-file", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path, reveal }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + console.error("Failed to open file:", data); + } + } catch (err) { + console.error("Failed to open file:", err); + } +} + +/** Clickable file path inline code element */ +function FilePathCode({ + path, + children, +}: { + path: string; + children: React.ReactNode; +}) { + const [status, setStatus] = useState<"idle" | "opening" | "error">("idle"); + + const handleClick = async (e: React.MouseEvent) => { + 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 { + setStatus("idle"); + } + } catch { + setStatus("error"); + setTimeout(() => setStatus("idle"), 2000); + } + }; + + const handleContextMenu = async (e: React.MouseEvent) => { + // Right-click reveals in Finder instead of opening + e.preventDefault(); + await openFilePath(path, true); + }; + + return ( + + + {status === "error" ? ( + <> + + + + + ) : ( + <> + + + + )} + + {children} + + ); +} + /* ─── Markdown component overrides for chat ─── */ const mdComponents: Components = { @@ -491,7 +605,7 @@ const mdComponents: Components = { // Fallback: default pre rendering return
{children}
; }, - // Inline code (no highlighting needed) + // 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.
@@ -502,7 +616,14 @@ const mdComponents: Components = {
 				
 			);
 		}
-		// Inline code
+
+		// Check if the inline code content looks like a file path
+		const text = typeof children === "string" ? children : "";
+		if (text && looksLikeFilePath(text)) {
+			return {children};
+		}
+
+		// Regular inline code
 		return {children};
 	},
 };
diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css
index 80392133772..1d62059e848 100644
--- a/apps/web/app/globals.css
+++ b/apps/web/app/globals.css
@@ -980,6 +980,36 @@ a,
   padding: 0.15em 0.35em;
 }
 
+/* Clickable file path inline code */
+.chat-prose code.file-path-code {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.2em;
+  padding: 0em 0.3em;
+  cursor: pointer;
+  transition:
+    background 0.15s ease,
+    border-color 0.15s ease,
+    color 0.15s ease;
+  color: var(--color-accent);
+  border: 1px solid var(--color-border);
+  background: rgba(255, 255, 255, 0.2);
+  text-decoration: none;
+}
+
+.chat-prose code.file-path-code:hover {
+  background: rgba(255, 255, 255, 0.4);
+}
+
+.chat-prose code.file-path-code:active {
+  background: rgba(255, 255, 255, 1);
+}
+
+.chat-prose code.file-path-code .file-path-icon {
+  flex-shrink: 0;
+  opacity: 0.6;
+}
+
 .chat-prose pre {
   background: var(--color-surface);
   border: 1px solid var(--color-border);