feat(chat): terminal chat parity for every tool; Desktop-like artifacts
- The chat over a terminal pane now covers Claude, Codex, omp, Cursor and Antigravity: Cursor's agent transcript and agy's step log (brain/<id>/.system_generated/logs/transcript_full.jsonl) are read as conversations; Cursor sessions are claimed by their workspace - Replies appear as soon as they are written: the projects-tree watcher broadcasts `transcript_changed` (at most every 250 ms per session) and the chat re-reads the transcript while the terminal underneath draws - Stop shows from the pane's own busy line (Claude/Codex `esc to interrupt`, agy `… esc to cancel`), or fresh output for omp/Cursor — no hooks needed - Permission prompts read off the terminal become a card with the tool, its command and "Deny with a note"; Codex gets Permission mode / Model buttons that open its own numbered pickers, answered from the menu card - Artifacts: cards for pages/documents/diagrams/tables/React components, a Preview | Code panel with versions, copy, download, save to the Artifacts Hub, full screen; sandboxed iframes without allow-same-origin - Claude panes get CLAUDE_CODE_ENABLE_TODO_TOOLS=1 so the Progress panel has a plan; multi-question pane menus read their Next row and question tabs - The realtime chat bridge (structured stdio, /api/chat-bridge, /ws-chat) from the unreleased ad9d475 is not kept
This commit is contained in:
@@ -18,15 +18,40 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState, type DragEvent } from "react";
|
||||
import { useEffect, useMemo, 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 {
|
||||
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 { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
||||
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 {
|
||||
@@ -146,33 +171,89 @@ function useContent(path: string, scope: PreviewScope): Content {
|
||||
return content;
|
||||
}
|
||||
|
||||
function Body({ path, scope }: { path: string; scope: PreviewScope }) {
|
||||
/** 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";
|
||||
|
||||
function Body({ path, content, view }: { path: string; content: Content; view: View }) {
|
||||
const { t } = useTranslation("run");
|
||||
const loaded = useContent(path, scope);
|
||||
const kind = artifactKind(path);
|
||||
// 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") {
|
||||
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 (content.kind === "image") {
|
||||
if (shown.kind === "image") {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center overflow-auto p-4">
|
||||
<img
|
||||
src={content.src}
|
||||
src={shown.src}
|
||||
alt={baseName(path)}
|
||||
onError={() => setBroken(content.src)}
|
||||
onError={() => setBroken(shown.src)}
|
||||
className="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (content.kind === "none") {
|
||||
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" />
|
||||
@@ -181,69 +262,244 @@ function Body({ path, scope }: { path: string; scope: PreviewScope }) {
|
||||
</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>
|
||||
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)} />;
|
||||
}
|
||||
|
||||
/** One file: header (name · EXT, Copy path, Close) over its content. */
|
||||
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");
|
||||
return (
|
||||
const kind = artifactKind(path);
|
||||
const loaded = useContent(path, scope);
|
||||
const [view, setView] = useState<View>(RENDERED.has(kind) ? "preview" : "code");
|
||||
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(RENDERED.has(artifactKind(path)) ? "preview" : "code");
|
||||
}, [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={`flex min-h-0 min-w-0 flex-col overflow-hidden bg-surface-1 ${className}`}
|
||||
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-4 py-2.5">
|
||||
<span className="min-w-0 truncate text-sm text-fg-primary" title={path}>
|
||||
<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]+$/, "")}
|
||||
</span>
|
||||
<span className="text-xs text-fg-muted">{extOf(path)}</span>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
</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"
|
||||
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"
|
||||
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="flex h-7 w-7 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
||||
className={iconBtn}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<Body path={path} scope={scope} />
|
||||
<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. */
|
||||
|
||||
Reference in New Issue
Block a user