750d74a2af
- 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.
636 lines
23 KiB
TypeScript
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}
|
|
/>
|
|
);
|
|
}
|