383b9ae231
- 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
228 lines
10 KiB
TypeScript
228 lines
10 KiB
TypeScript
/**
|
|
* @file artifactView.ts
|
|
* @description What makes a file an artifact, Claude Desktop's way, and how
|
|
* one is shown: its kind from its name (a page, an image, a diagram, a React
|
|
* component, a document, a table, code), the sandboxed document an HTML,
|
|
* SVG, Mermaid or React artifact runs in (an iframe `srcdoc` with no
|
|
* same-origin access, its libraries from a pinned CDN build, as Claude's own
|
|
* artifacts load theirs), a CSV's rows, and the versions a conversation's
|
|
* Write/Edit calls left of one file — rebuilt from their own inputs, so no
|
|
* server keeps copies.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import type { TranscriptMessage } from "./types";
|
|
|
|
export type ArtifactKind =
|
|
"html" | "svg" | "markdown" | "mermaid" | "react" | "csv" | "image" | "code";
|
|
|
|
const EXT = (p: string) =>
|
|
(/\.([A-Za-z0-9]+)$/.exec(p.split(/[\\/]/).pop() || "")?.[1] || "").toLowerCase();
|
|
|
|
/** The kind a file shows as. */
|
|
export function artifactKind(path: string): ArtifactKind {
|
|
const e = EXT(path);
|
|
if (e === "html" || e === "htm") return "html";
|
|
if (e === "svg") return "svg";
|
|
if (e === "md" || e === "markdown" || e === "mdx") return "markdown";
|
|
if (e === "mmd" || e === "mermaid") return "mermaid";
|
|
if (e === "jsx" || e === "tsx") return "react";
|
|
if (e === "csv" || e === "tsv") return "csv";
|
|
if (["png", "jpg", "jpeg", "gif", "webp", "avif", "bmp", "ico"].includes(e)) return "image";
|
|
return "code";
|
|
}
|
|
|
|
/** Kinds with a rendered Preview (the rest show their code only). */
|
|
export const RENDERED: ReadonlySet<ArtifactKind> = new Set([
|
|
"html",
|
|
"svg",
|
|
"markdown",
|
|
"mermaid",
|
|
"react",
|
|
"csv",
|
|
"image",
|
|
]);
|
|
|
|
/** An edit worth a card in the conversation: a page, a document, a diagram, a
|
|
* table — and a React component the model wrote whole (a `.tsx` it merely
|
|
* edited is project code, not an artifact). */
|
|
export function isArtifact(path: string, created: boolean): boolean {
|
|
const k = artifactKind(path);
|
|
if (k === "react") return created;
|
|
return k === "html" || k === "svg" || k === "markdown" || k === "mermaid" || k === "csv";
|
|
}
|
|
|
|
/** A code fence language for the Code tab. */
|
|
export function artifactLang(path: string): string {
|
|
const e = EXT(path);
|
|
const map: Record<string, string> = {
|
|
htm: "html",
|
|
mmd: "text",
|
|
mermaid: "text",
|
|
md: "markdown",
|
|
mdx: "markdown",
|
|
jsx: "jsx",
|
|
tsx: "tsx",
|
|
js: "javascript",
|
|
mjs: "javascript",
|
|
cjs: "javascript",
|
|
ts: "typescript",
|
|
py: "python",
|
|
sh: "bash",
|
|
yml: "yaml",
|
|
};
|
|
return map[e] || e;
|
|
}
|
|
|
|
// Pinned builds (all on npm for well over two weeks), as Claude's own
|
|
// artifacts load React, Tailwind and Mermaid from a CDN.
|
|
const CDN = {
|
|
react: "https://cdn.jsdelivr.net/npm/react@18.3.1/umd/react.production.min.js",
|
|
reactDom: "https://cdn.jsdelivr.net/npm/react-dom@18.3.1/umd/react-dom.production.min.js",
|
|
babel: "https://cdn.jsdelivr.net/npm/@babel/standalone@7.26.4/babel.min.js",
|
|
tailwind: "https://cdn.tailwindcss.com/3.4.16",
|
|
mermaid: "https://cdn.jsdelivr.net/npm/mermaid@11.4.1/dist/mermaid.esm.min.mjs",
|
|
};
|
|
|
|
/** Text safe inside a `<script>` element. */
|
|
const scriptSafe = (s: string) => s.replace(/<\/(script)/gi, "<\\/$1");
|
|
const htmlEscape = (s: string) =>
|
|
s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]!);
|
|
|
|
/** Shown inside the frame when the artifact itself fails. */
|
|
const ERROR_BOX = `<pre id="__err" style="display:none;margin:16px;padding:12px;border-radius:8px;background:#fee2e2;color:#991b1b;font:12px/1.5 ui-monospace,monospace;white-space:pre-wrap"></pre>
|
|
<script>window.addEventListener("error",function(e){var b=document.getElementById("__err");if(b){b.style.display="block";b.textContent=String(e.message||e.error||e);}});</script>`;
|
|
|
|
/** The document a rendered artifact runs in (null: it is not a frame kind). */
|
|
export function artifactDocument(kind: ArtifactKind, source: string, dark: boolean): string | null {
|
|
switch (kind) {
|
|
case "html":
|
|
return /<html[\s>]|<!doctype/i.test(source)
|
|
? source
|
|
: `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head><body>${source}</body></html>`;
|
|
case "svg":
|
|
return `<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;height:100%}body{display:flex;align-items:center;justify-content:center;background:${dark ? "#1f1f1f" : "#fff"}}svg{max-width:100%;max-height:100vh;height:auto}</style></head><body>${source}</body></html>`;
|
|
case "mermaid":
|
|
return `<!doctype html><html><head><meta charset="utf-8"><style>body{margin:0;padding:16px;background:${dark ? "#1f1f1f" : "#fff"};display:flex;justify-content:center}</style></head><body>${ERROR_BOX}<pre class="mermaid">${htmlEscape(source)}</pre>
|
|
<script type="module">import mermaid from "${CDN.mermaid}";mermaid.initialize({startOnLoad:false,theme:${dark ? '"dark"' : '"default"'},securityLevel:"strict"});mermaid.run().catch(function(e){var b=document.getElementById("__err");b.style.display="block";b.textContent=String(e&&e.message||e);});</script></body></html>`;
|
|
case "react":
|
|
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<script src="${CDN.react}"></script><script src="${CDN.reactDom}"></script><script src="${CDN.babel}"></script><script src="${CDN.tailwind}"></script></head>
|
|
<body><div id="root"></div>${ERROR_BOX}
|
|
<script>
|
|
(function(){
|
|
var src = ${JSON.stringify(scriptSafe(source))};
|
|
var mods = { react: window.React, "react-dom": window.ReactDOM, "react-dom/client": window.ReactDOM };
|
|
function req(name){ if (mods[name]) return mods[name]; throw new Error("This preview does not provide \\"" + name + "\\" — only react and react-dom are available."); }
|
|
try {
|
|
var out = Babel.transform(src, { filename: "Artifact.tsx", presets: [["typescript", { isTSX: true, allExtensions: true }], "react"], plugins: ["transform-modules-commonjs"] }).code;
|
|
var module = { exports: {} };
|
|
new Function("require", "module", "exports", "React", out)(req, module, module.exports, window.React);
|
|
var C = module.exports.default || module.exports.App || Object.values(module.exports).find(function(v){ return typeof v === "function"; });
|
|
if (!C) throw new Error("No component exported: export a default React component.");
|
|
ReactDOM.createRoot(document.getElementById("root")).render(React.createElement(C));
|
|
} catch (e) { var b = document.getElementById("__err"); b.style.display = "block"; b.textContent = String(e && e.message || e); }
|
|
})();
|
|
</script></body></html>`;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** The sandbox a frame kind gets: scripts but never the dashboard's origin
|
|
* (no `allow-same-origin`), and none at all for an SVG. */
|
|
export const frameSandbox = (kind: ArtifactKind) =>
|
|
kind === "svg" ? "" : "allow-scripts allow-forms allow-modals allow-popups allow-downloads";
|
|
|
|
/** A CSV/TSV's rows (quoted fields, doubled quotes, newlines inside quotes). */
|
|
export function parseCsv(text: string, sep = ","): string[][] {
|
|
const rows: string[][] = [];
|
|
let row: string[] = [];
|
|
let field = "";
|
|
let quoted = false;
|
|
for (let i = 0; i < text.length; i++) {
|
|
const c = text[i]!;
|
|
if (quoted) {
|
|
if (c === '"' && text[i + 1] === '"') {
|
|
field += '"';
|
|
i++;
|
|
} else if (c === '"') quoted = false;
|
|
else field += c;
|
|
} else if (c === '"' && field === "") quoted = true;
|
|
else if (c === sep) {
|
|
row.push(field);
|
|
field = "";
|
|
} else if (c === "\n" || c === "\r") {
|
|
if (c === "\r" && text[i + 1] === "\n") i++;
|
|
row.push(field);
|
|
rows.push(row);
|
|
row = [];
|
|
field = "";
|
|
} else field += c;
|
|
}
|
|
if (field !== "" || row.length) {
|
|
row.push(field);
|
|
rows.push(row);
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
/** One version of a file, as a Write/Edit call left it. */
|
|
export interface ArtifactVersion {
|
|
/** The call that made it. */
|
|
toolUseId: string | null;
|
|
timestamp: string | null;
|
|
/** null: the call is known, its result text is not (an edit to a file
|
|
* whose earlier text the conversation never showed). */
|
|
content: string | null;
|
|
}
|
|
|
|
const samePath = (a: unknown, b: string) =>
|
|
typeof a === "string" && a.replace(/\\/g, "/") === b.replace(/\\/g, "/");
|
|
|
|
/** Applies one Edit (old → new, once or everywhere) to known text. */
|
|
function applyEdit(text: string, oldS: unknown, newS: unknown, all: unknown): string | null {
|
|
if (typeof oldS !== "string" || typeof newS !== "string") return null;
|
|
if (!text.includes(oldS)) return null;
|
|
return all ? text.split(oldS).join(newS) : text.replace(oldS, () => newS);
|
|
}
|
|
|
|
/**
|
|
* The versions of `path` in a conversation, oldest first: each successful
|
|
* Write gives its whole text, and each Edit/MultiEdit after a known text gives
|
|
* that text with its edits applied. A call whose input the transcript cut, or
|
|
* an edit to text never shown, is a version of unknown content (the current
|
|
* file on disk stands in for the newest one).
|
|
*/
|
|
export function artifactVersions(messages: TranscriptMessage[], path: string): ArtifactVersion[] {
|
|
const failed = new Set<string>();
|
|
for (const m of messages)
|
|
for (const c of m.content) if (c.type === "tool_result" && c.is_error && c.id) failed.add(c.id);
|
|
const out: ArtifactVersion[] = [];
|
|
let text: string | null = null;
|
|
for (const m of messages) {
|
|
if (m.type !== "assistant") continue;
|
|
for (const c of m.content) {
|
|
if (c.type !== "tool_use" || (c.id && failed.has(c.id))) continue;
|
|
const input = (c.input || {}) as Record<string, unknown>;
|
|
if (!samePath(input.file_path, path)) continue;
|
|
if (c.name === "Write") {
|
|
text = typeof input.content === "string" ? input.content : null;
|
|
} else if (c.name === "Edit") {
|
|
text =
|
|
text === null
|
|
? null
|
|
: applyEdit(text, input.old_string, input.new_string, input.replace_all);
|
|
} else if (c.name === "MultiEdit") {
|
|
const edits = Array.isArray(input.edits) ? (input.edits as Record<string, unknown>[]) : [];
|
|
for (const e of edits) {
|
|
if (text === null) break;
|
|
text = applyEdit(text, e.old_string, e.new_string, e.replace_all);
|
|
}
|
|
} else continue;
|
|
out.push({ toolUseId: c.id ?? null, timestamp: m.timestamp, content: text });
|
|
}
|
|
}
|
|
return out;
|
|
}
|