Files
AgentHub/client/src/components/run/ChatFilePreview.tsx
T
nntrivi2001 750d74a2af chore: v2.8.15 — Oh My Pi login sharing, omp --allow-home, Markdown Preview default, first-login password
- omp-share.js: an admin's switch (S3) runs omp's own auth broker + gateway as
  the host on loopback; a member's omp pane reaches it as a scoped client keyed
  by its pane token (clients file = live omp panes only), runs on
  --model agenthub/<model>, never holds a credential.
- omp catalog: --allow-home, so a workspace at the account's home keeps its
  sessions there (they were recorded under a temp dir and dropped).
- Settings → Appearance → Open Markdown as: Preview (default) or Raw, for
  Files/Changes, the config viewer and a chat's file panel.
- First-login password change and password rules, avatar URLs, Artifacts
  "Mine" includes installed shared skills/plugins, a member's own hook pages.
2026-10-22 10:04:30 +07:00

636 lines
23 KiB
TypeScript

/**
* @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`, and a
* text file it read, wrote or named (a generated report) anywhere else through
* `GET /api/sessions/:id/file`.
* Anything else (or a file those routes refuse) 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ĩ <vinnt@smartgift.vn>
*/
import { useEffect, useMemo, useRef, useState, type DragEvent } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import {
ChevronLeft,
ChevronRight,
Copy,
Download,
FileText,
Loader2,
Maximize2,
Minimize2,
Share2,
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 { readMarkdownPreview } from "../../lib/markdownView";
import { MarkdownPreview } from "../MarkdownPreview";
import { CodeBlock } from "../conversation/CodeBlock";
import { useTheme } from "../../hooks/useTheme";
import { toast } from "../../lib/toast";
import type { TranscriptMessage } from "../../lib/types";
import {
RENDERED,
artifactDocument,
artifactKind,
artifactLang,
artifactVersions,
frameSandbox,
parseCsv,
type ArtifactKind,
} from "../../lib/artifactView";
/** 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 (`<data dir>/drops/`), or the older `<tmp>/agenthub-drops-<uid>/`.
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<Content>({ kind: "loading" });
useEffect(() => {
let live = true;
const show = (p: Promise<Content>) => {
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 if (scope.sessionId && /^(?:\/|[A-Za-z]:\/)/.test(file)) {
// A text file it read or wrote elsewhere (a report under ~/Documents):
// served only because this session's own Read/Write/Edit named it.
show(
api.sessions
.readFile(scope.sessionId, path)
.then((f) => ({ kind: "text", text: f.text, truncated: f.truncated }))
);
} 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;
}
/** A CSV/TSV as a table (the first rows; a file is not a spreadsheet). */
function CsvTable({ text, tab }: { text: string; tab: boolean }) {
const rows = useMemo(() => parseCsv(text, tab ? "\t" : ",").slice(0, 1000), [text, tab]);
const [head, ...body] = rows;
return (
<div className="min-h-0 flex-1 overflow-auto p-3">
<table className="w-full border-collapse text-xs">
{head && (
<thead className="sticky top-0 bg-surface-2">
<tr>
{head.map((h, i) => (
<th
key={i}
className="border border-border px-2 py-1 text-left font-medium text-fg-primary"
>
{h}
</th>
))}
</tr>
</thead>
)}
<tbody>
{body.map((r, i) => (
<tr key={i} className="odd:bg-surface-1 even:bg-surface-2/40">
{r.map((c, j) => (
<td key={j} className="border border-border px-2 py-1 text-fg-secondary">
{c}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
/** An HTML, SVG, Mermaid or React artifact, running in its own sandbox:
* scripts (none for an SVG) but never the dashboard's origin. */
function ArtifactFrame({ kind, text, title }: { kind: ArtifactKind; text: string; title: string }) {
const { theme } = useTheme();
const doc = useMemo(() => artifactDocument(kind, text, theme === "dark"), [kind, text, theme]);
return (
<iframe
data-testid="artifact-frame"
title={title}
srcDoc={doc ?? ""}
sandbox={frameSandbox(kind)}
referrerPolicy="no-referrer"
className={`min-h-0 w-full flex-1 border-0 ${kind === "html" || kind === "react" ? "bg-white" : ""}`}
/>
);
}
type View = "preview" | "code";
/** A file's first tab: Preview where it renders — Markdown as Settings → Appearance says. */
function defaultView(path: string): View {
const kind = artifactKind(path);
if (!RENDERED.has(kind)) return "code";
return kind === "markdown" && !readMarkdownPreview() ? "code" : "preview";
}
function Body({ path, content, view }: { path: string; content: Content; view: View }) {
const { t } = useTranslation("run");
const kind = artifactKind(path);
// An image the route refused (gone, server mode) shows like no preview.
const [broken, setBroken] = useState<string | null>(null);
const shown: Content =
content.kind === "image" && content.src === broken ? { kind: "none" } : content;
if (shown.kind === "loading") {
return (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-fg-muted" />
</div>
);
}
if (shown.kind === "image") {
return (
<div className="flex flex-1 items-center justify-center overflow-auto p-4">
<img
src={shown.src}
alt={baseName(path)}
onError={() => setBroken(shown.src)}
className="max-h-full max-w-full object-contain"
/>
</div>
);
}
if (shown.kind === "none") {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center text-sm text-fg-muted">
<FileText className="h-8 w-8" />
<p>{t("chat.outline.noPreview")}</p>
<p className="break-all font-mono text-xs">{path}</p>
</div>
);
}
const note = shown.truncated && (
<p className="px-4 pb-4 text-xs text-fg-muted">{t("chat.outline.truncated")}</p>
);
if (view === "code" || !RENDERED.has(kind)) {
return (
<div className="min-h-0 flex-1 overflow-auto p-3">
<CodeBlock code={shown.text} lang={artifactLang(path)} maxHeight="none" showLineNumbers />
{note}
</div>
);
}
if (kind === "markdown") {
return (
<div className="min-h-0 flex-1 overflow-auto">
<MarkdownPreview text={shown.text} className="p-4" />
{note}
</div>
);
}
if (kind === "csv") return <CsvTable text={shown.text} tab={/\.tsv$/i.test(path)} />;
return <ArtifactFrame kind={kind} text={shown.text} title={baseName(path)} />;
}
const iconBtn =
"flex h-7 w-7 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary disabled:opacity-40";
/** Saves text (or an image's URL) as a file through the browser. */
function download(name: string, content: Content) {
const a = document.createElement("a");
if (content.kind === "image") a.href = content.src;
else if (content.kind === "text")
a.href = URL.createObjectURL(new Blob([content.text], { type: "text/plain;charset=utf-8" }));
else return;
a.download = name;
a.click();
if (content.kind === "text") setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}
/** One file, Claude Desktop's artifact view: name · EXT, Preview | Code, the
* versions the conversation made of it, Copy, Download, Share to the
* Artifacts Hub, full screen, Close. */
export function FilePreview({
path,
scope,
onClose,
className = "",
messages,
}: {
path: string;
scope: PreviewScope;
onClose: () => void;
className?: string;
/** The conversation, for the versions its Write/Edit calls left. */
messages?: TranscriptMessage[];
}) {
const { t } = useTranslation("run");
const kind = artifactKind(path);
const loaded = useContent(path, scope);
const [view, setView] = useState<View>(() => defaultView(path));
const [full, setFull] = useState(false);
const [sharing, setSharing] = useState(false);
const versions = useMemo(
() => (messages ? artifactVersions(messages, path) : []),
[messages, path]
);
// null: the newest — the file as it is now.
const [picked, setPicked] = useState<number | null>(null);
// Another file opens on its own default tab, at its newest version.
useEffect(() => {
setPicked(null);
setView(defaultView(path));
}, [path]);
const last = Math.max(versions.length - 1, 0);
const at = picked ?? last;
const older = picked !== null && picked < last ? versions[picked] : null;
const content: Content =
older && older.content !== null
? { kind: "text", text: older.content, truncated: false }
: loaded;
const known = (i: number) => i === last || versions[i]?.content != null;
const step = (dir: -1 | 1) => {
for (let i = at + dir; i >= 0 && i <= last; i += dir) {
if (known(i)) return setPicked(i === last ? null : i);
}
};
const share = async () => {
if (sharing) return;
setSharing(true);
try {
const file = slashed(path);
const base = slashed(scope.laneCwd).replace(/\/+$/, "");
if (scope.laneId !== null && base && file.startsWith(`${base}/`) && picked === null) {
await api.artifacts.pinFile({
laneId: scope.laneId,
path: file.slice(base.length + 1),
title: displayName(path),
...(scope.sessionId ? { sessionId: scope.sessionId } : {}),
});
} else if (content.kind === "text") {
await api.artifacts.uploadFile(new File([content.text], displayName(path)), {
title: displayName(path),
});
} else throw new Error(t("chat.artifact.cannotShare"));
toast.success(t("chat.artifact.shared"));
} catch (err) {
toast.error(err);
} finally {
setSharing(false);
}
};
const panel = (
<section
data-testid="chat-file-preview"
className={
full
? "fixed inset-4 z-[200] flex min-h-0 min-w-0 flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl"
: `flex min-h-0 min-w-0 flex-col overflow-hidden bg-surface-1 ${className}`
}
>
<header className="flex items-center gap-2 border-b border-border px-3 py-2">
<button
type="button"
onClick={() => void copyText(path)}
title={t("chat.outline.copyPath", { path })}
className="min-w-0 truncate text-left text-sm text-fg-primary hover:underline"
>
{displayName(path).replace(/\.[A-Za-z0-9]+$/, "")}
</button>
<span className="flex-shrink-0 text-xs text-fg-muted">{extOf(path)}</span>
{versions.length > 1 && (
<span
data-testid="artifact-versions"
className="flex flex-shrink-0 items-center text-xs text-fg-muted"
>
<button
type="button"
onClick={() => step(-1)}
disabled={![...Array(at).keys()].some(known)}
aria-label={t("chat.artifact.older")}
title={t("chat.artifact.older")}
className={iconBtn}
>
<ChevronLeft className="h-3.5 w-3.5" />
</button>
<span className="tabular-nums">
{t("chat.artifact.version", { n: at + 1, total: versions.length })}
</span>
<button
type="button"
onClick={() => step(1)}
disabled={at >= last}
aria-label={t("chat.artifact.newer")}
title={t("chat.artifact.newer")}
className={iconBtn}
>
<ChevronRight className="h-3.5 w-3.5" />
</button>
</span>
)}
<div className="ml-auto flex flex-shrink-0 items-center gap-1">
{RENDERED.has(kind) && kind !== "image" && (
<div role="tablist" className="mr-1 flex rounded-md border border-border p-0.5 text-xs">
{(["preview", "code"] as const).map((v) => (
<button
key={v}
type="button"
role="tab"
aria-selected={view === v}
data-testid={`artifact-tab-${v}`}
onClick={() => setView(v)}
className={`rounded px-2 py-0.5 ${view === v ? "bg-surface-3 text-fg-primary" : "text-fg-muted hover:text-fg-primary"}`}
>
{t(`chat.artifact.${v}`)}
</button>
))}
</div>
)}
<button
type="button"
data-testid="artifact-copy"
onClick={() => void copyText(content.kind === "text" ? content.text : path)}
title={t("chat.artifact.copy")}
aria-label={t("chat.artifact.copy")}
className={iconBtn}
>
<Copy className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => download(displayName(path), content)}
disabled={content.kind !== "text" && content.kind !== "image"}
title={t("chat.artifact.download")}
aria-label={t("chat.artifact.download")}
className={iconBtn}
>
<Download className="h-3.5 w-3.5" />
</button>
<button
type="button"
data-testid="artifact-share"
onClick={() => void share()}
disabled={sharing || content.kind === "loading" || content.kind === "none"}
title={t("chat.artifact.share")}
aria-label={t("chat.artifact.share")}
className={iconBtn}
>
{sharing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Share2 className="h-3.5 w-3.5" />
)}
</button>
<button
type="button"
data-testid="artifact-expand"
onClick={() => setFull((f) => !f)}
title={t(full ? "chat.artifact.collapse" : "chat.artifact.expand")}
aria-label={t(full ? "chat.artifact.collapse" : "chat.artifact.expand")}
className={iconBtn}
>
{full ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
</button>
<button
type="button"
onClick={onClose}
title={t("chat.outline.close")}
aria-label={t("chat.outline.close")}
className={iconBtn}
>
<X className="h-4 w-4" />
</button>
</div>
</header>
<Body path={path} content={content} view={view} />
</section>
);
// Full screen: over the whole page, where a pane's edges cannot clip it.
return full ? createPortal(panel, document.body) : panel;
}
/** 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<HTMLDivElement>(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(
<div
className="fixed inset-0 z-[200] flex items-center justify-center bg-surface-0/70 p-6"
onClick={onClose}
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label={title}
data-testid="chat-file-modal"
onClick={(e) => e.stopPropagation()}
className="flex h-[80vh] w-full max-w-6xl overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl"
>
<div className="flex w-80 flex-shrink-0 flex-col border-r border-border">
<h2 className="px-5 pb-3 pt-5 text-lg text-fg-primary">{title}</h2>
<ul className="min-h-0 flex-1 space-y-0.5 overflow-y-auto px-2 pb-3">
{paths.map((p) => (
<li key={p}>
<button
type="button"
onClick={() => setPicked(p)}
draggable={!!onDragItem}
onDragStart={(e) => {
onDragItem?.(e, p);
// Not in dragstart itself: removing the source there cancels the drag.
if (onDragItem) window.setTimeout(onClose, 0);
}}
title={p}
aria-current={p === picked}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm ${p === picked ? "bg-surface-3 text-fg-primary" : "text-fg-secondary hover:bg-surface-2"}`}
>
<FileText className="h-4 w-4 flex-shrink-0 text-fg-muted" />
<span className="min-w-0 truncate">{label(p)}</span>
</button>
</li>
))}
</ul>
</div>
{picked && <FilePreview path={picked} scope={scope} onClose={onClose} className="flex-1" />}
</div>
</div>,
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<Map<string, string> | 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<string, string>();
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 (
<FileListModal
title={title}
paths={[...byPath.keys()]}
label={(p) => byPath.get(p) ?? displayName(p)}
scope={scope}
onClose={onClose}
onDragItem={draggable ? (e, p) => dragSkill(e, byPath.get(p) ?? p) : undefined}
/>
);
}