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
@@ -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 }} />
|
||||
))}
|
||||
|
||||
@@ -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,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> {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -410,6 +410,7 @@ export const de: Messages = {
|
||||
"stats.prefillSpeed": "Prefill-Geschwindigkeit",
|
||||
"stats.costPerDay": "Kosten pro Tag nach Modell",
|
||||
"stats.tokensPerDay": "Tokens pro Tag",
|
||||
"stats.heatEmpty": "keine Aktivität",
|
||||
"stats.heatmap": "Aktivität nach Wochentag und Uhrzeit",
|
||||
"stats.byModel": "Nach Modell",
|
||||
"stats.byProvider": "Nach Provider",
|
||||
|
||||
@@ -409,6 +409,7 @@ export const en = {
|
||||
"stats.prefillSpeed": "Prefill speed",
|
||||
"stats.costPerDay": "Cost per day by model",
|
||||
"stats.tokensPerDay": "Tokens per day",
|
||||
"stats.heatEmpty": "no activity",
|
||||
"stats.heatmap": "Activity by weekday and hour",
|
||||
"stats.byModel": "By model",
|
||||
"stats.byProvider": "By provider",
|
||||
|
||||
@@ -2090,3 +2090,117 @@ button.danger-solid {
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* --- files in chat (preview + type/size badges + name) ------------------------- */
|
||||
.file-figure {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin: 6px 8px 6px 0;
|
||||
max-width: min(100%, 640px);
|
||||
vertical-align: top;
|
||||
}
|
||||
.file-preview {
|
||||
min-width: 128px;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
}
|
||||
.file-preview img {
|
||||
margin: 0 !important;
|
||||
display: block;
|
||||
}
|
||||
.file-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
line-height: 1;
|
||||
padding: 3px 6px;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--bg0) 82%, transparent);
|
||||
color: var(--fg1);
|
||||
border: 1px solid var(--bg3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.file-preview .file-badge {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
pointer-events: none;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.file-preview .file-badge.left {
|
||||
left: 6px;
|
||||
}
|
||||
.file-preview .file-badge.right {
|
||||
right: 6px;
|
||||
}
|
||||
.file-figure figcaption {
|
||||
font-size: 12px;
|
||||
color: var(--fg3);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
.file-figure figcaption a {
|
||||
color: inherit;
|
||||
}
|
||||
.file-figure.thumb img {
|
||||
height: 140px;
|
||||
max-width: 260px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.msg-user .file-figure img {
|
||||
max-height: 160px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.user-files {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.file-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 100%;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--bg3);
|
||||
background: var(--bg1);
|
||||
color: var(--fg1);
|
||||
font-size: 12px;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
margin: 2px 0;
|
||||
}
|
||||
.file-chip:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.file-chip-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
.file-chip-size {
|
||||
color: var(--muted);
|
||||
}
|
||||
.file-chip .file-badge {
|
||||
background: var(--bg2);
|
||||
border: none;
|
||||
}
|
||||
.gallery .file-figure { margin: 0; max-width: none; display: flex; }
|
||||
|
||||
.nav-icon {
|
||||
display: inline-block;
|
||||
width: 1.3em;
|
||||
margin-right: 6px;
|
||||
text-align: center;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
.sidebar-foot button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
--series-6: #008300;
|
||||
--series-7: #9085e9;
|
||||
--series-8: #e66767;
|
||||
--seq-0: var(--bg1);
|
||||
--seq-0: color-mix(in srgb, var(--fg1) 9%, var(--bg1));
|
||||
--seq-1: #104281;
|
||||
--seq-2: #184f95;
|
||||
--seq-3: #256abf;
|
||||
@@ -26,7 +26,7 @@
|
||||
--series-6: #008300;
|
||||
--series-7: #6250d6;
|
||||
--series-8: #e34948;
|
||||
--seq-0: var(--bg1);
|
||||
--seq-0: color-mix(in srgb, var(--fg1) 9%, var(--bg1));
|
||||
--seq-1: #cde2fb;
|
||||
--seq-2: #9ec5f4;
|
||||
--seq-3: #6da7ec;
|
||||
|
||||
Reference in new issue
Block a user