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

+6 -2
View File
@@ -201,7 +201,9 @@ export function Heatmap({
<div className="chart" ref={ref} onMouseLeave={() => setTip(undefined)}>
<svg height={top + cell * 7 + 4} role="img" aria-label={t("stats.heatmap")}>
<g className="axis">
{[0, 3, 6, 9, 12, 15, 18, 21].map((h) => (
{Array.from({ length: 24 }, (_, h) => h)
.filter((h) => h % (cellW >= 22 ? 1 : cellW >= 12 ? 3 : 6) === 0)
.map((h) => (
<text key={h} x={left + h * cellW + cellW / 2} y={12} textAnchor="middle">
{h}
</text>
@@ -245,7 +247,9 @@ export function Heatmap({
)}
</svg>
<div className="legend" style={{ marginTop: 6 }}>
<span>{format(0)}</span>
<i style={{ background: SEQ[0], width: 18 }} />
<span>{t("stats.heatEmpty")}</span>
<span style={{ marginLeft: 12 }}>{format(0)}</span>
{SEQ.slice(1).map((c, i) => (
<i key={i} style={{ background: c, width: 18 }} />
))}
+46 -5
View File
@@ -1,3 +1,4 @@
import { base64Size, FileChip, FileFigure, fileType } from "./FileFigure";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { del, get, patch, post } from "../api";
import { useModels } from "../hooks";
@@ -12,7 +13,7 @@ import { ShareDialog } from "./ShareDialog";
import { QueueList } from "./QueueList";
import { BudgetChip } from "./Budget";
import { PermissionCard, QuestionCard } from "./Interaction";
import { ImageRefs, Markdown } from "./Markdown";
import { ImageRefs, Markdown, projectFileUrl } from "./Markdown";
import { UsageOverlay } from "./UsageOverlay";
import { RewindDialog } from "./RewindDialog";
import { ShellsChip } from "./ShellsPanel";
@@ -238,7 +239,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
</div>
{ctxShare !== undefined && (
<span className={`chip hide-mobile ${ctxShare > 0.7 ? "warn" : ""}`} title={t("chat.contextHint", { tokens: fmtNumber(ctxTokens) })}>
{t("chat.context")} {fmtPercent(ctxShare)}
{t("chat.context")} {fmtK(ctxTokens)}/{fmtK(modelInfo!.context!)} · {fmtPercent(ctxShare)}
</span>
)}
{stats && (
@@ -325,11 +326,13 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
</button>
</div>
)}
{texts.map((p) => p.text).join("\n\n")}
<UserText text={texts.map((p) => p.text).join("\n\n")} />
{imgs.length > 0 && (
<div>
<div className="user-files">
{imgs.map((p, i) => (
<img key={i} src={`data:${p.image.media_type};base64,${p.image.data}`} alt="" />
<FileFigure key={i} name={`image-${i + 1}.${fileType(undefined, p.image.media_type).toLowerCase()}`} mime={p.image.media_type} size={base64Size(p.image.data)}>
<img src={`data:${p.image.media_type};base64,${p.image.data}`} alt="" />
</FileFigure>
))}
</div>
)}
@@ -399,3 +402,41 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
</>
);
}
/** Attached-file lines ("- /path (12.3 KB)") of a user message become file chips. */
const ATTACHED_RE = /^- (\/\S+) \(([\d.]+ (?:B|KB|MB))\)$/;
function UserText({ text }: { text: string }) {
const lines = text.split("\n");
const files: { path: string; size: string }[] = [];
const rest: string[] = [];
for (const l of lines) {
const m = l.match(ATTACHED_RE);
if (m) files.push({ path: m[1], size: m[2] });
else rest.push(l);
}
// Drop the heading line ("Attached files …:") right before the list.
while (files.length && rest.length && (rest[rest.length - 1].trim() === "" || rest[rest.length - 1].trim().endsWith(":"))) {
const last = rest.pop()!;
if (last.trim().endsWith(":")) break;
}
return (
<>
{rest.join("\n").trimEnd()}
{files.length > 0 && (
<div className="user-files">
{files.map((f) => (
<FileChip key={f.path} url={projectFileUrl(f.path)} name={f.path} sizeText={f.size} />
))}
</div>
)}
</>
);
}
/** Token counts like "221k" / "1.2M" (same in every language). */
function fmtK(n: number) {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1).replace(/\.0$/, "")}M`;
if (n >= 1000) return `${Math.round(n / 1000)}k`;
return String(n);
}
+1 -5
View File
@@ -1,3 +1,4 @@
import { formatSize } from "./FileFigure";
import { useEffect, useMemo, useRef, useState } from "react";
import { useAgents, useFetch } from "../hooks";
import { useT, type Key } from "../i18n";
@@ -17,11 +18,6 @@ const BUILTIN: { name: string; key: Key }[] = [
const MAX_SIDE = 2000;
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`;
}
/** Read an image and downscale it: models don't use more detail, and uploads stay small. */
async function readImage(file: File): Promise<ImageData> {
+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>
);
}
+14 -10
View File
@@ -2,6 +2,7 @@ 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;
@@ -31,7 +32,9 @@ export function SvgBlock({ code, children }: { code: string; children?: ReactNod
const url = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(code)))}`;
return (
<div className="svg-block">
<img src={url} alt="SVG" />
<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>
@@ -53,13 +56,14 @@ export const Markdown = memo(function Markdown({ text, baseDir }: { text: string
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 (
<a href={url} target="_blank" rel="noreferrer">
📎 {alt || src}
</a>
);
return <FileChip url={url} name={src} />;
}
return <img className="inline-image" src={url} alt={alt ?? ""} loading="lazy" />;
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);
@@ -105,9 +109,9 @@ export function ImageRefs({ text, baseDir }: { text: string; baseDir?: string })
{paths.map((p) => {
const url = projectFileUrl(p, baseDir)!;
return (
<a key={p} href={url} target="_blank" rel="noreferrer" title={p}>
<img src={url} alt={p} loading="lazy" onError={(e) => ((e.currentTarget.parentElement as HTMLElement).style.display = "none")} />
</a>
<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>
+16
View File
@@ -818,6 +818,19 @@ function Raw({ reload }: { reload: () => void }) {
const ADMIN_TABS = ["general", "providers", "agents", "mcp", "permissions", "users", "folders", "tools", "audit", "account", "raw"] as const;
const USER_TABS = ["general", "account"] as const;
const TAB_ICONS: Record<string, string> = {
general: "⚙️",
providers: "🧠",
agents: "🤖",
mcp: "🔌",
permissions: "🛡️",
users: "👥",
folders: "🗂️",
tools: "🧰",
audit: "📜",
account: "👤",
raw: "📝",
};
export function Settings({ tab, onMenu }: { tab?: string; onMenu: () => void }) {
const t = useT();
@@ -841,6 +854,9 @@ export function Settings({ tab, onMenu }: { tab?: string; onMenu: () => void })
<div className="tabs" role="tablist">
{TABS.map((x) => (
<button key={x} role="tab" aria-selected={active === x} className={active === x ? "active" : ""} onClick={() => navigate(`/settings/${x}`)}>
<span className="nav-icon" aria-hidden>
{TAB_ICONS[x]}
</span>
{t(`settings.tab.${x}` as Key)}
</button>
))}
+5
View File
@@ -91,20 +91,25 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
</span>
<ThemeModeButton />
<button className="small ghost" onClick={() => post("/auth/logout").then(() => location.reload())}>
<span className="nav-icon" aria-hidden>🚪</span>
{t("nav.logout")}
</button>
</div>
<div className="sidebar-foot">
<button className={`ghost ${section === "files" ? "active" : ""}`} onClick={() => navigate("/files")}>
<span className="nav-icon" aria-hidden>📁</span>
{t("nav.files")}
</button>
<button className={`ghost ${section === "images" ? "active" : ""}`} onClick={() => navigate("/images")}>
<span className="nav-icon" aria-hidden>🖼️</span>
{t("nav.images")}
</button>
<button className={`ghost ${section === "stats" ? "active" : ""}`} onClick={() => navigate("/stats")}>
<span className="nav-icon" aria-hidden>📊</span>
{t("nav.stats")}
</button>
<button className={`ghost ${section === "settings" ? "active" : ""}`} onClick={() => navigate("/settings")}>
<span className="nav-icon" aria-hidden>🔧</span>
{t("nav.settings")}
</button>
</div>
+10 -5
View File
@@ -1,3 +1,4 @@
import { base64Size, FileFigure } from "./FileFigure";
import { useT } from "../i18n";
import { navigate } from "../router";
import { useStore } from "../store";
@@ -91,9 +92,9 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
<span className="tool-title">{call.input.path}</span>
<span className="status ok">✓</span>
</div>
<a href={writtenImage} target="_blank" rel="noreferrer">
<FileFigure url={writtenImage} name={call.input.path}>
<img src={`${writtenImage}&v=${result!.call_id}`} alt={call.input.path} />
</a>
</FileFigure>
</div>
);
}
@@ -143,13 +144,17 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
{Array.isArray(meta?.image_ids) ? (
<div className="gallery small">
{meta.image_ids.map((id: string) => (
<a key={id} href={`/api/images/${id}/file`} target="_blank" rel="noreferrer" className="gallery-item">
<FileFigure key={id} url={`/api/images/${id}/file`} className="gallery-item">
<img src={`/api/images/${id}/file`} alt="" />
</a>
</FileFigure>
))}
</div>
) : (
result?.images?.map((img, i) => <img key={i} src={`data:${img.media_type};base64,${img.data}`} alt="" />)
result?.images?.map((img, i) => (
<FileFigure key={i} name={typeof call.input?.path === "string" ? call.input.path : undefined} mime={img.media_type} size={base64Size(img.data)}>
<img src={`data:${img.media_type};base64,${img.data}`} alt="" />
</FileFigure>
))
)}
</div>
</details>