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:
1 parent
013695722a
commit
a0d0e707f2
48 files changed
+2724
-228
No files matched your search
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in new issue
Block a user