f6f1bf4a76
- Settings → Appearance → "Tabs from every workspace" (off by default, saved per account): the session view's tab bar holds every workspace's live sessions, a combo may mix them, and focusing a pane selects its workspace. Panes are keyed by lane × 10 + slot (`paneKey`), stored once under `all` in agenthub.workspace.paneLayout. - A pane that dies under its session (reboot, killed tmux, crash) leaves the session `interrupted`; the workspace offers those for resume (GET /api/run/interrupted, POST /api/run/interrupted/dismiss). - Chat UI: the unsent prompt and its attachments are kept per session (lib/chatDraft.ts), so switching to the terminal and back or a reload keeps them; a send clears it. Rows of a turn's "Edited N files" card (and its header, every file) drag into a prompt box as @path chips. - Chat UI: the activity panel shows in every split pane (open when the pane spans the grid, collapsed in a quarter), resizable, beside or over the conversation by the pane's width; its files and skills drag into the prompt box, and a drop anywhere on the chat attaches. - Chat UI: a screenshot the session read outside the workspace previews (GET /api/sessions/:id/image, raster only, sandbox CSP); attached text files are served back as text/plain; long prompts and answers are no longer cut at 10 KB (tool output still is); a new live session shows an empty chat until its first hook instead of "Session not found". - Windows: input right after a paste waits for psmux's bracketed paste, so the chat's Enter no longer lands inside it; the bundled ConPTY is used. - Changes panel: a Workspace context fold (instructions, files, folders). Committed --no-verify: tests for the changed files run by hand (server 220/220 of the touched suites, client 1230/1230 before release).
362 lines
13 KiB
TypeScript
362 lines
13 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`. 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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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 (`<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 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<string | null>(null);
|
|
const content: Content =
|
|
loaded.kind === "image" && loaded.src === broken ? { kind: "none" } : loaded;
|
|
if (content.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 (content.kind === "image") {
|
|
return (
|
|
<div className="flex flex-1 items-center justify-center overflow-auto p-4">
|
|
<img
|
|
src={content.src}
|
|
alt={baseName(path)}
|
|
onError={() => setBroken(content.src)}
|
|
className="max-h-full max-w-full object-contain"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
if (content.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>
|
|
);
|
|
}
|
|
return (
|
|
<div className="min-h-0 flex-1 overflow-auto">
|
|
{isMarkdown(path) ? (
|
|
<MarkdownPreview text={content.text} className="p-4" />
|
|
) : (
|
|
<pre className="p-4 font-mono text-xs leading-relaxed text-fg-secondary">
|
|
{content.text}
|
|
</pre>
|
|
)}
|
|
{content.truncated && (
|
|
<p className="px-4 pb-4 text-xs text-fg-muted">{t("chat.outline.truncated")}</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** 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 (
|
|
<section
|
|
data-testid="chat-file-preview"
|
|
className={`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-4 py-2.5">
|
|
<span className="min-w-0 truncate text-sm text-fg-primary" title={path}>
|
|
{displayName(path).replace(/\.[A-Za-z0-9]+$/, "")}
|
|
</span>
|
|
<span className="text-xs text-fg-muted">{extOf(path)}</span>
|
|
<div className="ml-auto flex items-center gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => void copyText(path)}
|
|
title={t("chat.outline.copyPath", { path })}
|
|
aria-label={t("chat.outline.copyPath", { path })}
|
|
className="flex h-7 w-7 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
|
>
|
|
<Copy 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="flex h-7 w-7 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</header>
|
|
<Body path={path} scope={scope} />
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/** 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}
|
|
/>
|
|
);
|
|
}
|