/** * @file ChatFilePreview.tsx * @description A file a Chat pane's session touched, shown the way Claude * Desktop shows one: `FilePreview` (name · EXT, Copy path, Close, then the * content) and `FileListModal` (a list on the left, the picked file's preview * on the right — Claude's Uploads dialog). Content comes only through routes * that already guard it: a file inside the workspace through * `GET /api/lanes/:id/git/file` (or `/git/raw` for an image), a file under * `~/.claude` (a memory file, a plugin's skill) through `GET /api/cc-config/file`, * an attached image or text file through `GET /api/run/drop/:name`, an image * the session read anywhere else through `GET /api/sessions/:id/image`. Anything * else (or an image that route refuses) shows its path only. A Windows path's `\` counts as `/` throughout. * `SkillListModal` is the same dialog over the session's skills, each one's * SKILL.md found by name (`plugin:name` from the plugin list, else the user's * or the project's); a built-in skill has no file and shows by name only. * @author Nguyễn Ngọc Trí Vĩ */ import { useEffect, useRef, useState, type DragEvent } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { Copy, FileText, Loader2, X } from "lucide-react"; import { api, gitTarget } from "../../lib/api"; import { copyText } from "../../lib/clipboard"; import { useDialog } from "../../hooks/useDialog"; import { dragSkill } from "../../lib/promptAttach"; import { MarkdownPreview, isMarkdown } from "../MarkdownPreview"; /** Where a Chat pane's files can be read from. */ export interface PreviewScope { laneId: number | null; /** The workspace's own folder. */ laneCwd: string; /** The pane's folder — the workspace's, or the session's own worktree. */ cwd: string; sessionId: string; } const IMAGE_RE = /\.(png|jpe?g|gif|webp)$/i; // An attached file the drop route serves as text (`DROP_TEXT_TYPES` in server/routes/run.js). const DROP_TEXT_RE = /\.(txt|md|markdown|csv|tsv|log|json|xml|ya?ml)$/i; // The drop dir (`/drops/`), or the older `/agenthub-drops-/`. const DROP_RE = /[\\/](?:agenthub-drops-\d+|drops)[\\/]([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+)$/; /** A dropped text file shown at most this long. */ const DROP_TEXT_MAX = 200_000; /** `C:\a\b` → `C:/a/b`: a Windows path matched like a POSIX one. */ const slashed = (p: string) => p.replace(/\\/g, "/"); export const baseName = (p: string) => p.split(/[\\/]/).pop() || p; /** `report.v2.xlsx` → `XLSX`; none → "". */ export const extOf = (p: string) => { const m = /\.([A-Za-z0-9]+)$/.exec(baseName(p)); return m ? m[1]!.toUpperCase() : ""; }; /** An attached file's name without the drop dir's random prefix. */ export const displayName = (p: string) => DROP_RE.test(p) ? baseName(p).replace(/^[0-9a-f]{16}-?/, "") || baseName(p) : baseName(p); type Content = | { kind: "loading" } | { kind: "image"; src: string } | { kind: "text"; text: string; truncated: boolean } | { kind: "none" }; function useContent(path: string, scope: PreviewScope): Content { const [content, setContent] = useState({ kind: "loading" }); useEffect(() => { let live = true; const show = (p: Promise) => { setContent({ kind: "loading" }); p.then((c) => live && setContent(c)).catch(() => live && setContent({ kind: "none" })); }; const drop = DROP_RE.exec(path); if (drop) { const src = `/api/run/drop/${encodeURIComponent(drop[1]!)}`; if (IMAGE_RE.test(path)) setContent({ kind: "image", src }); else if (DROP_TEXT_RE.test(path)) show( fetch(src).then(async (r) => { if (!r.ok) throw new Error(String(r.status)); const text = await r.text(); return { kind: "text", text: text.slice(0, DROP_TEXT_MAX), truncated: text.length > DROP_TEXT_MAX, }; }) ); else setContent({ kind: "none" }); return () => { live = false; }; } const file = slashed(path); const base = slashed(scope.cwd).replace(/\/+$/, ""); if (scope.laneId === null || !base || !file.startsWith(`${base}/`)) { // Claude Code's own files (memory, a skill): the config pages' guarded read. if (/\/\.claude\//.test(file) && !IMAGE_RE.test(file)) { show( api.ccConfig .file(path) .then((f) => ({ kind: "text", text: f.text, truncated: !!f.truncated })) ); } else if (IMAGE_RE.test(file) && scope.sessionId) { // An image it read elsewhere (a screenshot under ~/.cache): served // only because this session's own Read named it. setContent({ kind: "image", src: api.sessions.readImageUrl(scope.sessionId, path) }); } else setContent({ kind: "none" }); return () => { live = false; }; } const rel = file.slice(base.length + 1); const target = base === slashed(scope.laneCwd).replace(/\/+$/, "") ? scope.laneId : gitTarget(scope.laneId, scope.sessionId); if (IMAGE_RE.test(path)) { setContent({ kind: "image", src: api.lanes.gitRawUrl(target, rel) }); return; } show( api.lanes .gitFile(target, rel) .then((f) => f.available && !f.binary ? { kind: "text", text: f.lines.join("\n"), truncated: f.truncated } : { kind: "none" } ) ); return () => { live = false; }; }, [path, scope.cwd, scope.laneCwd, scope.laneId, scope.sessionId]); return content; } function Body({ path, scope }: { path: string; scope: PreviewScope }) { const { t } = useTranslation("run"); const loaded = useContent(path, scope); // An image the route refused (gone, server mode) shows like no preview. const [broken, setBroken] = useState(null); const content: Content = loaded.kind === "image" && loaded.src === broken ? { kind: "none" } : loaded; if (content.kind === "loading") { return (
); } if (content.kind === "image") { return (
{baseName(path)} setBroken(content.src)} className="max-h-full max-w-full object-contain" />
); } if (content.kind === "none") { return (

{t("chat.outline.noPreview")}

{path}

); } return (
{isMarkdown(path) ? ( ) : (
          {content.text}
        
)} {content.truncated && (

{t("chat.outline.truncated")}

)}
); } /** One file: header (name · EXT, Copy path, Close) over its content. */ export function FilePreview({ path, scope, onClose, className = "", }: { path: string; scope: PreviewScope; onClose: () => void; className?: string; }) { const { t } = useTranslation("run"); return (
{displayName(path).replace(/\.[A-Za-z0-9]+$/, "")} {extOf(path)}
); } /** Claude's Uploads dialog: the files on the left, the picked one on the right. */ export function FileListModal({ title, paths, label = displayName, scope, onClose, onDragItem, }: { title: string; paths: string[]; /** A row's text; the file name by default. */ label?: (path: string) => string; scope: PreviewScope; onClose: () => void; /** Rows drag into the prompt box; the dialog then closes, out of the way. */ onDragItem?: (e: DragEvent, path: string) => void; }) { const [picked, setPicked] = useState(paths[0] ?? ""); const panelRef = useRef(null); useDialog(true, panelRef, onClose); // Into document.body, like ImageLightbox: a pane's own stacking layer // would otherwise draw over a `fixed` overlay opened from inside it. return createPortal(
e.stopPropagation()} className="flex h-[80vh] w-full max-w-6xl overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl" >

