Stats history with year filter, folding and XLSX/CSV export; snippets, read-only links, commit suggestions, weekly review, offline copies, server speech-to-text, planning model, job reports; fal.ai health fix

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Sonnet 5.5 committed 2026-11-15 08:20:52 +01:00
1 parent c08c6c7436
commit 388ca96419
31 files changed
+1518 -82

No files matched your search

+19 -3
View File
@@ -2,7 +2,7 @@ import { lazy, Suspense, useEffect, useState } from "react";
import { connectEvents, get, setUnauthorizedHandler } from "./api";
import { useT } from "./i18n";
import { navigate, useRoute, closePane, MAX_PANES } from "./router";
import { applyEvent, loadSessions, resync, setState } from "./store";
import { applyEvent, loadSessions, resync, setState, useStore } from "./store";
import type { Info } from "./types";
import { ImageViewer } from "./components/ImageViewer";
import { ProjectPage } from "./components/ProjectPage";
@@ -22,6 +22,8 @@ import { WhatsNew } from "./components/WhatsNew";
import { ShareTarget } from "./components/ShareTarget";
import { Palette } from "./components/Palette";
import { pendingShare } from "./share";
import { cacheInfo, cachedInfo, isOffline } from "./offline";
import { PublicChat } from "./components/PublicChat";
// The file manager (editor, git) is loaded on demand to keep the chat small.
const Files = lazy(() => import("./components/Files").then((m) => ({ default: m.Files })));
@@ -34,11 +36,21 @@ export function App() {
const [error, setError] = useState<string>();
const [menuOpen, setMenuOpen] = useState(false);
const [shared, setShared] = useState(pendingShare);
const offline = useStore((s) => s.offline);
useTitleActivity();
const refresh = async () => {
try {
const i = await get<Info>("/info");
let i: Info;
try {
i = await get<Info>("/info");
cacheInfo(i);
} catch (e) {
// No connection: open with the saved copy (chats are read-only).
const saved = isOffline(e) ? cachedInfo() : undefined;
if (!saved) throw e;
i = saved;
}
setState(() => ({ me: i.user, update: i.update ?? null }));
if (i.user) {
await loadSessions();
@@ -71,6 +83,7 @@ export function App() {
// Close the mobile menu on navigation.
useEffect(() => setMenuOpen(false), [route.join("/")]);
if (route[0] === "shared" && route[1]) return <PublicChat token={route[1]} />;
if (error) return <div className="empty">{error}</div>;
if (!info) return <div className="empty">{t("common.loading")}</div>;
if (!info.user)
@@ -119,7 +132,10 @@ export function App() {
<div className="app">
{menuOpen && <div className="backdrop" onClick={() => setMenuOpen(false)} />}
<Sidebar open={menuOpen} activeIds={chatIds} section={section ?? ""} />
<main className="main">{content}</main>
<main className="main">
{offline && <div className="offline-bar" role="status">{t("offline.banner")}</div>}
{content}
</main>
<WhatsNew version={info.version} />
<Palette />
<ImageViewer />
+7 -3
View File
@@ -78,6 +78,7 @@ export function Composer({
const [mention, setMention] = useState<{ start: number; end: number; q: string }>();
const [mentionHits, setMentionHits] = useState<{ rel: string; is_dir: boolean }[]>([]);
const agents = useAgents(session.cwd).filter((a) => a.mode === "primary");
const snippets = useFetch<{ id: string; name: string; text: string }[]>("/account/snippets").data;
const cmdData = useFetch<{ commands: Suggestion[] }>(`/commands?cwd=${encodeURIComponent(session.cwd)}`).data;
// Auto-grow.
@@ -134,11 +135,14 @@ export function Composer({
...BUILTIN.map((b) => ({ name: b.name, description: t(b.key) })),
...(cmdData?.commands ?? []),
];
return all
const mine = (snippets ?? [])
.filter((s) => s.name.toLowerCase().startsWith(m[1].toLowerCase()))
.map((s) => ({ key: `snip:${s.id}`, label: `/${s.name}`, description: s.text.replace(/\s+/g, " ").slice(0, 80), apply: () => (setText(s.text), requestAnimationFrame(() => ref.current?.focus())) }));
const cmds = all
.filter((c) => c.name.startsWith(m[1]))
.slice(0, 12)
.map((c) => ({ key: c.name, label: `/${c.name}`, description: c.description, apply: () => setText(`/${c.name} `) }));
}, [text, cmdData, t, mention, mentionHits]);
return [...mine, ...cmds].slice(0, 12);
}, [text, cmdData, snippets, t, mention, mentionHits]);
const send = () => {
if ((!text.trim() && images.length === 0 && files.length === 0) || uploading > 0) return;
+7
View File
@@ -207,6 +207,13 @@ export function GitPanel({
<input type="checkbox" checked={amend} onChange={(e) => setAmend(e.target.checked)} /> {t("git.amend")}
</label>
<span className="spacer" />
<button
disabled={!!busy || staged.length === 0}
title={t("git.suggestHint")}
onClick={() => run("suggest", async () => setMessage((await call<{ message: string }>("suggest")).message))}
>
{busy === "suggest" ? <Spinner small /> : t("git.suggest")}
</button>
<button
className="primary"
disabled={!!busy || (!amend && (!message.trim() || staged.length === 0))}
+6
View File
@@ -20,6 +20,7 @@ interface Settings {
model: string;
permission_mode: string;
timeout_minutes: number;
notify: boolean;
}
const settingsOf = (j: JobInfo): Settings => ({
@@ -29,6 +30,7 @@ const settingsOf = (j: JobInfo): Settings => ({
model: j.spec?.model ?? "",
permission_mode: j.spec?.permission_mode ?? "auto",
timeout_minutes: j.spec?.timeout_minutes ?? 60,
notify: j.spec?.notify ?? true,
});
type Tab = "prompt" | "settings" | "assistant";
@@ -254,6 +256,10 @@ export function JobEditor({ id, onMenu }: { id: string; onMenu: () => void }) {
<input type="number" min={1} max={1440} value={settings.timeout_minutes} onChange={(e) => up({ timeout_minutes: Math.max(1, Number(e.target.value) || 60) })} />
</label>
</div>
<label className="row" style={{ gap: 6, alignItems: "center", marginTop: 6 }}>
<input type="checkbox" checked={settings.notify} onChange={(e) => up({ notify: e.target.checked })} />
{t("jobEditor.notify")}
</label>
{settings.permission_mode !== "bypass" && <div className="hint">{t("jobEditor.bypassHint")}</div>}
</section>
<section className="job-editor-prompt">
+1 -1
View File
@@ -17,7 +17,7 @@ export interface JobInfo {
next_run?: number | null;
error?: string;
webhook?: boolean;
spec?: { name: string; schedule: string; agent: string; model?: string | null; permission_mode: string; timeout_minutes: number; prompt: string; assistant_session?: string | null };
spec?: { name: string; schedule: string; agent: string; model?: string | null; permission_mode: string; timeout_minutes: number; notify?: boolean; prompt: string; assistant_session?: string | null };
}
const fmtDate = (ms?: number | null) => (ms ? new Date(ms).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : "–");
+41
View File
@@ -0,0 +1,41 @@
import { useEffect, useState } from "react";
import { get } from "../api";
import { fmtMsgTime, useT } from "../i18n";
import { Markdown } from "./Markdown";
interface Shared {
title: string;
messages: { id: string; role: "user" | "assistant"; text: string; created_at: number }[];
}
/** A chat opened through a read-only link (no account needed). */
export function PublicChat({ token }: { token: string }) {
const t = useT();
const [chat, setChat] = useState<Shared>();
const [error, setError] = useState<string>();
useEffect(() => {
get<Shared>(`/public/chat/${encodeURIComponent(token)}`)
.then((c) => (setChat(c), (document.title = `${c.title || "Chat"} – CodeCraft`)))
.catch((e) => setError(e.message));
}, [token]);
if (error) return <div className="empty">{error}</div>;
if (!chat) return <div className="empty">{t("common.loading")}</div>;
return (
<div className="public-chat">
<header>
<h1>{chat.title || "Chat"}</h1>
<div className="hint">{t("link.readOnly")}</div>
</header>
<div className="chat-scroll">
{chat.messages.map((m) => (
<div key={m.id} className={m.role === "user" ? "bubble user-msg" : "bubble agent"}>
<Markdown text={m.text} />
<div className="msg-foot">
<span className="hint">{fmtMsgTime(m.created_at)}</span>
</div>
</div>
))}
</div>
</div>
);
}
+38 -1
View File
@@ -146,8 +146,11 @@ function VersionInfo() {
function DigestToggle() {
const t = useT();
const [on, setOn] = useState<boolean>();
const [weekly, setWeekly] = useState(false);
useEffect(() => {
get<{ enabled: boolean }>("/push/digest").then((r) => setOn(r.enabled)).catch(() => {});
get<{ enabled: boolean; weekly?: boolean }>("/push/digest")
.then((r) => (setOn(r.enabled), setWeekly(!!r.weekly)))
.catch(() => {});
}, []);
if (on === undefined) return null;
return (
@@ -164,6 +167,30 @@ function DigestToggle() {
/>
{t("settings.digest")}
</label>
<label className="row" style={{ gap: 6, alignItems: "center" }}>
<input
type="checkbox"
checked={weekly}
onChange={(e) => {
const v = e.target.checked;
setWeekly(v);
put("/push/digest", { weekly: v }).catch((err) => (setWeekly(!v), toast(err.message)));
}}
/>
{t("settings.weeklyDigest")}
</label>
{weekly && (
<button
className="small ghost"
onClick={() =>
post<{ sent: number; errors: string[] }>("/push/digest?weekly=true")
.then((r) => toast(r.sent ? t("settings.pushTestSent") : r.errors.join("; ") || t("settings.pushTestNone"), r.sent ? "success" : "error"))
.catch((e) => toast(e.message))
}
>
{t("settings.digestNow")}
</button>
)}
{on && (
<button
className="small ghost"
@@ -246,6 +273,8 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
const [form, setForm] = useState({
default_model: s.default_model ?? "",
small_model: s.small_model ?? "",
plan_model: ((cfg.config as any).agents?.plan?.model ?? "") as string,
transcribe_model: ((s as any).transcribe_model ?? "") as string,
currency: s.currency ?? "USD",
auto_compact_threshold: s.auto_compact_threshold ?? 0.85,
commit_trailer: ((s as any).commit_trailer ?? "Co-Authored-By: {model} ({provider}) via CodeCraft") as string,
@@ -270,6 +299,8 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
savePatches([
{ path: ["server", "default_model"], value: form.default_model },
{ path: ["server", "small_model"], value: form.small_model },
{ path: ["agents", "plan", "model"], value: form.plan_model || null },
{ path: ["server", "transcribe_model"], value: form.transcribe_model.trim() },
{ path: ["server", "currency"], value: form.currency },
{ path: ["server", "auto_compact_threshold"], value: Number(form.auto_compact_threshold) },
{ path: ["server", "commit_trailer"], value: form.commit_trailer.trim() },
@@ -302,6 +333,12 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
<label>{t("settings.smallModel")}</label>
<ModelSelect value={form.small_model} onChange={(v) => setForm({ ...form, small_model: v })} />
<div className="hint">{t("settings.smallModelHint")}</div>
<label>{t("settings.planModel")}</label>
<ModelSelect value={form.plan_model} emptyLabel={t("settings.sameAsChat")} onChange={(v) => setForm({ ...form, plan_model: v })} />
<div className="hint">{t("settings.planModelHint")}</div>
<label>{t("settings.transcribeModel")}</label>
<input value={form.transcribe_model} placeholder="openai/whisper-1" onChange={(e) => setForm({ ...form, transcribe_model: e.target.value })} />
<div className="hint">{t("settings.transcribeHint")}</div>
<label>{t("settings.currency")}</label>
<input value={form.currency} onChange={(e) => setForm({ ...form, currency: e.target.value.toUpperCase() })} maxLength={3} />
<label>{t("settings.autoCompact")}</label>
+52 -1
View File
@@ -1,7 +1,9 @@
import { useEffect, useState } from "react";
import { get, put } from "../api";
import { del, get, post, put } from "../api";
import { useT } from "../i18n";
import { useStore } from "../store";
import { toast } from "../dialogs";
import { fmtDate } from "../i18n";
interface DirUser {
id: string;
@@ -9,6 +11,54 @@ interface DirUser {
display_name: string;
}
interface Link {
token: string;
url: string;
expires_at: number;
}
/** Read-only links for people without an account. */
function Links({ sessionId }: { sessionId: string }) {
const t = useT();
const [links, setLinks] = useState<Link[]>([]);
const [days, setDays] = useState(7);
const load = () => get<Link[]>(`/sessions/${sessionId}/links`).then(setLinks).catch(() => undefined);
useEffect(() => void load(), [sessionId]);
const create = async () => {
try {
const l = await post<Link>(`/sessions/${sessionId}/links`, { days });
await navigator.clipboard?.writeText(l.url).then(() => toast(t("link.copied"), "success"), () => undefined);
void load();
} catch (e: any) {
toast(e.message);
}
};
return (
<div className="share-links">
<h4>{t("link.title")}</h4>
<div className="hint">{t("link.hint")}</div>
{links.map((l) => (
<div key={l.token} className="row share-link">
<input readOnly value={l.url} onFocus={(e) => e.currentTarget.select()} aria-label={t("link.title")} />
<span className="hint">{t("link.until", { date: fmtDate(l.expires_at, false) })}</span>
<button onClick={() => navigator.clipboard?.writeText(l.url).then(() => toast(t("link.copied"), "success"))}>{t("link.copy")}</button>
<button onClick={() => del(`/sessions/${sessionId}/links/${l.token}`).then(load).catch((e) => toast(e.message))}>{t("link.revoke")}</button>
</div>
))}
<div className="row" style={{ marginTop: 6 }}>
<select value={days} onChange={(e) => setDays(Number(e.target.value))} aria-label={t("link.validity")}>
{[1, 7, 30, 90].map((d) => (
<option key={d} value={d}>
{t("link.days", { n: d })}
</option>
))}
</select>
<button onClick={create}>{t("link.create")}</button>
</div>
</div>
);
}
/** Choose users a session is shared with (read-only for them). */
export function ShareDialog({ sessionId, onClose }: { sessionId: string; onClose: () => void }) {
const t = useT();
@@ -59,6 +109,7 @@ export function ShareDialog({ sessionId, onClose }: { sessionId: string; onClose
</label>
))}
</div>
<Links sessionId={sessionId} />
{error && <div className="notice err">{error}</div>}
<div className="row" style={{ marginTop: 12, justifyContent: "flex-end" }}>
<button onClick={onClose}>{t("common.cancel")}</button>
+186 -56
View File
@@ -1,5 +1,5 @@
import { Icon } from "./Icon";
import { useMemo, useState } from "react";
import { Fragment, useEffect, useMemo, useState } from "react";
import { useFetch } from "../hooks";
import { fmtCompact, fmtCost, fmtDate, fmtNumber, fmtPercent, getLang, useT, type Key } from "../i18n";
import { navigate } from "../router";
@@ -15,6 +15,9 @@ interface StatsData {
total: Aggregate;
by_day: Row<"day">[];
by_day_model: { day: string; model: string; cost: number; input_tokens: number; output_tokens: number }[];
by_month: Row<"month">[];
/** First recorded request (any range): the years to choose from. */
first_ts: number | null;
by_model: Row<"model">[];
by_provider: Row<"provider">[];
by_agent: Row<"agent">[];
@@ -37,6 +40,16 @@ const RANGES: { key: string; days: number; label: Key }[] = [
type HeatMetric = "requests" | "tokens" | "cost";
/** All months (YYYY-MM, local time) from the month of `fromMs` to the month of `toMs`. */
function monthRange(fromMs: number, toMs: number): string[] {
const out: string[] = [];
const d = new Date(fromMs);
d.setDate(1);
d.setHours(0, 0, 0, 0);
for (; d.getTime() <= toMs && out.length < 600; d.setMonth(d.getMonth() + 1)) out.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`);
return out;
}
function dayKey(ms: number) {
const d = new Date(ms);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
@@ -51,6 +64,44 @@ function dayRange(fromMs: number, toMs: number, firstDataDay?: string): string[]
return out;
}
const FOLD_KEY = "codecraft.statsFolded";
function foldedSet(): Set<string> {
try {
return new Set(JSON.parse(localStorage.getItem(FOLD_KEY) ?? "[]"));
} catch {
return new Set();
}
}
/** Panel with a title that folds the content; the state is remembered per device. */
function Fold({ id, title, children }: { id: string; title: React.ReactNode; children: React.ReactNode }) {
const [closed, setClosed] = useState(() => foldedSet().has(id));
const toggle = () => {
const next = !closed;
setClosed(next);
try {
const set = foldedSet();
if (next) set.add(id);
else set.delete(id);
localStorage.setItem(FOLD_KEY, JSON.stringify([...set]));
} catch {
/* storage unavailable */
}
};
return (
<div className={`panel fold${closed ? " closed" : ""}`}>
<h3>
<button className="fold-toggle" aria-expanded={!closed} onClick={toggle}>
<span className="fold-caret" aria-hidden="true" />
{title}
</button>
</h3>
{!closed && children}
</div>
);
}
function Kpi({ label, value, sub }: { label: string; value: string; sub?: string }) {
return (
<div className="kpi">
@@ -67,8 +118,39 @@ function perMillion(r: Aggregate, currency: string) {
return tokens > 0 && r.cost > 0 ? fmtCost((r.cost / tokens) * 1_000_000, currency) : "–";
}
function AggTable({ rows, keyName, label, currency, link }: { rows: (Aggregate & Record<string, any>)[]; keyName: string; label: string; currency: string; link?: (r: any) => void }) {
function AggTable({
rows,
keyName,
label,
currency,
link,
group,
}: {
rows: (Aggregate & Record<string, any>)[];
keyName: string;
label: string;
currency: string;
link?: (r: any) => void;
/** Group key of a row (e.g. the provider of a model): groups fold. */
group?: (r: any) => string;
}) {
const t = useT();
const [shut, setShut] = useState<Set<string>>(new Set());
const groups = group ? [...new Set(rows.map(group))] : [];
const row = (r: (typeof rows)[number]) => (
<tr key={r[keyName]}>
<td className={group ? "fold-indent" : undefined}>{link ? <a href="#" onClick={(e) => (e.preventDefault(), link(r))}>{r[keyName]}</a> : r[keyName]}</td>
<td className="num">{fmtNumber(r.requests)}</td>
<td className="num">{fmtCompact(r.input_tokens + r.cache_write_tokens)}</td>
<td className="num">{fmtCompact(r.cache_read_tokens)}</td>
<td className="num">{fmtCompact(r.output_tokens)}</td>
<td className="num">{fmtNumber(r.avg_ttft_ms)} ms</td>
<td className="num">{fmtNumber(r.output_tps, 1)}</td>
<td className="num">{fmtNumber(r.prefill_tps)}</td>
<td className="num">{fmtCost(r.cost, currency)}</td>
<td className="num">{perMillion(r, currency)}</td>
</tr>
);
return (
<div style={{ overflowX: "auto" }}>
<table className="grid">
@@ -87,20 +169,33 @@ function AggTable({ rows, keyName, label, currency, link }: { rows: (Aggregate &
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r[keyName]}>
<td>{link ? <a href="#" onClick={(e) => (e.preventDefault(), link(r))}>{r[keyName]}</a> : r[keyName]}</td>
<td className="num">{fmtNumber(r.requests)}</td>
<td className="num">{fmtCompact(r.input_tokens + r.cache_write_tokens)}</td>
<td className="num">{fmtCompact(r.cache_read_tokens)}</td>
<td className="num">{fmtCompact(r.output_tokens)}</td>
<td className="num">{fmtNumber(r.avg_ttft_ms)} ms</td>
<td className="num">{fmtNumber(r.output_tps, 1)}</td>
<td className="num">{fmtNumber(r.prefill_tps)}</td>
<td className="num">{fmtCost(r.cost, currency)}</td>
<td className="num">{perMillion(r, currency)}</td>
</tr>
))}
{group
? groups.map((g) => {
const members = rows.filter((r) => group(r) === g);
const open = !shut.has(g);
return (
<Fragment key={g}>
<tr className="fold-group">
<td>
<button
className="fold-toggle"
aria-expanded={open}
onClick={() => setShut((x) => (x.has(g) ? new Set([...x].filter((k) => k !== g)) : new Set(x).add(g)))}
>
<span className="fold-caret" aria-hidden="true" />
{g} <span className="hint">({members.length})</span>
</button>
</td>
<td className="num">{fmtNumber(members.reduce((a, r) => a + r.requests, 0))}</td>
<td colSpan={6} />
<td className="num">{fmtCost(members.reduce((a, r) => a + r.cost, 0), currency)}</td>
<td />
</tr>
{open && members.map(row)}
</Fragment>
);
})
: rows.map(row)}
</tbody>
</table>
</div>
@@ -117,16 +212,40 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
const isAdmin = useStore((s) => s.me?.role === "admin");
const users = useFetch<User[]>(isAdmin ? "/users" : null).data ?? [];
const [userFilter, setUserFilter] = useState("");
const days = RANGES.find((r) => r.key === range)!.days;
const tz = -new Date().getTimezoneOffset();
// Stable per range: rounded to the minute so the URL doesn't change on every render.
const now = Math.floor(Date.now() / 60000) * 60000 + 60000;
const from = days ? now - days * 86400000 : 0;
const { data, error } = useFetch<StatsData>(`/stats?from=${from}&to=${now}&tz=${tz}${userFilter ? `&user=${userFilter}` : ""}`);
// "y2025": that calendar year (local time); otherwise a rolling window.
const year = /^y(\d{4})$/.test(range) ? Number(range.slice(1)) : 0;
const days = RANGES.find((r) => r.key === range)?.days ?? 0;
const from = year ? new Date(year, 0, 1).getTime() : days ? now - days * 86400000 : 0;
const to = year ? Math.min(now, new Date(year + 1, 0, 1).getTime()) : now;
const { data, error } = useFetch<StatsData>(`/stats?from=${from}&to=${to}&tz=${tz}${userFilter ? `&user=${userFilter}` : ""}`);
/** Download of what is shown: all tables as XLSX, or one table as CSV. */
const exportAs = (what: string) => {
if (!what) return;
const q = `from=${from}&to=${to}&tz=${tz}&lang=${getLang()}${userFilter ? `&user=${userFilter}` : ""}`;
const a = document.createElement("a");
a.href = `/api/stats/export?${q}&format=${what === "xlsx" ? "xlsx" : "csv&table=" + what.slice(4)}`;
a.download = "";
document.body.appendChild(a);
a.click();
a.remove();
};
// Years since the first recorded request (the data survives indefinitely).
const [firstYear, setFirstYear] = useState(new Date().getFullYear());
useEffect(() => {
if (data?.first_ts) setFirstYear(new Date(data.first_ts).getFullYear());
}, [data?.first_ts]);
const years = Array.from({ length: new Date().getFullYear() - firstYear + 1 }, (_, i) => new Date().getFullYear() - i);
const spanDays = ((data?.to ?? now) - (data?.from || data?.first_ts || now)) / 86400000;
// Long ranges: one bar per month instead of one per day.
const monthly = spanDays > 120;
const view = useMemo(() => {
if (!data) return undefined;
const cats = dayRange(data.from, data.to, data.by_day[0]?.day);
const bucket = (day: string) => (monthly ? day.slice(0, 7) : day);
const cats = monthly ? monthRange(data.from || data.first_ts || data.to, data.to) : dayRange(data.from, data.to, data.by_day[0]?.day);
// Up to 7 models by cost get their own color; the rest fold into "Other".
const ranked = [...data.by_model].sort((a, b) => b.cost - a.cost || b.requests - a.requests);
const named = ranked.slice(0, ranked.length > 8 ? 7 : 8).map((m) => m.model);
@@ -135,7 +254,7 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
const costByDay = cats.map(() => ({}) as Record<string, number>);
const idx = new Map(cats.map((c, i) => [c, i]));
for (const r of data.by_day_model) {
const i = idx.get(r.day);
const i = idx.get(bucket(r.day));
if (i === undefined) continue;
const key = named.includes(r.model) ? r.model : other;
costByDay[i][key] = (costByDay[i][key] ?? 0) + r.cost;
@@ -146,7 +265,7 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
{ key: "output", label: t("stats.output"), color: SERIES[2] },
];
const tokensByDay = cats.map((c): Record<string, number> => {
const d = data.by_day.find((x) => x.day === c);
const d = monthly ? data.by_month.find((x) => x.month === c) : data.by_day.find((x) => x.day === c);
return d ? { input: d.input_tokens + d.cache_write_tokens, cache: d.cache_read_tokens, output: d.output_tokens } : {};
});
// Rolling window: the last 14 days, today at the bottom.
@@ -174,10 +293,12 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
return { label: `${wd.format(date)} ${dm.format(date)}`, title: full.format(date) };
});
return { cats, modelSeries, costByDay, tokenSeries, tokensByDay, cells, heatRows };
}, [data, heat, t]);
}, [data, heat, t, monthly]);
const dayLabel = (d: string) =>
new Intl.DateTimeFormat(getLang(), { day: "numeric", month: "short" }).format(new Date(`${d}T00:00:00`));
monthly
? new Intl.DateTimeFormat(getLang(), { month: "short", year: "2-digit" }).format(new Date(`${d}-01T00:00:00`))
: new Intl.DateTimeFormat(getLang(), { day: "numeric", month: "short" }).format(new Date(`${d}T00:00:00`));
const cur = data?.currency ?? "USD";
const tot = data?.total;
const heatFormat = (v: number) => (heat === "cost" ? fmtCost(v, cur) : fmtCompact(v));
@@ -203,6 +324,16 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
</button>
))}
</div>
{years.length > 1 && (
<select value={year ? range : ""} onChange={(e) => e.target.value && setRange(e.target.value)} aria-label={t("stats.year")} className={year ? "active" : ""}>
<option value="">{t("stats.year")}</option>
{years.map((y) => (
<option key={y} value={`y${y}`}>
{y}
</option>
))}
</select>
)}
{isAdmin && (
<select value={userFilter} onChange={(e) => setUserFilter(e.target.value)} aria-label={t("stats.user")}>
<option value="">{t("stats.allUsers")}</option>
@@ -213,6 +344,15 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
))}
</select>
)}
<select value="" onChange={(e) => exportAs(e.target.value)} aria-label={t("stats.export")} title={t("stats.exportHint")}>
<option value="">{t("stats.export")}</option>
<option value="xlsx">{t("stats.exportXlsx")}</option>
{(["day", "month", "model", "provider", "agent", "user", "session"] as const).map((k) => (
<option key={k} value={`csv:${k}`}>
{t("stats.exportCsv", { table: t(`stats.table.${k}` as Key) })}
</option>
))}
</select>
<button className="small" onClick={() => setTableView(!tableView)}>
{tableView ? t("stats.chartView") : t("stats.tableView")}
</button>
@@ -238,61 +378,51 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
{tot.requests === 0 ? (
<div className="empty">{t("stats.noData")}</div>
) : tableView ? (
<div className="panel">
<h3>{t("stats.perDay")}</h3>
<Fold id="perDay" title={t("stats.perDay")}>
<AggTable rows={data.by_day} keyName="day" label={t("stats.day")} currency={cur} />
</div>
</Fold>
) : (
<>
<div className="panel">
<h3>{t("stats.costPerDay")}</h3>
<Fold id="costPerDay" title={t(monthly ? "stats.costPerMonth" : "stats.costPerDay")}>
<Legend series={view.modelSeries} />
<StackedBars categories={view.cats} categoryLabel={dayLabel} series={view.modelSeries} values={view.costByDay} format={(v) => fmtCost(v, cur)} />
</div>
<div className="panel">
<h3>{t("stats.tokensPerDay")}</h3>
</Fold>
<Fold id="tokensPerDay" title={t(monthly ? "stats.tokensPerMonth" : "stats.tokensPerDay")}>
<Legend series={view.tokenSeries} />
<StackedBars categories={view.cats} categoryLabel={dayLabel} series={view.tokenSeries} values={view.tokensByDay} format={fmtCompact} />
</div>
<div className="panel">
<h3>
{t("stats.heatmap")}
<span className="spacer" />
<select value={heat} onChange={(e) => setHeat(e.target.value as HeatMetric)}>
</Fold>
<Fold id="heatmap" title={t("stats.heatmap")}>
<div className="row" style={{ justifyContent: "flex-end", marginBottom: 6 }}>
<select value={heat} onChange={(e) => setHeat(e.target.value as HeatMetric)} aria-label={t("stats.heatmap")}>
<option value="requests">{t("stats.requests")}</option>
<option value="tokens">{t("stats.tokens")}</option>
<option value="cost">{t("stats.cost")}</option>
</select>
</h3>
</div>
<Heatmap cells={view.cells} rows={view.heatRows} format={heatFormat} />
</div>
</Fold>
</>
)}
{tot.requests > 0 && (
<>
<div className="panel">
<h3>{t("stats.byModel")}</h3>
<AggTable rows={data.by_model} keyName="model" label={t("stats.model")} currency={cur} />
</div>
<div className="panel">
<h3>{t("stats.byProvider")}</h3>
<Fold id="byModel" title={t("stats.byModel")}>
<AggTable rows={data.by_model} keyName="model" label={t("stats.model")} currency={cur} group={(r) => String(r.model).split("/")[0]} />
</Fold>
<Fold id="byProvider" title={t("stats.byProvider")}>
<AggTable rows={data.by_provider} keyName="provider" label={t("stats.provider")} currency={cur} />
</div>
<div className="panel">
<h3>{t("stats.byAgent")}</h3>
</Fold>
<Fold id="byAgent" title={t("stats.byAgent")}>
<AggTable rows={data.by_agent} keyName="agent" label={t("stats.agent")} currency={cur} />
</div>
</Fold>
{isAdmin && !userFilter && data.by_user.length > 1 && (
<div className="panel">
<h3>{t("stats.byUser")}</h3>
<Fold id="byUser" title={t("stats.byUser")}>
<AggTable rows={data.by_user} keyName="user" label={t("stats.user")} currency={cur} />
</div>
</Fold>
)}
<div className="panel">
<h3>{t("stats.topSessions")}</h3>
<Fold id="topSessions" title={t("stats.topSessions")}>
<AggTable rows={data.top_sessions} keyName="title" label={t("stats.session")} currency={cur} link={(r) => !r.session_id.startsWith("cli:") && navigate(`/s/${r.session_id}`)} />
</div>
</Fold>
</>
)}
</>
+78 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { getLang, useT } from "../i18n";
import { toast } from "../dialogs";
import { get } from "../api";
import { Icon } from "./Icon";
/* Web Speech API (Chrome/Edge/Safari; Firefox has none). */
@@ -18,10 +19,86 @@ const Impl: (new () => Recognition) | undefined = (window as any).SpeechRecognit
export const voiceSupported = () => !!Impl;
/** Server-side transcription is on (server.transcribe_model) – for browsers without dictation. */
function useServerSpeech(): boolean {
const [on, setOn] = useState(false);
useEffect(() => {
if (Impl) return;
get<{ transcribe?: boolean }>("/config")
.then((c) => setOn(!!c.transcribe))
.catch(() => undefined);
}, []);
return on;
}
/** Record with the microphone; the audio is transcribed by the server. */
function RecorderButton({ onText, onInterim }: { onText: (text: string) => void; onInterim: (text: string) => void }) {
const t = useT();
const [state, setState] = useState<"idle" | "recording" | "sending">("idle");
const rec = useRef<MediaRecorder | null>(null);
useEffect(() => () => rec.current?.stream.getTracks().forEach((x) => x.stop()), []);
const toggle = async () => {
if (state === "recording") {
rec.current?.stop();
return;
}
if (state === "sending") return;
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const r = new MediaRecorder(stream);
const chunks: Blob[] = [];
r.ondataavailable = (e) => e.data.size && chunks.push(e.data);
r.onstop = async () => {
stream.getTracks().forEach((x) => x.stop());
setState("sending");
onInterim(t("voice.transcribing"));
try {
const blob = new Blob(chunks, { type: r.mimeType || "audio/webm" });
const res = await fetch(`/api/transcribe?lang=${getLang()}`, { method: "POST", credentials: "same-origin", headers: { "Content-Type": blob.type }, body: blob });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error ?? data.message ?? `HTTP ${res.status}`);
if (data.text) onText(String(data.text));
} catch (e: any) {
toast(`${t("voice.error")}: ${e.message}`);
} finally {
onInterim("");
setState("idle");
}
};
rec.current = r;
r.start();
setState("recording");
} catch {
toast(t("voice.denied"));
}
};
return (
<button
type="button"
className={`ghost small voice-btn ${state === "recording" ? "on" : ""}`}
disabled={state === "sending"}
onClick={() => void toggle()}
title={t(state === "recording" ? "voice.stop" : "voice.start")}
aria-label={t(state === "recording" ? "voice.stop" : "voice.start")}
aria-pressed={state === "recording"}
>
<Icon name="mic" />
</button>
);
}
const LOCALES: Record<string, string> = { de: "de-DE", en: "en-US" };
/** Microphone button: dictated text is appended to the message. */
export function VoiceButton({ onText, onInterim }: { onText: (text: string) => void; onInterim: (text: string) => void }) {
export function VoiceButton(props: { onText: (text: string) => void; onInterim: (text: string) => void }) {
const server = useServerSpeech();
if (!Impl) return server ? <RecorderButton {...props} /> : null;
return <BrowserVoiceButton {...props} />;
}
function BrowserVoiceButton({ onText, onInterim }: { onText: (text: string) => void; onInterim: (text: string) => void }) {
const t = useT();
const [listening, setListening] = useState(false);
const rec = useRef<Recognition | null>(null);
+2
View File
@@ -6,6 +6,7 @@ import { GitAccount } from "./GitAccount";
import { ApiTokens } from "./ApiTokens";
import { CliInstall } from "./CliInstall";
import { Memory } from "./Memory";
import { Snippets } from "./Snippets";
import { ClaudeCode } from "./ClaudeCode";
import { Telegram } from "./Telegram";
@@ -51,6 +52,7 @@ export function Account() {
<div style={{ fontSize: 12, color: "var(--muted)", marginTop: 8 }}>{t("account.hint")}</div>
</div>
<Memory />
<Snippets />
<GitAccount />
<CliInstall />
<ClaudeCode />
+61
View File
@@ -0,0 +1,61 @@
import { useEffect, useState } from "react";
import { get, put } from "../../api";
import { useT } from "../../i18n";
import { toast } from "../../dialogs";
import { Icon } from "../Icon";
export interface Snippet {
id?: string;
name: string;
text: string;
}
/** Personal prompt snippets: typed as `/name` in the composer. */
export function Snippets() {
const t = useT();
const [items, setItems] = useState<Snippet[]>();
useEffect(() => {
get<Snippet[]>("/account/snippets")
.then(setItems)
.catch((e) => toast(e.message));
}, []);
if (!items) return null;
const set = (i: number, patch: Partial<Snippet>) => setItems(items.map((s, j) => (j === i ? { ...s, ...patch } : s)));
const save = () => {
const clean = items.filter((s) => s.name.trim() || s.text.trim()).map((s) => ({ ...s, name: s.name.trim().replace(/^\/+/, "").replace(/\s+/g, "-") }));
put<Snippet[]>("/account/snippets", clean)
.then((r) => (setItems(r), toast(t("settings.saved"), "success")))
.catch((e) => toast(e.message));
};
return (
<div className="panel">
<h3>
<Icon name="sparkle" /> {t("snippets.title")}
</h3>
<div className="hint" style={{ marginBottom: 8 }}>
{t("snippets.hint")}
</div>
{items.map((s, i) => (
<div key={s.id ?? i} className="snippet-row">
<div className="row">
<span className="snippet-slash">/</span>
<input value={s.name} placeholder={t("snippets.name")} maxLength={40} onChange={(e) => set(i, { name: e.target.value })} aria-label={t("snippets.name")} />
<button title={t("common.delete")} aria-label={t("common.delete")} onClick={() => setItems(items.filter((_, j) => j !== i))}>
<Icon name="trash" />
</button>
</div>
<textarea rows={2} value={s.text} placeholder={t("snippets.text")} onChange={(e) => set(i, { text: e.target.value })} aria-label={t("snippets.text")} />
</div>
))}
<div className="row" style={{ marginTop: 8 }}>
<button onClick={() => setItems([...items, { name: "", text: "" }])}>
<Icon name="plus" /> {t("snippets.add")}
</button>
<span className="grow" />
<button className="primary" onClick={save}>
{t("common.save")}
</button>
</div>
</div>
);
}
+43
View File
@@ -1101,4 +1101,47 @@ export const de: Messages = {
"stats.user": "Benutzer",
"stats.allUsers": "Alle Benutzer",
"stats.byUser": "Nach Benutzer",
"snippets.title": "Prompt-Bausteine",
"snippets.hint": "Eigene Textbausteine. Tippe / im Nachrichtenfeld und wähle einen Baustein, um ihn einzufügen (z. B. /tests → \"Schreibe Tests zu dieser Änderung\").",
"snippets.name": "Name",
"snippets.text": "Einzufügender Text",
"snippets.add": "Baustein hinzufügen",
"snippets.insert": "Baustein",
"link.title": "Nur-Lese-Link",
"link.hint": "Wer den Link hat, kann den Gesprächstext lesen (keine Tool-Ausgaben, keine Dateien) – ohne Konto.",
"link.create": "Link erstellen",
"link.copy": "Kopieren",
"link.copied": "Link kopiert",
"link.revoke": "Widerrufen",
"link.validity": "Gültigkeit",
"link.days": "{n} Tage",
"link.until": "bis {date}",
"link.readOnly": "Schreibgeschützt geteilt",
"git.suggest": "Nachricht vorschlagen",
"git.suggestHint": "Das kleine Modell schreibt eine Commit-Nachricht für die vorgemerkten Änderungen",
"settings.weeklyDigest": "Wochenrückblick montags um 8:00 (Chats, Checks, Kosten)",
"settings.transcribeModel": "Spracherkennungs-Modell",
"settings.transcribeHint": "provider/modell eines OpenAI-kompatiblen Transkriptions-Endpunkts (z. B. openai/whisper-1). Schaltet den Mikrofon-Knopf in Browsern ohne eigene Diktierfunktion frei, etwa Firefox.",
"voice.transcribing": "Wird transkribiert …",
"offline.banner": "Keine Verbindung – gespeicherte Kopien. Geschriebene Nachrichten warten im Postausgang.",
"settings.planModel": "Planungsmodell",
"settings.planModelHint": "Der Plan-Modus nutzt immer dieses Modell (z. B. ein starkes); alle anderen Modi das im Chat gewählte (z. B. ein günstigeres). Leer = Modell des Chats.",
"settings.sameAsChat": "(Modell des Chats)",
"jobEditor.notify": "Bericht als Push-Nachricht senden, wenn ein Lauf endet",
"stats.year": "Jahr",
"stats.month": "Monat",
"stats.perMonth": "Pro Monat",
"stats.costPerMonth": "Kosten pro Monat",
"stats.tokensPerMonth": "Tokens pro Monat",
"stats.export": "Export",
"stats.exportHint": "Die Werte des gewählten Zeitraums herunterladen",
"stats.exportXlsx": "Excel (.xlsx) – alle Tabellen",
"stats.exportCsv": "CSV – {table}",
"stats.table.day": "pro Tag",
"stats.table.month": "pro Monat",
"stats.table.model": "nach Modell",
"stats.table.provider": "nach Anbieter",
"stats.table.agent": "nach Modus",
"stats.table.user": "nach Benutzer",
"stats.table.session": "teuerste Chats",
};
+43
View File
@@ -1100,6 +1100,49 @@ export const en = {
"stats.user": "User",
"stats.allUsers": "All users",
"stats.byUser": "By user",
"snippets.title": "Prompt snippets",
"snippets.hint": "Your own text blocks. Type / in the message field and pick a snippet to insert it (e.g. /tests → \"Add tests for this change\").",
"snippets.name": "name",
"snippets.text": "Text to insert",
"snippets.add": "Add snippet",
"snippets.insert": "Snippet",
"link.title": "Read-only link",
"link.hint": "Anyone with the link can read the conversation text (no tool output, no files) – without an account.",
"link.create": "Create link",
"link.copy": "Copy",
"link.copied": "Link copied",
"link.revoke": "Revoke",
"link.validity": "Validity",
"link.days": "{n} days",
"link.until": "until {date}",
"link.readOnly": "Shared read-only",
"git.suggest": "Suggest message",
"git.suggestHint": "Let the small model write a commit message for the staged changes",
"settings.weeklyDigest": "Weekly review on Mondays at 8:00 (chats, checks, costs)",
"settings.transcribeModel": "Speech-to-text model",
"settings.transcribeHint": "provider/model of an OpenAI-compatible transcription endpoint (e.g. openai/whisper-1). Adds the microphone button in browsers without built-in dictation, such as Firefox.",
"voice.transcribing": "Transcribing …",
"offline.banner": "No connection – showing saved copies. Messages you write wait in the outbox.",
"settings.planModel": "Planning model",
"settings.planModelHint": "Plan mode always uses this model (e.g. a strong one); all other modes use the model chosen for the chat (e.g. a cheaper one). Empty = the chat's model.",
"settings.sameAsChat": "(model of the chat)",
"jobEditor.notify": "Send the report as a push notification when a run ends",
"stats.year": "Year",
"stats.month": "Month",
"stats.perMonth": "Per month",
"stats.costPerMonth": "Cost per month",
"stats.tokensPerMonth": "Tokens per month",
"stats.export": "Export",
"stats.exportHint": "Download the figures of the selected range",
"stats.exportXlsx": "Excel (.xlsx) – all tables",
"stats.exportCsv": "CSV – {table}",
"stats.table.day": "per day",
"stats.table.month": "per month",
"stats.table.model": "by model",
"stats.table.provider": "by provider",
"stats.table.agent": "by agent",
"stats.table.user": "by user",
"stats.table.session": "top chats",
};
export type Messages = Record<keyof typeof en, string>;
+86
View File
@@ -0,0 +1,86 @@
import type { Info, Message, Part, Session } from "./types";
/**
* Read-only copy of the app's last state for the offline case (train, tunnel): the chat
* list and the most recently opened chats. Kept small: attachments are dropped and long
* tool output is cut. Everything is best-effort – storage may be full or blocked.
*/
const KEY = "codecraft.offline.v1";
const MAX_CHATS = 6;
const MAX_MESSAGES = 80;
const MAX_BYTES = 2_500_000;
interface Cache {
info?: Info;
sessions?: Session[];
chats: Record<string, { at: number; session: Session; messages: Message[] }>;
}
function read(): Cache {
try {
const c = JSON.parse(localStorage.getItem(KEY) ?? "null");
if (c && typeof c === "object") return { chats: {}, ...c };
} catch {
/* empty or corrupt */
}
return { chats: {} };
}
function write(c: Cache) {
try {
// Oldest chats go first when the copy gets too big.
let text = JSON.stringify(c);
while (text.length > MAX_BYTES && Object.keys(c.chats).length > 0) {
const oldest = Object.entries(c.chats).sort((a, b) => a[1].at - b[1].at)[0][0];
delete c.chats[oldest];
text = JSON.stringify(c);
}
localStorage.setItem(KEY, text);
} catch {
/* storage full or unavailable */
}
}
function slim(part: Part): Part {
if (part.type === "image") return { type: "text", text: "[image]" } as Part;
if (part.type === "tool_result") return { ...part, content: part.content.slice(0, 1500), images: [], meta: undefined } as Part;
return part;
}
export function cacheInfo(info: Info) {
if (!info.user) return;
write({ ...read(), info });
}
export function cachedInfo(): Info | undefined {
return read().info;
}
export function cacheSessions(sessions: Session[]) {
write({ ...read(), sessions });
}
export function cachedSessions(): Session[] {
return read().sessions ?? [];
}
export function cacheChat(session: Session, messages: Message[]) {
const c = read();
c.chats[session.id] = {
at: Date.now(),
session,
messages: messages.slice(-MAX_MESSAGES).map((m) => ({ ...m, parts: m.parts.map(slim) })),
};
const ids = Object.entries(c.chats).sort((a, b) => b[1].at - a[1].at);
for (const [id] of ids.slice(MAX_CHATS)) delete c.chats[id];
write(c);
}
export function cachedChat(id: string) {
return read().chats[id];
}
/** A request failed because there is no connection (as opposed to an answer from the server). */
export function isOffline(e: unknown): boolean {
return e instanceof TypeError || !navigator.onLine;
}
+28 -4
View File
@@ -1,3 +1,4 @@
import { cacheChat, cacheSessions, cachedChat, cachedSessions, isOffline } from "./offline";
import { useSyncExternalStore } from "react";
import { get } from "./api";
import type {
@@ -29,6 +30,8 @@ export interface State {
/** Newer release (admins). */
update: { version: string; url: string; notes: string } | null;
connected: boolean;
/** Showing a saved copy: the server cannot be reached. */
offline: boolean;
sessions: Session[];
/** Includes subagent sessions loaded on demand. */
sessionById: Record<string, Session>;
@@ -60,6 +63,7 @@ let state: State = {
me: null,
update: null,
connected: false,
offline: false,
sessions: [],
sessionById: {},
messages: {},
@@ -236,7 +240,17 @@ export function dismissError(sessionId: string, id: number) {
}
export async function loadSessions() {
const list = await get<Session[]>("/sessions");
let list: Session[];
try {
list = await get<Session[]>("/sessions");
cacheSessions(list);
if (getState().offline) setState(() => ({ offline: false }));
} catch (e) {
if (!isOffline(e)) throw e;
list = cachedSessions().map((x) => ({ ...x, busy: false }));
setState(() => ({ offline: true }));
if (list.length === 0) throw e;
}
setState((s) => ({
sessions: list,
sessionById: { ...s.sessionById, ...Object.fromEntries(list.map((x) => [x.id, x])) },
@@ -250,9 +264,19 @@ export const PAGE = 200;
type Page = { messages: Message[]; has_older: boolean; has_newer: boolean };
export async function loadSession(id: string) {
const data = await get<{ session: Session; messages: Message[]; busy: boolean; can_write: boolean; queue: QueueItem[]; step?: any; has_older?: boolean; has_newer?: boolean }>(
`/sessions/${id}?limit=${PAGE}`,
);
type Loaded = { session: Session; messages: Message[]; busy: boolean; can_write: boolean; queue: QueueItem[]; step?: any; has_older?: boolean; has_newer?: boolean };
let data: Loaded;
try {
data = await get<Loaded>(`/sessions/${id}?limit=${PAGE}`);
cacheChat(data.session, data.messages);
if (getState().offline) setState(() => ({ offline: false }));
} catch (e) {
const copy = isOffline(e) ? cachedChat(id) : undefined;
if (!copy) throw e;
// No connection: show the saved copy; new messages wait in the outbox.
data = { session: copy.session, messages: copy.messages, busy: false, can_write: true, queue: [], has_older: true };
setState(() => ({ offline: true }));
}
setState((s) => ({
windows: { ...s.windows, [id]: { older: !!data.has_older, newer: !!data.has_newer } },
...upsertSession(s, data.session),
+57
View File
@@ -4965,3 +4965,60 @@ span.chat-tab > button {
.tl-meta .check-timeout {
color: var(--red);
}
/* Foldable statistics sections */
.fold-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
background: none;
border: 0;
padding: 0;
font: inherit;
color: inherit;
cursor: pointer;
text-align: left;
}
.fold-caret {
width: 0;
height: 0;
border-left: 5px solid currentColor;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
transition: transform 0.15s;
opacity: 0.7;
}
.fold-toggle[aria-expanded="true"] .fold-caret {
transform: rotate(90deg);
}
.panel.fold.closed h3 {
margin-bottom: 0;
}
.fold-group td {
background: var(--bg2, color-mix(in srgb, var(--fg, #888) 6%, transparent));
font-weight: 600;
}
td.fold-indent {
padding-left: 1.6em;
}
.snippet-row {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 10px;
}
.snippet-slash {
color: var(--muted);
font-family: var(--mono, monospace);
}
.public-chat { max-width: 820px; margin: 0 auto; padding: 16px; }
.public-chat header { margin-bottom: 12px; }
.public-chat h1 { font-size: 1.2rem; margin: 0 0 4px; }
.public-chat .chat-scroll { display: flex; flex-direction: column; gap: 10px; }
.share-links { margin-top: 14px; border-top: 1px solid var(--border, #8884); padding-top: 10px; }
.share-links h4 { margin: 0 0 4px; }
.share-link { margin-top: 6px; flex-wrap: wrap; }
.share-link input { flex: 1 1 200px; min-width: 0; }
.offline-bar { background: color-mix(in srgb, var(--warn, #d79921) 25%, var(--bg0, transparent)); color: var(--fg, inherit); padding: 6px 12px; font-size: 0.85rem; text-align: center; }
+10
View File
@@ -3,6 +3,16 @@
* under "unreleased"; scripts/release.sh renames the key to the release version.
*/
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
unreleased: {
en: [
"Statistics: foldable sections, years of history with a year filter, export as XLSX/CSV",
"Prompt snippets (/name), read-only links for chats, commit message suggestions, weekly review, offline copies of recent chats, planning model",
],
de: [
"Statistik: zuklappbare Abschnitte, Historie über Jahre mit Jahresfilter, Export als XLSX/CSV",
"Prompt-Bausteine (/name), Nur-Lese-Links für Chats, Commit-Vorschläge, Wochenrückblick, Offline-Kopien der letzten Chats, Planungsmodell",
],
},
"202611-00028": {
en: ["18 more icons for chats (gamepad, computer, food, …); the ⋯ menu of a message now sits inside its bubble"],
de: ["18 weitere Icons für Chats (Gamepad, Computer, Essen, …); das ⋯-Menü einer Nachricht sitzt jetzt in der Sprechblase"],