Integrate file preview on the side

This commit is contained in:
Mark 2026-02-19 17:46:54 -08:00
parent 4f80c60f88
commit a0ba55feec
11 changed files with 1262 additions and 230 deletions

View File

@ -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 },
);
}
}

View File

@ -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> | 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"
}
>
<svg
width="12"
@ -557,103 +601,128 @@ function FilePathCode({
/* ─── Markdown component overrides for chat ─── */
const mdComponents: Components = {
// Open external links in new tab
a: ({ href, children, ...props }) => {
const isExternal =
href && (href.startsWith("http") || href.startsWith("//"));
return (
<a
href={href}
{...(isExternal
? { target: "_blank", rel: "noopener noreferrer" }
: {})}
{...props}
>
{children}
</a>
);
},
// Render images with loading=lazy
img: ({ src, alt, ...props }) => (
// eslint-disable-next-line @next/next/no-img-element
<img src={src} alt={alt ?? ""} loading="lazy" {...props} />
),
// Syntax-highlighted fenced code blocks
pre: ({ children, ...props }) => {
// react-markdown wraps code blocks in <pre><code>...
// 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 (
<a
href={href}
{...(isExternal
? { target: "_blank", rel: "noopener noreferrer" }
: {})}
{...props}
onClick={(e) => {
if (!isLocalPathLink || !onFilePathClick) {return;}
e.preventDefault();
void onFilePathClick(normalizedHref);
}}
>
{children}
</a>
);
},
// Render images with loading=lazy
img: ({ src, alt, ...props }) => (
// eslint-disable-next-line @next/next/no-img-element
<img src={src} alt={alt ?? ""} loading="lazy" {...props} />
),
// Syntax-highlighted fenced code blocks
pre: ({ children, ...props }) => {
// react-markdown wraps code blocks in <pre><code>...
// 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 <DiffCard diff={code} />;
}
// Diff language: render as DiffCard
if (lang === "diff") {
return <DiffCard diff={code} />;
}
// Known language: syntax-highlight with shiki
if (lang) {
return (
<div className="chat-code-block">
<div
className="chat-code-lang"
>
{lang}
// Known language: syntax-highlight with shiki
if (lang) {
return (
<div className="chat-code-block">
<div
className="chat-code-lang"
>
{lang}
</div>
<SyntaxBlock code={code} lang={lang} />
</div>
<SyntaxBlock code={code} lang={lang} />
</div>
);
}
}
// Fallback: default pre rendering
return <pre {...props}>{children}</pre>;
},
// Inline code — detect file paths and make them clickable
code: ({ children, className, ...props }) => {
// If this code has a language class, it's inside a <pre> and
// will be handled by the pre override above. Just return raw.
if (className?.startsWith("language-")) {
return (
<code className={className} {...props}>
{children}
</code>
);
}
}
// Fallback: default pre rendering
return <pre {...props}>{children}</pre>;
},
// Inline code — detect file paths and make them clickable
code: ({ children, className, ...props }) => {
// If this code has a language class, it's inside a <pre> and
// will be handled by the pre override above. Just return raw.
if (className?.startsWith("language-")) {
return (
<code className={className} {...props}>
{children}
</code>
);
}
// Check if the inline code content looks like a file path
const text = typeof children === "string" ? children : "";
if (text && looksLikeFilePath(text)) {
return <FilePathCode path={text}>{children}</FilePathCode>;
}
// 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 (
<FilePathCode path={normalizedText} onFilePathClick={onFilePathClick}>
{children}
</FilePathCode>
);
}
// Regular inline code
return <code {...props}>{children}</code>;
},
};
// Regular inline code
return <code {...props}>{children}</code>;
},
};
}
/* ─── 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
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={mdComponents}
components={markdownComponents}
>
{segment.text}
</ReactMarkdown>

View File

@ -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> | 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<ChatPanelHandle, ChatPanelProps>(
onSessionsChange,
onSubagentSpawned,
onSubagentClick,
onFilePathClick,
onDeleteSession,
},
ref,
@ -541,21 +550,6 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
// ── Message queue (messages to send after current run completes) ──
const [queuedMessages, setQueuedMessages] = useState<QueuedMessage[]>([]);
// ── Header menu (3-dots) ──
const [headerMenuOpen, setHeaderMenuOpen] = useState(false);
const headerMenuRef = useRef<HTMLDivElement>(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<ChatPanelHandle, ChatPanelProps>(
</h2>
)}
</div>
<div className="flex items-center gap-1 shrink-0" ref={headerMenuRef}>
<div className="flex items-center gap-1 shrink-0">
{currentSessionId && onDeleteSession && (
<div className="relative">
<button
type="button"
onClick={() => setHeaderMenuOpen((open) => !open)}
<DropdownMenu>
<DropdownMenuTrigger
className="p-1.5 rounded-lg"
style={{ color: "var(--color-text-muted)" }}
title="More options"
@ -1426,33 +1418,16 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
<circle cx="5" cy="12" r="1" />
<circle cx="19" cy="12" r="1" />
</svg>
</button>
{headerMenuOpen && (
<div
className="absolute right-0 top-full z-50 mt-0.5 py-1 rounded-lg shadow-lg border whitespace-nowrap"
style={{
background: "var(--color-surface)",
borderColor: "var(--color-border)",
}}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom">
<DropdownMenuItem
variant="destructive"
onSelect={() => onDeleteSession(currentSessionId)}
>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setHeaderMenuOpen(false);
onDeleteSession(currentSessionId);
}}
className="w-full text-left px-3 py-2 text-sm transition-colors rounded-md hover:opacity-90"
style={{
color: "var(--color-error)",
background: "transparent",
}}
>
Delete
</button>
</div>
)}
</div>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{compact && (
<button
@ -1590,6 +1565,7 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
message={message}
isStreaming={isStreaming && i === messages.length - 1}
onSubagentClick={onSubagentClick}
onFilePathClick={onFilePathClick}
/>
))}
{showInlineSpinner && (
@ -1655,10 +1631,10 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
>
<div
data-chat-drop-target=""
className="rounded-3xl overflow-hidden border-2 shadow-[0_0_32px_rgba(0,0,0,0.07)] transition-[outline,box-shadow] duration-150 ease-out data-drag-hover:outline-2 data-drag-hover:outline-dashed data-drag-hover:outline-(--color-accent) data-drag-hover:-outline-offset-2 data-drag-hover:shadow-[0_0_0_4px_color-mix(in_srgb,var(--color-accent)_15%,transparent),0_0_32px_rgba(0,0,0,0.07)]!"
className="rounded-3xl overflow-hidden border shadow-[0_0_32px_rgba(0,0,0,0.07)] transition-[outline,box-shadow] duration-150 ease-out data-drag-hover:outline-2 data-drag-hover:outline-dashed data-drag-hover:outline-(--color-accent) data-drag-hover:-outline-offset-2 data-drag-hover:shadow-[0_0_0_4px_color-mix(in_srgb,var(--color-accent)_15%,transparent),0_0_32px_rgba(0,0,0,0.07)]!"
style={{
background: "var(--color-surface)",
borderColor: "var(--color-border-strong)",
borderColor: "var(--color-border)",
}}
onDragOver={(e) => {
if (

View File

@ -0,0 +1,320 @@
"use client";
import * as React from "react";
import { Menu as MenuPrimitive } from "@base-ui/react/menu";
import { ChevronRightIcon, CheckIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function DropdownMenu({
...props
}: React.ComponentProps<typeof MenuPrimitive.Root>) {
return (
<MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
);
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof MenuPrimitive.Portal>) {
return (
<MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
);
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof MenuPrimitive.Trigger>) {
return (
<MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
);
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: React.ComponentProps<typeof MenuPrimitive.Popup> &
Pick<
React.ComponentProps<typeof MenuPrimitive.Positioner>,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-[100] outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn(
"min-w-32 rounded-lg p-1 shadow-md duration-100 overflow-x-hidden overflow-y-auto outline-none",
"bg-[var(--color-surface)] text-[var(--color-text)] border border-[var(--color-border)]",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
);
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof MenuPrimitive.Group>) {
return (
<MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof MenuPrimitive.GroupLabel> & {
inset?: boolean;
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-[var(--color-text-muted)]",
inset && "pl-7",
className,
)}
{...props}
/>
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
onSelect,
onClick,
...props
}: React.ComponentProps<typeof MenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
onSelect?: () => void;
}) {
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
onClick?.(e);
onSelect?.();
};
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"gap-1.5 rounded-md px-1.5 py-1 text-sm relative flex cursor-default items-center outline-none select-none",
"focus:bg-[var(--color-surface-hover)] focus:text-[var(--color-text)]",
"data-[variant=destructive]:text-[var(--color-error)] data-[variant=destructive]:focus:bg-[var(--color-error)]/10 data-[variant=destructive]:focus:text-[var(--color-error)]",
inset && "pl-7",
"[&_svg]:pointer-events-none [&_svg]:shrink-0",
"data-disabled:pointer-events-none data-disabled:opacity-50",
className,
)}
onClick={handleClick}
{...props}
/>
);
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof MenuPrimitive.SubmenuRoot>) {
return (
<MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
);
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof MenuPrimitive.SubmenuTrigger> & {
inset?: boolean;
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-[var(--color-surface-hover)] focus:text-[var(--color-text)] data-open:bg-[var(--color-surface-hover)] data-open:text-[var(--color-text)]",
"gap-1.5 rounded-md px-1.5 py-1 text-sm flex cursor-default items-center outline-none select-none",
inset && "pl-7",
"[&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</MenuPrimitive.SubmenuTrigger>
);
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn("min-w-[96px]", className)}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof MenuPrimitive.CheckboxItem> & {
inset?: boolean;
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"focus:bg-[var(--color-surface-hover)] focus:text-[var(--color-text)]",
"gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm relative flex cursor-default items-center outline-none select-none",
inset && "pl-7",
"[&_svg]:pointer-events-none [&_svg]:shrink-0",
"data-disabled:pointer-events-none data-disabled:opacity-50",
className,
)}
checked={checked}
{...props}
>
<span
className="absolute right-2 flex items-center justify-center pointer-events-none"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon className="size-4" />
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof MenuPrimitive.RadioGroup>) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: React.ComponentProps<typeof MenuPrimitive.RadioItem> & {
inset?: boolean;
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"focus:bg-[var(--color-surface-hover)] focus:text-[var(--color-text)]",
"gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm relative flex cursor-default items-center outline-none select-none",
inset && "pl-7",
"[&_svg]:pointer-events-none [&_svg]:shrink-0",
"data-disabled:pointer-events-none data-disabled:opacity-50",
className,
)}
{...props}
>
<span
className="absolute right-2 flex items-center justify-center pointer-events-none"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon className="size-4" />
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof MenuPrimitive.Separator>) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn(
"bg-[var(--color-border)] -mx-1 my-1 h-px",
className,
)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-[var(--color-text-muted)] ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};

View File

@ -1,7 +1,13 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { UnicodeSpinner } from "../unicode-spinner";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
type WebSession = {
id: string;
@ -154,21 +160,6 @@ export function ChatSessionsSidebar({
loading = false,
}: ChatSessionsSidebarProps) {
const [hoveredId, setHoveredId] = useState<string | null>(null);
const [menuOpenId, setMenuOpenId] = useState<string | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
// Close menu on outside click
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuOpenId(null);
}
}
if (menuOpenId !== null) {
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}
}, [menuOpenId]);
const handleSelect = useCallback(
(id: string) => {
@ -187,9 +178,7 @@ export function ChatSessionsSidebar({
);
const handleDeleteSession = useCallback(
(sessionId: string, e: React.MouseEvent) => {
e.stopPropagation();
setMenuOpenId(null);
(sessionId: string) => {
onDeleteSession?.(sessionId);
},
[onDeleteSession],
@ -279,14 +268,12 @@ export function ChatSessionsSidebar({
{group.sessions.map((session) => {
const isActive = session.id === activeSessionId && !activeSubagentKey;
const isHovered = session.id === hoveredId;
const isMenuOpen = menuOpenId === session.id;
const showMore = isHovered || isMenuOpen;
const showMore = isHovered;
const isStreamingSession = streamingSessionIds?.has(session.id) ?? false;
const sessionSubagents = subagentsByParent.get(session.id);
return (
<div
key={session.id}
ref={isMenuOpen ? menuRef : undefined}
className="group relative"
onMouseEnter={() => setHoveredId(session.id)}
onMouseLeave={() => setHoveredId(null)}
@ -344,41 +331,26 @@ export function ChatSessionsSidebar({
</div>
</button>
{onDeleteSession && (
<div className="relative w-7 shrink-0 flex flex-col items-end">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setMenuOpenId((id) => (id === session.id ? null : session.id));
}}
className={`flex items-center justify-center w-7 h-full rounded-r-lg transition-opacity ${showMore ? "opacity-100" : "opacity-0"}`}
style={{ color: "var(--color-text-muted)" }}
title="More options"
aria-label="More options"
>
<MoreHorizontalIcon />
</button>
{isMenuOpen && (
<div
className="absolute top-full right-0 z-50 mt-0.5 py-1 rounded-lg shadow-lg border whitespace-nowrap"
style={{
background: "var(--color-surface)",
borderColor: "var(--color-border)",
}}
<div className={`shrink-0 flex items-center pr-1 transition-opacity ${showMore ? "opacity-100" : "opacity-0"}`}>
<DropdownMenu>
<DropdownMenuTrigger
onClick={(e) => 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"
>
<button
type="button"
onClick={(e) => handleDeleteSession(session.id, e)}
className="w-full text-left px-3 py-1.5 text-xs transition-colors rounded-md hover:opacity-90"
style={{
color: "var(--color-error)",
background: "transparent",
}}
<MoreHorizontalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom">
<DropdownMenuItem
variant="destructive"
onSelect={() => handleDeleteSession(session.id)}
>
Delete
</button>
</div>
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>

View File

@ -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)
============================================================ */

