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:
1 parent
5b569b6835
commit
ec1b9b4925
22 files changed
+1036
-48
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user