Add Web Push, guided start screen, SVG icons, chunked uploads, rename

- Web Push (VAPID + RFC 8291, pure Rust): permission/question/finished
  notifications while no app window is visible; settings with test button
- Guided new-session flow: task, craft target (website/app/game/old
  software/fresh), clean room or deep, description, model, folder
  (new, existing or cloned git repository); originals upload to original/
- Craft phase bar (craft_phase tool) for study/specify/build/check/ready
- Chunked uploads (8 MB) up to server.max_upload_mb (default 500 MB)
- All UI glyphs and emojis replaced by themeable SVG icons
- Rename dialog with manual name or model-generated suggestion

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 21:03:26 +01:00
1 parent 013695722a
commit a0d0e707f2
48 files changed
+2724 -228

No files matched your search

+73 -29
View File
@@ -1,9 +1,11 @@
import { Icon } from "./Icon";
import { useEffect, useState, useSyncExternalStore } from "react";
import { get, patch as apiPatch, post, put } from "../api";
import { useFetch, useModels } from "../hooks";
import { getLang, languages, setLang, useT, type Key } from "../i18n";
import { navigate } from "../router";
import { useStore } from "../store";
import { currentSubscription, disablePush, enablePush, pushSupported, testPush } from "../push";
import { applyTheme, getTheme, getThemeMode, onThemeChange, THEME_MODES, THEMES, type Theme, type ThemeMode } from "../theme";
import { setReasoningMode, useReasoningMode, type ReasoningMode } from "../prefs";
import { ModelSelect } from "./ModelSelect";
@@ -54,7 +56,6 @@ function InterfaceSettings() {
const theme = useSyncExternalStore(onThemeChange, getTheme);
const mode = useSyncExternalStore(onThemeChange, getThemeMode);
const themeInfo = THEMES.find((th) => th.id === theme)!;
const [notif, setNotif] = useState(typeof Notification !== "undefined" ? Notification.permission : "denied");
return (
<div className="panel">
<h3>{t("settings.interface")}</h3>
@@ -107,21 +108,66 @@ function InterfaceSettings() {
</select>
<div className="hint">{t("settings.reasoningHint")}</div>
<label>{t("settings.notifications")}</label>
<div>
{notif === "granted" ? (
<span className="chip">{t("settings.notificationsOn")}</span>
) : (
<button className="small" onClick={() => Notification.requestPermission().then(setNotif)} disabled={notif === "denied"}>
{t("settings.notificationsEnable")}
</button>
)}
</div>
<PushControl />
<div className="hint">{t("settings.notificationsHint")}</div>
</div>
</div>
);
}
/** Web Push on this device: enable/disable and a test notification. */
function PushControl() {
const t = useT();
const [state, setState] = useState<"loading" | "on" | "off" | "unsupported" | "denied">("loading");
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!pushSupported()) return setState("unsupported");
if (Notification.permission === "denied") return setState("denied");
currentSubscription()
.then((s) => setState(s ? "on" : "off"))
.catch(() => setState("off"));
}, []);
const run = (f: () => Promise<unknown>) => async () => {
setBusy(true);
try {
await f();
} catch (e: any) {
toast(e.message ?? String(e));
} finally {
setBusy(false);
}
};
if (state === "loading") return <div />;
if (state === "unsupported") return <div className="hint">{t("settings.pushUnsupported")}</div>;
if (state === "denied") return <div className="hint">{t("settings.pushDenied")}</div>;
return (
<div className="row" style={{ gap: 8, flexWrap: "wrap" }}>
{state === "on" ? (
<>
<span className="chip">{t("settings.notificationsOn")}</span>
<button
className="small"
disabled={busy}
onClick={run(async () => {
const n = await testPush();
toast(n > 0 ? t("settings.pushTestSent") : t("settings.pushTestNone"));
})}
>
{t("settings.pushTest")}
</button>
<button className="small ghost" disabled={busy} onClick={run(async () => (await disablePush(), setState("off")))}>
{t("settings.pushDisable")}
</button>
</>
) : (
<button className="small" disabled={busy} onClick={run(async () => (await enablePush(), setState("on")))}>
{t("settings.notificationsEnable")}
</button>
)}
</div>
);
}
function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
const t = useT();
const s = cfg.config.server;
@@ -425,8 +471,8 @@ function ProviderEditor({ name, p, reload }: { name: string; p: Json; reload: ()
<th>ID</th>
<th>{t("settings.context")}</th>
<th>{t("settings.maxOutput")}</th>
<th title={t("settings.reasoning")}>🧠</th>
<th title={t("settings.vision")}>👁</th>
<th title={t("settings.reasoning")}><Icon name="brain" /></th>
<th title={t("settings.vision")}><Icon name="eye" /></th>
<th>{t("settings.effort")}</th>
<th>{t("settings.priceIn")}</th>
<th>{t("settings.priceOut")}</th>
@@ -472,7 +518,7 @@ function ProviderEditor({ name, p, reload }: { name: string; p: Json; reload: ()
</td>
<td>
<button className="small ghost danger" onClick={() => setModels(models.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
×
<Icon name="close" />
</button>
</td>
</tr>
@@ -733,7 +779,7 @@ function RuleTable({ rules, setRules, choices, keyLabel }: { rules: [string, str
</td>
<td>
<button className="small ghost danger" onClick={() => setRules(rules.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
×
<Icon name="close" />
</button>
</td>
</tr>
@@ -819,17 +865,17 @@ 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: "📝",
general: "gear",
providers: "brain",
agents: "robot",
mcp: "plug",
permissions: "shield",
users: "users",
folders: "folders",
tools: "toolbox",
audit: "scroll",
account: "user",
raw: "file-text",
};
export function Settings({ tab, onMenu }: { tab?: string; onMenu: () => void }) {
@@ -843,7 +889,7 @@ export function Settings({ tab, onMenu }: { tab?: string; onMenu: () => void })
<>
<div className="topbar">
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
☰
<Icon name="menu" />
</button>
<div className="grow">
<h1>{t("nav.settings")}</h1>
@@ -854,9 +900,7 @@ 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>
<Icon name={TAB_ICONS[x]} className="nav-icon" />
{t(`settings.tab.${x}` as Key)}
</button>
))}