View File

@ -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<string | null>(null);
const [content, setContent] = useState<ContentState>({ kind: "none" });
const [showChatSidebar, setShowChatSidebar] = useState(true);
const [chatSidebarPreview, setChatSidebarPreview] = useState<ChatSidebarPreviewState | null>(null);
// Chat session state
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
@ -686,6 +737,169 @@ function WorkspacePageInner() {
[loadContent, router, cronJobs, browseDir, workspaceRoot, openclawDir, setBrowseDir],
);
const loadSidebarPreviewFromNode = useCallback(
async (node: TreeNode): Promise<SidebarPreviewContent | null> => {
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 }}
>
<ChatSessionsSidebar
sessions={sessions}
activeSessionId={activeSessionId}
activeSessionTitle={activeSessionTitle}
streamingSessionIds={streamingSessionIds}
subagents={subagents}
activeSubagentKey={activeSubagentKey}
loading={sessionsLoading}
onSelectSession={(sessionId) => {
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 ? (
<ChatSidebarPreview
preview={chatSidebarPreview}
onClose={() => setChatSidebarPreview(null)}
/>
) : (
<ChatSessionsSidebar
sessions={sessions}
activeSessionId={activeSessionId}
activeSessionTitle={activeSessionTitle}
streamingSessionIds={streamingSessionIds}
subagents={subagents}
activeSubagentKey={activeSubagentKey}
loading={sessionsLoading}
onSelectSession={(sessionId) => {
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}
/>
)}
</div>
</>
)}
@ -1354,6 +1576,7 @@ function WorkspacePageInner() {
compact
fileContext={fileContext}
onFileChanged={handleFileChanged}
onFilePathClick={handleFilePathClickFromChat}
/>
</aside>
</>
@ -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 = (
<div className="flex flex-col h-full items-center justify-center gap-3">
<UnicodeSpinner
name="braille"
className="text-2xl"
style={{ color: "var(--color-text-muted)" }}
/>
<p className="text-xs" style={{ color: "var(--color-text-muted)" }}>
Loading preview...
</p>
</div>
);
} else if (preview.status === "error") {
body = (
<div className="flex flex-col h-full items-center justify-center gap-4 px-6">
<div
className="w-14 h-14 rounded-2xl flex items-center justify-center"
style={{ background: "color-mix(in srgb, var(--color-error) 10%, transparent)" }}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--color-error)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="15" x2="9" y1="9" y2="15" />
<line x1="9" x2="15" y1="9" y2="15" />
</svg>
</div>
<div className="text-center">
<p className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
Preview unavailable
</p>
<p className="text-xs mt-1" style={{ color: "var(--color-text-muted)" }}>
{preview.message}
</p>
</div>
</div>
);
} 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 = (
<iframe
src={pdfUrl}
className="w-full h-full"
style={{ border: "none", colorScheme: "light" }}
title={`Preview: ${c.filename}`}
/>
);
} else if (c.mediaType === "image") {
body = (
<div className="flex items-center justify-center h-full p-4 overflow-auto" style={{ background: "var(--color-bg)" }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={c.url}
alt={c.filename}
className="max-w-full max-h-full object-contain rounded-lg"
style={{ boxShadow: "0 2px 16px rgba(0,0,0,0.08)" }}
draggable={false}
/>
</div>
);
} else if (c.mediaType === "video") {
body = (
<div className="flex items-center justify-center h-full p-4" style={{ background: "#000" }}>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video src={c.url} controls className="max-w-full max-h-full rounded-lg" />
</div>
);
} else if (c.mediaType === "audio") {
body = (
<div className="flex flex-col items-center justify-center h-full gap-6 px-6">
<div
className="w-20 h-20 rounded-2xl flex items-center justify-center"
style={{ background: "linear-gradient(135deg, #f59e0b20, #f59e0b08)" }}
>
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" />
</svg>
</div>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<audio src={c.url} controls className="w-full" />
</div>
);
}
break;
case "document":
body = (
<div className="p-5 overflow-auto h-full">
<div className="workspace-prose text-sm">
<DocumentView
content={c.data.content}
title={c.title}
/>
</div>
</div>
);
break;
case "code":
body = (
<div className="overflow-auto h-full">
<CodeViewer content={c.data.content} filename={c.filename} />
</div>
);
break;
case "file":
body = (
<div className="overflow-auto h-full">
<FileViewer content={c.data.content} filename={c.filename} type={c.data.type === "yaml" ? "yaml" : "text"} />
</div>
);
break;
case "database":
body = (
<div className="overflow-auto h-full">
<DatabaseViewer dbPath={c.dbPath} filename={c.filename} />
</div>
);
break;
case "directory":
body = (
<div className="flex flex-col items-center justify-center h-full gap-4 px-6">
<div
className="w-14 h-14 rounded-2xl flex items-center justify-center"
style={{ background: "color-mix(in srgb, var(--color-accent) 10%, transparent)" }}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
</svg>
</div>
<p className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
{c.name}
</p>
</div>
);
break;
default:
body = null;
}
}
return (
<aside
className="h-full border-l flex flex-col"
style={{
borderColor: "var(--color-border)",
background: "var(--color-bg)",
}}
>
{/* Header: close + filename + badge + actions */}
<div
className="px-3 py-2.5 flex items-center gap-2 flex-shrink-0"
style={{ borderBottom: "1px solid var(--color-border)" }}
>
<button
type="button"
onClick={onClose}
className="p-1 rounded-md transition-colors flex-shrink-0"
style={{ color: "var(--color-text-muted)" }}
title="Close preview"
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)"; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "transparent"; }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
</svg>
</button>
<span className="text-[13px] font-medium truncate min-w-0" style={{ color: "var(--color-text)" }}>
{preview.filename}
</span>
<span
className="text-[10px] font-medium px-1.5 py-[1px] rounded flex-shrink-0"
style={{
background: `${badge.color}14`,
color: badge.color,
}}
>
{badge.label}
</span>
<div className="flex items-center gap-0.5 ml-auto flex-shrink-0">
<button
type="button"
onClick={openWithSystem}
className="p-1.5 rounded-md transition-colors"
style={{ color: "var(--color-text-muted)" }}
title="Open with default app"
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)"; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "transparent"; }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 3h6v6" /><path d="M10 14 21 3" /><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
</svg>
</button>
{downloadUrl && (
<a
href={downloadUrl}
download={preview.filename}
className="p-1.5 rounded-md transition-colors"
style={{ color: "var(--color-text-muted)" }}
title="Download"
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)"; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "transparent"; }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" x2="12" y1="15" y2="3" />
</svg>
</a>
)}
<button
type="button"
onClick={openInFinder}
className="p-1.5 rounded-md transition-colors"
style={{ color: "var(--color-text-muted)" }}
title="Reveal in Finder"
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)"; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "transparent"; }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
</svg>
</button>
</div>
</div>
{/* Preview body */}
<div className="flex-1 min-h-0 overflow-hidden">
{body}
</div>
{/* Footer path */}
<div
className="px-3 py-1.5 border-t flex-shrink-0"
style={{ borderColor: "var(--color-border)" }}
>
<p
className="text-[10px] truncate"
style={{ color: "var(--color-text-muted)", fontFamily: "'SF Mono', 'Fira Code', monospace" }}
title={preview.path}
>
{shortenPreviewPath(preview.path)}
</p>
</div>
</aside>
);
}
// --- Content Renderer ---
function ContentRenderer({

6
apps/web/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@ -11,6 +11,7 @@
},
"dependencies": {
"@ai-sdk/react": "^3.0.75",
"@base-ui/react": "^1.2.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@ -32,8 +33,10 @@
"@tiptap/starter-kit": "^3.19.0",
"@tiptap/suggestion": "^3.19.0",
"ai": "^6.0.73",
"clsx": "^2.1.1",
"framer-motion": "^12.34.0",
"fuse.js": "^7.1.0",
"lucide-react": "^0.575.0",
"next": "^15.3.3",
"next-themes": "^0.4.6",
"react": "^19.1.0",
@ -43,6 +46,7 @@
"recharts": "^3.7.0",
"remark-gfm": "^4.0.1",
"shiki": "^3.22.0",
"tailwind-merge": "^3.5.0",
"unicode-animations": "^1.0.3"
},
"devDependencies": {

File diff suppressed because one or more lines are too long

93
pnpm-lock.yaml generated
View File

@ -296,6 +296,9 @@ importers:
'@ai-sdk/react':
specifier: ^3.0.75
version: 3.0.88(react@19.2.4)(zod@4.3.6)
'@base-ui/react':
specifier: ^1.2.0
version: 1.2.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@dnd-kit/core':
specifier: ^6.3.1
version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@ -359,12 +362,18 @@ importers:
ai:
specifier: ^6.0.73
version: 6.0.86(zod@4.3.6)
clsx:
specifier: ^2.1.1
version: 2.1.1
framer-motion:
specifier: ^12.34.0
version: 12.34.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
fuse.js:
specifier: ^7.1.0
version: 7.1.0
lucide-react:
specifier: ^0.575.0
version: 0.575.0(react@19.2.4)
next:
specifier: ^15.3.3
version: 15.5.12(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@ -392,6 +401,9 @@ importers:
shiki:
specifier: ^3.22.0
version: 3.22.0
tailwind-merge:
specifier: ^3.5.0
version: 3.5.0
unicode-animations:
specifier: ^1.0.3
version: 1.0.3
@ -1100,6 +1112,27 @@ packages:
resolution: {integrity: sha512-ubmJ6TShyaD69VE9DQrlXcdkvJbmwWPB8qYj0H2kaJi29O7vJT9ajSdBd2W8CG34pwL9pYA74fi7RHC1qbLoVQ==}
engines: {node: ^20.19.0 || >=22.12.0}
'@base-ui/react@1.2.0':
resolution: {integrity: sha512-O6aEQHcm+QyGTFY28xuwRD3SEJGZOBDpyjN2WvpfWYFVhg+3zfXPysAILqtM0C1kWC82MccOE/v1j+GHXE4qIw==}
engines: {node: '>=14.0.0'}
peerDependencies:
'@types/react': ^17 || ^18 || ^19
react: ^17 || ^18 || ^19
react-dom: ^17 || ^18 || ^19
peerDependenciesMeta:
'@types/react':
optional: true
'@base-ui/utils@0.2.5':
resolution: {integrity: sha512-oYC7w0gp76RI5MxprlGLV0wze0SErZaRl3AAkeP3OnNB/UBMb6RqNf6ZSIlxOc9Qp68Ab3C2VOcJQyRs7Xc7Vw==}
peerDependencies:
'@types/react': ^17 || ^18 || ^19
react: ^17 || ^18 || ^19
react-dom: ^17 || ^18 || ^19
peerDependenciesMeta:
'@types/react':
optional: true
'@bcoe/v8-coverage@1.0.2':
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
@ -1359,6 +1392,12 @@ packages:
'@floating-ui/dom@1.7.5':
resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==}
'@floating-ui/react-dom@2.1.7':
resolution: {integrity: sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@floating-ui/utils@0.2.10':
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
@ -5364,6 +5403,11 @@ packages:
lru-memoizer@2.3.0:
resolution: {integrity: sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==}
lucide-react@0.575.0:
resolution: {integrity: sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@ -6646,10 +6690,16 @@ packages:
peerDependencies:
react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
tabbable@6.4.0:
resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==}
table-layout@4.1.1:
resolution: {integrity: sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==}
engines: {node: '>=12.17'}
tailwind-merge@3.5.0:
resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==}
tailwindcss@4.1.18:
resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==}
@ -7837,6 +7887,30 @@ snapshots:
'@babel/helper-string-parser': 8.0.0-rc.2
'@babel/helper-validator-identifier': 8.0.0-rc.1
'@base-ui/react@1.2.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@babel/runtime': 7.28.6
'@base-ui/utils': 0.2.5(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@floating-ui/react-dom': 2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@floating-ui/utils': 0.2.10
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
tabbable: 6.4.0
use-sync-external-store: 1.6.0(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@base-ui/utils@0.2.5(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@babel/runtime': 7.28.6
'@floating-ui/utils': 0.2.10
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
reselect: 5.1.1
use-sync-external-store: 1.6.0(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@bcoe/v8-coverage@1.0.2': {}
'@borewit/text-codec@0.2.1': {}
@ -8079,16 +8153,19 @@ snapshots:
'@floating-ui/core@1.7.4':
dependencies:
'@floating-ui/utils': 0.2.10
optional: true
'@floating-ui/dom@1.7.5':
dependencies:
'@floating-ui/core': 1.7.4
'@floating-ui/utils': 0.2.10
optional: true
'@floating-ui/utils@0.2.10':
optional: true
'@floating-ui/react-dom@2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@floating-ui/dom': 1.7.5
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@floating-ui/utils@0.2.10': {}
'@google/genai@1.41.0':
dependencies:
@ -12216,6 +12293,10 @@ snapshots:
lodash.clonedeep: 4.5.0
lru-cache: 6.0.0
lucide-react@0.575.0(react@19.2.4):
dependencies:
react: 19.2.4
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@ -14023,11 +14104,15 @@ snapshots:
react: 19.2.4
use-sync-external-store: 1.6.0(react@19.2.4)
tabbable@6.4.0: {}
table-layout@4.1.1:
dependencies:
array-back: 6.2.2
wordwrapjs: 5.1.1
tailwind-merge@3.5.0: {}
tailwindcss@4.1.18: {}
tapable@2.3.0: {}