- Files in chat: preview with type/size badges and file name; attachment chips - Menu and settings tabs with icons - Heatmap: distinct colour for empty hours, hourly labels - Context chip shows used/total tokens (e.g. 221k/240k) plus percent - LSP client: diagnostics appended to edit/write results and a diagnostics tool (typescript-language-server, pyright, rust-analyzer, gopls, clangd; sandboxed) - Compaction summarizes a plain transcript and retries once on empty output - files/stat endpoint Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
120 lines
4.9 KiB
TypeScript
120 lines
4.9 KiB
TypeScript
import { memo, useState, type ReactNode } from "react";
|
|
import ReactMarkdown from "react-markdown";
|
|
import remarkGfm from "remark-gfm";
|
|
import rehypeHighlight from "rehype-highlight";
|
|
import { FileChip, FileFigure } from "./FileFigure";
|
|
|
|
export const IMAGE_EXT = /\.(png|jpe?g|gif|webp|bmp|svg)$/i;
|
|
|
|
/** URL for a file in the project (absolute or relative to baseDir). */
|
|
export function projectFileUrl(src: string, baseDir?: string): string | undefined {
|
|
if (/^(https?:|data:|blob:)/i.test(src)) return src;
|
|
if (!baseDir && !src.startsWith("/")) return undefined;
|
|
const parts = (src.startsWith("/") ? src : `${baseDir}/${src}`).split("/");
|
|
const out: string[] = [];
|
|
for (const p of parts) {
|
|
if (p === "..") out.pop();
|
|
else if (p && p !== ".") out.push(p);
|
|
}
|
|
return `/api/files/raw?path=${encodeURIComponent("/" + out.join("/"))}`;
|
|
}
|
|
|
|
function textOf(node: ReactNode): string {
|
|
if (typeof node === "string") return node;
|
|
if (Array.isArray(node)) return node.map(textOf).join("");
|
|
if (node && typeof node === "object" && "props" in node) return textOf((node as any).props.children);
|
|
return "";
|
|
}
|
|
|
|
/** SVG code shown as an image (img context: scripts never run), code on demand. */
|
|
export function SvgBlock({ code, children }: { code: string; children?: ReactNode }) {
|
|
const [showCode, setShowCode] = useState(false);
|
|
const url = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(code)))}`;
|
|
return (
|
|
<div className="svg-block">
|
|
<FileFigure name="inline.svg" size={new Blob([code]).size} mime="image/svg+xml">
|
|
<img src={url} alt="SVG" />
|
|
</FileFigure>
|
|
<button className="small ghost" onClick={() => setShowCode(!showCode)}>
|
|
{showCode ? "▾ SVG" : "▸ SVG"}
|
|
</button>
|
|
{showCode && children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export const Markdown = memo(function Markdown({ text, baseDir }: { text: string; baseDir?: string }) {
|
|
return (
|
|
<div className="markdown">
|
|
<ReactMarkdown
|
|
remarkPlugins={[remarkGfm]}
|
|
rehypePlugins={[[rehypeHighlight, { detect: false, ignoreMissing: true }]]}
|
|
components={{
|
|
a: (props) => <a {...props} target="_blank" rel="noreferrer" />,
|
|
img: ({ src, alt }) => {
|
|
const url = typeof src === "string" ? projectFileUrl(src, baseDir) : undefined;
|
|
if (!url) return <span>{alt}</span>;
|
|
// Non-image files (3D models, documents) become links.
|
|
if (typeof src === "string" && !/^(data:|https?:)/i.test(src) && !IMAGE_EXT.test(src)) {
|
|
return <FileChip url={url} name={src} />;
|
|
}
|
|
const name = typeof src === "string" && !src.startsWith("data:") ? decodeURIComponent(src.split("?")[0]) : alt || undefined;
|
|
return (
|
|
<FileFigure url={url} name={name} mime={typeof src === "string" ? src.match(/^data:([^;,]+)/)?.[1] : undefined}>
|
|
<img className="inline-image" src={url} alt={alt ?? ""} loading="lazy" />
|
|
</FileFigure>
|
|
);
|
|
},
|
|
pre: ({ children }) => {
|
|
const code = textOf(children);
|
|
const child: any = Array.isArray(children) ? children[0] : children;
|
|
const cls = String(child?.props?.className ?? "");
|
|
if (/language-svg/.test(cls) || (/language-(xml|html)/.test(cls) && code.trim().startsWith("<svg"))) {
|
|
return (
|
|
<SvgBlock code={code}>
|
|
<pre>{children}</pre>
|
|
</SvgBlock>
|
|
);
|
|
}
|
|
return <pre>{children}</pre>;
|
|
},
|
|
}}
|
|
>
|
|
{text}
|
|
</ReactMarkdown>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
/** Image files mentioned in a text (paths in backticks or plain). */
|
|
export function imagePathsIn(text: string): string[] {
|
|
const found = new Set<string>();
|
|
const re = /(?:^|[\s`(\["'])((?:\.{0,2}\/)?[\w\-./]+\.(?:png|jpe?g|gif|webp|bmp|svg))(?=$|[\s`)\]"',.:;!?])/gim;
|
|
for (const m of text.matchAll(re)) {
|
|
// Skip URLs and markdown images (rendered anyway).
|
|
const idx = m.index ?? 0;
|
|
const before = text.slice(Math.max(0, idx - 2), idx + 1);
|
|
if (m[1].includes("://") || before.includes("](") || text.slice(Math.max(0, idx - 10), idx + 1).includes("http")) continue;
|
|
found.add(m[1]);
|
|
}
|
|
return [...found].slice(0, 8);
|
|
}
|
|
|
|
/** Thumbnails for image files mentioned in a message; missing files hide themselves. */
|
|
export function ImageRefs({ text, baseDir }: { text: string; baseDir?: string }) {
|
|
const paths = imagePathsIn(text.replace(/!\[[^\]]*\]\([^)]*\)/g, ""));
|
|
if (paths.length === 0 || !baseDir) return null;
|
|
return (
|
|
<div className="image-refs">
|
|
{paths.map((p) => {
|
|
const url = projectFileUrl(p, baseDir)!;
|
|
return (
|
|
<FileFigure key={p} url={url} name={p} className="thumb">
|
|
<img src={url} alt={p} loading="lazy" onError={(e) => ((e.currentTarget.closest("figure") as HTMLElement).style.display = "none")} />
|
|
</FileFigure>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|