Status LEDs, image model selection, backup restore, provider test, monthly versions
- Sidebar: three status LEDs (server, models, MCP) with an overview popover (/api/status) - Images: preview stage, purpose-based model choice and reference images (Nano Banana Pro edit refines the chosen preview) - Settings: restore a stored or uploaded backup (staged, applied on restart) - Provider form: test connection; saving or reloading re-checks the status at once - opencode Zen/Go: send x-opencode-session with a stable id per conversation - Releases are named vYYYYMM-NNNNN; scripts/release.sh numbers, tags, builds and publishes - Fix: backups made in the same second no longer overwrite each other Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
49d404eec7
commit
1f69d63b09
35 files changed
+1156
-88
No files matched your search
@@ -461,6 +461,33 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
|
||||
return savePatches(patches);
|
||||
});
|
||||
|
||||
// Check URL and key as entered, without saving.
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; text: string }>();
|
||||
const [testing, setTesting] = useState(false);
|
||||
const test = async () => {
|
||||
setTesting(true);
|
||||
setTestResult(undefined);
|
||||
try {
|
||||
const r = await post<{ status: ProviderStatus; models: number }>("/providers/test", {
|
||||
name,
|
||||
kind: form.kind,
|
||||
base_url: form.base_url,
|
||||
api_key: form.api_key,
|
||||
auth_prefix: form.auth_prefix ?? "",
|
||||
});
|
||||
const st = r.status;
|
||||
setTestResult(
|
||||
st.up
|
||||
? { ok: true, text: t("settings.testOk", { ms: st.latency_ms, n: r.models }) }
|
||||
: { ok: false, text: `${st.state === "auth" ? t("settings.providerAuth") : t("settings.providerDown")}: ${st.error ?? ""}` },
|
||||
);
|
||||
} catch (e: any) {
|
||||
setTestResult({ ok: false, text: e.message });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
if (!(await confirmDialog({ message: t("settings.deleteProviderConfirm", { name }), danger: true, confirmLabel: t("common.delete") }))) return;
|
||||
void run(() => savePatches([{ path: ["providers", name], value: null }]));
|
||||
@@ -614,7 +641,15 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
|
||||
</div>
|
||||
{models.length === 0 && <div className="hint" style={{ color: "var(--muted)", fontSize: 12 }}>{t("settings.modelsEmptyHint")}</div>}
|
||||
{view}
|
||||
{testResult && (
|
||||
<div className={`notice ${testResult.ok ? "ok" : "err"}`} style={{ marginTop: 12 }}>
|
||||
{testResult.text}
|
||||
</div>
|
||||
)}
|
||||
<div className="row" style={{ marginTop: 12 }}>
|
||||
<button onClick={test} disabled={testing || !form.base_url}>
|
||||
<Icon name="plug" /> {testing ? t("settings.testing") : t("settings.testConnection")}
|
||||
</button>
|
||||
<button className="primary" onClick={save}>
|
||||
{t("common.save")}
|
||||
</button>
|
||||
@@ -637,9 +672,10 @@ function Providers({ cfg, reload }: { cfg: ConfigResponse; reload: () => void })
|
||||
const providers = cfg.config.providers ?? {};
|
||||
const [status, setStatus] = useState<Record<string, ProviderStatus>>({});
|
||||
const [checking, setChecking] = useState(false);
|
||||
// Saving a provider re-checks it on the server, so refresh whenever the config changes.
|
||||
useEffect(() => {
|
||||
get<Record<string, ProviderStatus>>("/providers/status").then(setStatus).catch(() => {});
|
||||
}, []);
|
||||
}, [cfg]);
|
||||
const recheck = () => {
|
||||
setChecking(true);
|
||||
post<Record<string, ProviderStatus>>("/providers/status")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Icon } from "./Icon";
|
||||
import { StatusLeds } from "./StatusLeds";
|
||||
import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme";
|
||||
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { get, patch, post } from "../api";
|
||||
@@ -102,7 +103,6 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
|
||||
const t = useT();
|
||||
const sessions = useStore((s) => s.sessions);
|
||||
const busy = useStore((s) => s.busy);
|
||||
const connected = useStore((s) => s.connected);
|
||||
const permissions = useStore((s) => s.permissions);
|
||||
const questions = useStore((s) => s.questions);
|
||||
const me = useStore((s) => s.me);
|
||||
@@ -187,7 +187,7 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
|
||||
<img src="/icon.svg" alt="" />
|
||||
CodeCraft
|
||||
</div>
|
||||
<span className={`conn-dot ${connected ? "on" : ""}`} title={connected ? t("sidebar.connected") : t("sidebar.disconnected")} />
|
||||
<StatusLeds />
|
||||
</div>
|
||||
<div style={{ padding: "0 12px 8px" }}>
|
||||
<button className="primary" style={{ width: "100%" }} onClick={() => navigate("/new")}>
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { get, post } from "../api";
|
||||
import { useT, type Key } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { useStore } from "../store";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
type Level = "ok" | "warn" | "err" | "off";
|
||||
|
||||
interface ProviderState {
|
||||
state: "ok" | "auth" | "down" | "unknown";
|
||||
latency_ms?: number;
|
||||
checked_at?: number;
|
||||
error?: string | null;
|
||||
models: number;
|
||||
}
|
||||
interface McpEntry {
|
||||
state: "connected" | "connecting" | "failed";
|
||||
tools?: number;
|
||||
error?: string | null;
|
||||
}
|
||||
interface Overview {
|
||||
version: string;
|
||||
providers: Record<string, ProviderState>;
|
||||
mcp: Record<string, McpEntry>;
|
||||
}
|
||||
|
||||
const POLL_MS = 30_000;
|
||||
/** A dropped connection shows yellow (reconnecting) for this long, then red. */
|
||||
const RECONNECT_GRACE_MS = 10_000;
|
||||
|
||||
/** Combined level of a group: all fine → ok, some broken → warn, none working → err. */
|
||||
function combine(levels: Level[]): Level {
|
||||
const active = levels.filter((l) => l !== "off");
|
||||
if (active.length === 0) return "off";
|
||||
if (active.every((l) => l === "ok")) return "ok";
|
||||
if (active.some((l) => l === "ok" || l === "warn")) return "warn";
|
||||
return "err";
|
||||
}
|
||||
|
||||
const providerLevel = (p: ProviderState): Level => (p.state === "ok" ? "ok" : p.state === "unknown" ? "warn" : "err");
|
||||
const mcpLevel = (m: McpEntry): Level => (m.state === "connected" ? "ok" : m.state === "connecting" ? "warn" : "err");
|
||||
|
||||
function Led({ level, title }: { level: Level; title?: string }) {
|
||||
return <span className={`led led-${level}`} title={title} />;
|
||||
}
|
||||
|
||||
/** Three status LEDs (server, models, MCP) in the sidebar; a click opens the details. */
|
||||
export function StatusLeds() {
|
||||
const t = useT();
|
||||
const connected = useStore((s) => s.connected);
|
||||
const admin = useStore((s) => s.me?.role === "admin");
|
||||
const [data, setData] = useState<Overview>();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [lostAt, setLostAt] = useState<number>();
|
||||
const [pos, setPos] = useState<{ left: number; top: number }>();
|
||||
const [, tick] = useState(0);
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
|
||||
const load = useCallback(() => get<Overview>("/status").then(setData).catch(() => {}), []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const id = setInterval(load, POLL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
// Track when the event connection dropped (yellow while reconnecting, red after a while).
|
||||
useEffect(() => {
|
||||
if (connected) {
|
||||
setLostAt(undefined);
|
||||
void load();
|
||||
return;
|
||||
}
|
||||
setLostAt((v) => v ?? Date.now());
|
||||
const id = setTimeout(() => tick((n) => n + 1), RECONNECT_GRACE_MS + 100);
|
||||
return () => clearTimeout(id);
|
||||
}, [connected, load]);
|
||||
|
||||
// Close on outside click and Escape.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent) => !root.current?.contains(e.target as Node) && setOpen(false);
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
|
||||
document.addEventListener("mousedown", onDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const server: Level = connected ? "ok" : lostAt && Date.now() - lostAt > RECONNECT_GRACE_MS ? "err" : "warn";
|
||||
const providers = Object.entries(data?.providers ?? {});
|
||||
const mcp = Object.entries(data?.mcp ?? {});
|
||||
const models: Level = data ? (providers.length ? combine(providers.map(([, p]) => providerLevel(p))) : "err") : "off";
|
||||
const tools: Level = combine(mcp.map(([, m]) => mcpLevel(m)));
|
||||
|
||||
const label = (l: Level) => t(`status.level.${l}` as Key);
|
||||
const summary = `${t("status.server")}: ${label(server)} · ${t("status.models")}: ${label(models)} · ${t("status.mcp")}: ${label(tools)}`;
|
||||
|
||||
const recheck = async () => {
|
||||
setChecking(true);
|
||||
try {
|
||||
await post("/providers/status");
|
||||
await load();
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
const go = (tab: string) => {
|
||||
setOpen(false);
|
||||
navigate(`/settings/${tab}`);
|
||||
};
|
||||
|
||||
const providerText = (p: ProviderState) =>
|
||||
p.state === "ok"
|
||||
? t("status.providerOk", { n: p.models, ms: p.latency_ms ?? 0 })
|
||||
: p.state === "auth"
|
||||
? t("settings.providerAuth")
|
||||
: p.state === "down"
|
||||
? t("settings.providerDown")
|
||||
: t("status.notChecked");
|
||||
|
||||
return (
|
||||
<div className="status-leds" ref={root}>
|
||||
<button
|
||||
className="ghost status-leds-btn"
|
||||
onClick={(e) => {
|
||||
// Fixed position below the LEDs, kept inside the window.
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
setPos({ left: Math.max(8, Math.min(r.left, window.innerWidth - 328)), top: r.bottom + 6 });
|
||||
setOpen(!open);
|
||||
}} title={summary} aria-label={summary} aria-expanded={open}>
|
||||
<Led level={server} />
|
||||
<Led level={models} />
|
||||
<Led level={tools} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="status-pop" role="dialog" aria-label={t("status.title")} style={pos}>
|
||||
<div className="status-section">
|
||||
<div className="status-head">
|
||||
<Led level={server} /> {t("status.server")}
|
||||
</div>
|
||||
<div className="status-row">
|
||||
<span className="grow">{connected ? t("sidebar.connected") : t("sidebar.disconnected")}</span>
|
||||
{data && <span className="hint">v{data.version}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="status-section">
|
||||
<div className="status-head">
|
||||
<Led level={models} /> {t("status.models")}
|
||||
<span className="spacer" />
|
||||
{admin && (
|
||||
<button className="small ghost" onClick={recheck} disabled={checking} title={t("status.recheck")}>
|
||||
<Icon name="refresh" size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{providers.length === 0 && <div className="status-row hint">{t("status.noProviders")}</div>}
|
||||
{providers.map(([name, p]) => (
|
||||
<div key={name} className="status-row" title={p.error ?? undefined}>
|
||||
<Led level={providerLevel(p)} />
|
||||
<span className="grow mono">{name}</span>
|
||||
<span className="hint">{providerText(p)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="status-section">
|
||||
<div className="status-head">
|
||||
<Led level={tools} /> {t("status.mcp")}
|
||||
</div>
|
||||
{mcp.length === 0 && <div className="status-row hint">{t("status.noMcp")}</div>}
|
||||
{mcp.map(([name, m]) => (
|
||||
<div key={name} className="status-row" title={m.error ?? undefined}>
|
||||
<Led level={mcpLevel(m)} />
|
||||
<span className="grow mono">{name}</span>
|
||||
<span className="hint">{m.state === "connected" ? t("mcp.tools", { n: m.tools ?? 0 }) : t(`mcp.state.${m.state}` as Key)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{admin && (
|
||||
<div className="status-foot">
|
||||
<button className="small ghost" onClick={() => go("providers")}>
|
||||
<Icon name="brain" size={13} /> {t("status.providerSettings")}
|
||||
</button>
|
||||
<button className="small ghost" onClick={() => go("mcp")}>
|
||||
<Icon name="plug" size={13} /> {t("status.mcpSettings")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { get, patch, post } from "../../api";
|
||||
import { toast } from "../../dialogs";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api, get, patch, post } from "../../api";
|
||||
import { confirmDialog, toast } from "../../dialogs";
|
||||
import { useFetch } from "../../hooks";
|
||||
import { fmtDate, useT } from "../../i18n";
|
||||
import { formatSize } from "../FileFigure";
|
||||
@@ -90,6 +90,32 @@ export function Backups() {
|
||||
}
|
||||
};
|
||||
|
||||
// Restore: the server stages the backup, restarts and applies it before opening the database.
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
const restore = async (source: { name: string } | { file: File }) => {
|
||||
const label = "name" in source ? source.name : source.file.name;
|
||||
const ok = await confirmDialog({ title: t("backup.restoreTitle"), message: t("backup.restoreConfirm", { file: label }), confirmLabel: t("backup.restore"), danger: true });
|
||||
if (!ok) return;
|
||||
setRestoring(true);
|
||||
try {
|
||||
if ("name" in source) await post(`/backups/${encodeURIComponent(source.name)}/restore`);
|
||||
else await api("/backups/restore", { method: "POST", body: source.file, headers: { "Content-Type": "application/zip" } });
|
||||
toast(t("backup.restarting"), "info");
|
||||
// Wait until the server is gone and back, then reload with the restored data.
|
||||
await new Promise((r) => setTimeout(r, 2500));
|
||||
for (let i = 0; i < 120; i++) {
|
||||
if (await fetch("/api/health").then((r) => r.ok).catch(() => false)) return location.reload();
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
toast(t("backup.notBack"));
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
} finally {
|
||||
setRestoring(false);
|
||||
}
|
||||
};
|
||||
|
||||
const s3 = (k: keyof S3, label: string, placeholder = "", type = "text") =>
|
||||
form && (
|
||||
<label className="field">
|
||||
@@ -111,6 +137,20 @@ export function Backups() {
|
||||
<a className="button small" href="/api/backup">
|
||||
<Icon name="download" /> {t("audit.download")}
|
||||
</a>
|
||||
<button className="small" onClick={() => fileInput.current?.click()} disabled={restoring}>
|
||||
<Icon name="upload" /> {restoring ? t("backup.restoring") : t("backup.restoreFile")}
|
||||
</button>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (file) void restore({ file });
|
||||
}}
|
||||
/>
|
||||
<button className="small primary" onClick={runNow} disabled={busy}>
|
||||
{busy ? t("backup.running") : t("backup.now")}
|
||||
</button>
|
||||
@@ -176,12 +216,17 @@ export function Backups() {
|
||||
{data && data.files.length > 0 && (
|
||||
<div className="backup-list">
|
||||
{data.files.map((f) => (
|
||||
<a key={f.name} href={`/api/backups/${encodeURIComponent(f.name)}`} className="backup-file">
|
||||
<div key={f.name} className="backup-file">
|
||||
<Icon name="file" />
|
||||
<span className="grow">{f.name}</span>
|
||||
<span className="hint">{formatSize(f.size)}</span>
|
||||
<Icon name="download" />
|
||||
</a>
|
||||
<button className="small" title={t("backup.restore")} onClick={() => void restore({ name: f.name })} disabled={restoring}>
|
||||
<Icon name="undo" />
|
||||
</button>
|
||||
<a className="button small" title={t("audit.download")} href={`/api/backups/${encodeURIComponent(f.name)}`}>
|
||||
<Icon name="download" />
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -204,6 +204,21 @@ export const de: Messages = {
|
||||
"sidebar.yesterday": "Gestern",
|
||||
"sidebar.week": "Letzte 7 Tage",
|
||||
"sidebar.older": "Älter",
|
||||
"status.title": "Status",
|
||||
"status.server": "Server",
|
||||
"status.models": "Modelle",
|
||||
"status.mcp": "MCP",
|
||||
"status.level.ok": "alles in Ordnung",
|
||||
"status.level.warn": "teilweise nicht verfügbar",
|
||||
"status.level.err": "nicht verfügbar",
|
||||
"status.level.off": "keine konfiguriert",
|
||||
"status.providerOk": "{n} Modelle · {ms} ms",
|
||||
"status.notChecked": "noch nicht geprüft",
|
||||
"status.recheck": "Erneut prüfen",
|
||||
"status.noProviders": "Keine Provider aktiv.",
|
||||
"status.noMcp": "Keine MCP-Server konfiguriert.",
|
||||
"status.providerSettings": "Provider",
|
||||
"status.mcpSettings": "MCP-Server",
|
||||
"sidebar.connected": "Verbunden",
|
||||
"sidebar.disconnected": "Getrennt – verbinde neu…",
|
||||
"sidebar.needsInput": "Wartet auf deine Eingabe",
|
||||
@@ -464,6 +479,13 @@ export const de: Messages = {
|
||||
"sidebar.moveHintExisting": "Vorhandene Ordner: {folders}. Gib einen davon oder einen neuen Namen ein.",
|
||||
"sidebar.folderName": "Ordnername",
|
||||
"sidebar.move": "Verschieben",
|
||||
"backup.restore": "Wiederherstellen",
|
||||
"backup.restoreFile": "Aus Datei wiederherstellen…",
|
||||
"backup.restoring": "Wird wiederhergestellt…",
|
||||
"backup.restoreTitle": "Backup wiederherstellen?",
|
||||
"backup.restoreConfirm": "Datenbank und Konfiguration durch {file} ersetzen? Alle Sitzungen, Benutzer, Statistiken und Einstellungen werden durch den Stand im Backup ersetzt; Dateien im Workspace bleiben unverändert. Der aktuelle Stand wird vorher gesichert. CodeCraft startet neu – laufende Agents werden beendet.",
|
||||
"backup.restarting": "Backup übernommen – CodeCraft startet neu…",
|
||||
"backup.notBack": "Der Server ist nicht zurückgekommen. Er braucht eine Restart-Policy (Docker: restart: unless-stopped) – bitte von Hand starten.",
|
||||
"backup.now": "Jetzt sichern",
|
||||
"backup.running": "Sichere…",
|
||||
"backup.created": "Backup {file} erstellt.",
|
||||
@@ -647,6 +669,9 @@ export const de: Messages = {
|
||||
"settings.providerName": "Name (optional)",
|
||||
"settings.providerUp": "erreichbar · {ms} ms",
|
||||
"settings.providerDown": "nicht erreichbar",
|
||||
"settings.testConnection": "Verbindung testen",
|
||||
"settings.testing": "Teste…",
|
||||
"settings.testOk": "Verbindung klappt: {n} Modelle, {ms} ms. Noch nicht gespeichert.",
|
||||
"settings.providerAuth": "API-Key fehlt oder ungültig",
|
||||
"settings.providerCheck": "Provider prüfen",
|
||||
"settings.providerChecking": "Prüfe…",
|
||||
|
||||
@@ -203,6 +203,21 @@ export const en = {
|
||||
"sidebar.yesterday": "Yesterday",
|
||||
"sidebar.week": "Last 7 days",
|
||||
"sidebar.older": "Older",
|
||||
"status.title": "Status",
|
||||
"status.server": "Server",
|
||||
"status.models": "Models",
|
||||
"status.mcp": "MCP",
|
||||
"status.level.ok": "all fine",
|
||||
"status.level.warn": "partly unavailable",
|
||||
"status.level.err": "unavailable",
|
||||
"status.level.off": "none configured",
|
||||
"status.providerOk": "{n} models · {ms} ms",
|
||||
"status.notChecked": "not checked yet",
|
||||
"status.recheck": "Check again",
|
||||
"status.noProviders": "No providers enabled.",
|
||||
"status.noMcp": "No MCP servers configured.",
|
||||
"status.providerSettings": "Providers",
|
||||
"status.mcpSettings": "MCP servers",
|
||||
"sidebar.connected": "Connected",
|
||||
"sidebar.disconnected": "Disconnected – reconnecting…",
|
||||
"sidebar.needsInput": "Waiting for your input",
|
||||
@@ -463,6 +478,13 @@ export const en = {
|
||||
"sidebar.moveHintExisting": "Existing folders: {folders}. Type one of them or a new name.",
|
||||
"sidebar.folderName": "Folder name",
|
||||
"sidebar.move": "Move",
|
||||
"backup.restore": "Restore",
|
||||
"backup.restoreFile": "Restore from file…",
|
||||
"backup.restoring": "Restoring…",
|
||||
"backup.restoreTitle": "Restore backup?",
|
||||
"backup.restoreConfirm": "Replace the database and the configuration with {file}? All sessions, users, statistics and settings are replaced by the state in the backup; files in the workspace stay as they are. The current state is backed up first. CodeCraft restarts – running agents are stopped.",
|
||||
"backup.restarting": "Backup accepted – CodeCraft is restarting…",
|
||||
"backup.notBack": "The server did not come back. It needs a restart policy (Docker: restart: unless-stopped) – start it again by hand.",
|
||||
"backup.now": "Back up now",
|
||||
"backup.running": "Backing up…",
|
||||
"backup.created": "Backup {file} created.",
|
||||
@@ -646,6 +668,9 @@ export const en = {
|
||||
"settings.providerName": "Name (optional)",
|
||||
"settings.providerUp": "reachable · {ms} ms",
|
||||
"settings.providerDown": "unreachable",
|
||||
"settings.testConnection": "Test connection",
|
||||
"settings.testing": "Testing…",
|
||||
"settings.testOk": "Connection works: {n} models, {ms} ms. Not saved yet.",
|
||||
"settings.providerAuth": "API key missing or rejected",
|
||||
"settings.providerCheck": "Check providers",
|
||||
"settings.providerChecking": "Checking…",
|
||||
|
||||
+117
-7
@@ -132,14 +132,120 @@ textarea:focus {
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.conn-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--red);
|
||||
/* --- status LEDs (sidebar) ------------------------------------------------------ */
|
||||
/* Theme hues, pushed to a vivid chroma and a readable lightness so the state is
|
||||
obvious in every theme; warn takes the red's lightness/chroma at an amber hue. */
|
||||
:root {
|
||||
--led-ok: #22c55e;
|
||||
--led-warn: #f5a700;
|
||||
--led-err: #ef4444;
|
||||
}
|
||||
.conn-dot.on {
|
||||
background: var(--green);
|
||||
@supports (color: oklch(from red l c h)) {
|
||||
:root {
|
||||
--led-ok: oklch(from var(--green) clamp(0.7, l, 0.8) max(c, 0.2) clamp(138, h, 158));
|
||||
--led-warn: oklch(from var(--red) clamp(0.78, l, 0.85) max(c, 0.17) 75);
|
||||
--led-err: oklch(from var(--red) clamp(0.6, l, 0.7) max(c, 0.21) h);
|
||||
}
|
||||
}
|
||||
.status-leds {
|
||||
position: relative;
|
||||
}
|
||||
.status-leds-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 5px 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg2);
|
||||
}
|
||||
.led {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
display: inline-block;
|
||||
}
|
||||
.led-ok {
|
||||
background: var(--led-ok);
|
||||
box-shadow: 0 0 6px color-mix(in srgb, var(--led-ok) 70%, transparent);
|
||||
}
|
||||
.led-warn {
|
||||
background: var(--led-warn);
|
||||
box-shadow: 0 0 6px color-mix(in srgb, var(--led-warn) 70%, transparent);
|
||||
}
|
||||
.led-err {
|
||||
background: var(--led-err);
|
||||
box-shadow: 0 0 7px color-mix(in srgb, var(--led-err) 80%, transparent);
|
||||
}
|
||||
.status-leds-btn .led-err {
|
||||
animation: led-blink 1.6s ease-in-out infinite;
|
||||
}
|
||||
.led-off {
|
||||
background: transparent;
|
||||
box-shadow: inset 0 0 0 1.5px var(--muted);
|
||||
}
|
||||
@keyframes led-blink {
|
||||
50% {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.status-leds-btn .led-err {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
.status-pop {
|
||||
position: fixed;
|
||||
z-index: 40;
|
||||
width: min(320px, calc(100vw - 24px));
|
||||
background: var(--bg1);
|
||||
border: 1px solid var(--bg3);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.status-section + .status-section {
|
||||
border-top: 1px solid var(--bg3);
|
||||
margin-top: 4px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
.status-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px 4px;
|
||||
font-weight: 600;
|
||||
color: var(--fg0);
|
||||
}
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 8px 4px 25px;
|
||||
color: var(--fg1);
|
||||
}
|
||||
.status-row .led {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
margin-left: -15px;
|
||||
}
|
||||
.status-row .grow {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status-row .hint {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status-foot {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
border-top: 1px solid var(--bg3);
|
||||
margin-top: 4px;
|
||||
padding: 6px 4px 2px;
|
||||
}
|
||||
|
||||
.session-list {
|
||||
@@ -3413,6 +3519,10 @@ button.danger-solid {
|
||||
.backup-file .grow {
|
||||
flex: 1;
|
||||
}
|
||||
.backup-file button,
|
||||
.backup-file .button {
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
/* --- automation templates ------------------------------------------------------ */
|
||||
.job-tpl {
|
||||
|
||||
+20
-1
@@ -1,5 +1,24 @@
|
||||
/** "What's new" entries per version (shown once after an update). */
|
||||
/**
|
||||
* "What's new" entries per version (shown once after an update). Add new entries
|
||||
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
||||
*/
|
||||
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
||||
unreleased: {
|
||||
en: [
|
||||
"Status LEDs in the sidebar for server, models and MCP – click them for details",
|
||||
"Images: cheap previews first, then the final image with the best model (Nano Banana Pro refines the chosen preview)",
|
||||
"Settings → Audit: restore a backup (stored or uploaded)",
|
||||
"Provider form: test the connection before saving; saving checks the status right away",
|
||||
"opencode Go works again (session header)",
|
||||
],
|
||||
de: [
|
||||
"Status-LEDs in der Seitenleiste für Server, Modelle und MCP – ein Klick zeigt die Details",
|
||||
"Bilder: erst günstige Vorschauen, dann das Endbild mit dem besten Modell (Nano Banana Pro verfeinert die gewählte Vorschau)",
|
||||
"Einstellungen → Audit: Backup wiederherstellen (gespeichert oder hochgeladen)",
|
||||
"Provider-Formular: Verbindung vor dem Speichern testen; Speichern prüft den Status sofort",
|
||||
"opencode Go funktioniert wieder (Session-Header)",
|
||||
],
|
||||
},
|
||||
"0.1.0": {
|
||||
en: [
|
||||
"Terminal client `codecraft`: work locally like with Claude Code – Settings → Account → Terminal client",
|
||||
|
||||
Reference in new issue
Block a user