Add file badges, menu icons, LSP diagnostics, robust compaction

- 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>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 19:50:12 +01:00
1 parent 5b569b6835
commit ec1b9b4925
22 files changed
+1036 -48

No files matched your search

+146
View File
@@ -0,0 +1,146 @@
import { useEffect, useState, type ReactNode } from "react";
/** Human-readable file size. */
export function formatSize(n: number) {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / 1024 / 1024).toFixed(1)} MB`;
}
const MIME_EXT: Record<string, string> = {
"image/png": "png",
"image/jpeg": "jpg",
"image/gif": "gif",
"image/webp": "webp",
"image/bmp": "bmp",
"image/svg+xml": "svg",
"application/pdf": "pdf",
"model/gltf-binary": "glb",
};
/** File type label (extension) from a name or MIME type. */
export function fileType(name?: string, mime?: string): string {
const ext = name?.match(/\.([a-z0-9]{1,6})$/i)?.[1];
if (ext) return ext.toUpperCase();
if (mime) return (MIME_EXT[mime] ?? mime.split("/").pop() ?? "").toUpperCase();
return "";
}
export function basename(p: string) {
return p.split(/[\\/]/).filter(Boolean).pop() ?? p;
}
/** Bytes of a base64 payload. */
export function base64Size(b64: string) {
const pad = b64.endsWith("==") ? 2 : b64.endsWith("=") ? 1 : 0;
return Math.floor((b64.length * 3) / 4) - pad;
}
interface Meta {
size?: number;
mime?: string;
name?: string;
}
const cache = new Map<string, Promise<Meta>>();
/** Size/type/name of a file URL in the app (project files via stat, others via HEAD). */
function loadMeta(url: string): Promise<Meta> {
let p = cache.get(url);
if (!p) {
const project = url.match(/^\/api\/files\/raw\?path=([^&]+)/);
p = project
? fetch(`/api/files/stat?path=${project[1]}`, { credentials: "same-origin" })
.then((r) => (r.ok ? r.json() : {}))
.then((j: any) => ({ size: j.size, mime: j.mime }))
: fetch(url, { method: "HEAD", credentials: "same-origin" }).then((r) => {
if (!r.ok) return {};
const len = r.headers.get("content-length");
const name = r.headers.get("content-disposition")?.match(/filename="?([^";]+)"?/)?.[1];
return { size: len ? Number(len) : undefined, mime: r.headers.get("content-type")?.split(";")[0], name };
});
p = p.catch(() => ({}));
cache.set(url, p);
}
return p;
}
export function useFileMeta(url: string | undefined, known?: Meta): Meta {
const [meta, setMeta] = useState<Meta>(known ?? {});
useEffect(() => {
if (!url || url.startsWith("data:") || known?.size !== undefined) return;
let alive = true;
// Bust the cache key per version param so rewritten files show the new size.
loadMeta(url).then((m) => alive && setMeta((old) => ({ ...m, ...old, size: m.size ?? old.size })));
return () => {
alive = false;
};
}, [url, known?.size]);
return meta;
}
/**
* A file shown in the chat: preview with type (top left) and size (top right)
* badges, file name below.
*/
export function FileFigure({
url,
name,
size,
mime,
children,
className,
}: {
url?: string;
name?: string;
size?: number;
mime?: string;
children: ReactNode;
className?: string;
}) {
const meta = useFileMeta(url, size !== undefined ? { size, mime, name } : undefined);
const shownName = name ?? meta.name;
const type = fileType(shownName, mime ?? meta.mime);
const sz = size ?? meta.size;
return (
<figure className={`file-figure ${className ?? ""}`}>
<div className="file-preview">
{url ? (
<a href={url} target="_blank" rel="noreferrer">
{children}
</a>
) : (
children
)}
{type && <span className="file-badge left">{type}</span>}
{sz !== undefined && <span className="file-badge right">{formatSize(sz)}</span>}
</div>
{shownName && (
<figcaption title={shownName}>
{url ? (
<a href={url} target="_blank" rel="noreferrer">
{basename(shownName)}
</a>
) : (
basename(shownName)
)}
</figcaption>
)}
</figure>
);
}
/** A non-previewable file: name with type and size as text. */
export function FileChip({ url, name, size, sizeText }: { url?: string; name: string; size?: number; sizeText?: string }) {
const meta = useFileMeta(url, size !== undefined ? { size } : undefined);
const sz = size ?? meta.size;
const type = fileType(name, meta.mime);
return (
<a className="file-chip" href={url} target="_blank" rel="noreferrer" title={name}>
<span className="file-chip-icon">📎</span>
<span className="file-chip-name">{basename(name)}</span>
{type && <span className="file-badge">{type}</span>}
{(sizeText || sz !== undefined) && <span className="file-chip-size">{sizeText ?? formatSize(sz!)}</span>}
</a>
);
}