{title}

    {paths.map((p) => (
  • ))}
{picked && }
, document.body ); } /** The session's skills in the list dialog, each one's SKILL.md previewed. */ export function SkillListModal({ title, names, scope, onClose, draggable = false, }: { title: string; names: string[]; scope: PreviewScope; onClose: () => void; /** Rows drag into the prompt box as `/name`. */ draggable?: boolean; }) { const [files, setFiles] = useState | null>(null); useEffect(() => { let live = true; void Promise.all([ api.ccConfig.skills(undefined, scope.cwd).catch(() => ({ items: [] })), api.ccConfig.plugins().catch(() => ({ plugins: [] })), ]).then(([sk, pl]) => { const found = new Map(); const add = (name: string, file: string | undefined) => { if (file && !found.has(name)) found.set(name, file); }; for (const p of pl.plugins) { const items = p.contributes?.items; for (const it of [...(items?.skills ?? []), ...(items?.commands ?? [])]) add(`${p.name}:${it.name}`, it.file); } // A personal skill wins over a project one of the same name, as in Claude Code. for (const s of sk.items) if (s.scope === "user") add(s.name, s.file); for (const s of sk.items) add(s.name, s.file); if (live) setFiles(found); }); return () => { live = false; }; }, [scope.cwd]); if (!files) return null; // A built-in skill has no file: its name stands in and previews as nothing. const byPath = new Map(names.map((n) => [files.get(n) ?? n, n])); return ( byPath.get(p) ?? displayName(p)} scope={scope} onClose={onClose} onDragItem={draggable ? (e, p) => dragSkill(e, byPath.get(p) ?? p) : undefined} /> ); }