@agentprojectcontext/apx 1.52.0 → 1.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,8 +18,8 @@
18
18
  <link rel="apple-touch-icon" href="/favicon/dark/apple-touch-icon.png" media="(prefers-color-scheme: dark)" />
19
19
  <link rel="manifest" href="/favicon/white/site.webmanifest" media="(prefers-color-scheme: light)" />
20
20
  <link rel="manifest" href="/favicon/dark/site.webmanifest" media="(prefers-color-scheme: dark)" />
21
- <script type="module" crossorigin src="/assets/index-adeTv5sA.js"></script>
22
- <link rel="stylesheet" crossorigin href="/assets/index-L1pXYFUg.css">
21
+ <script type="module" crossorigin src="/assets/index-vuz4PKLd.js"></script>
22
+ <link rel="stylesheet" crossorigin href="/assets/index-BwnMDZaD.css">
23
23
  </head>
24
24
  <body class="bg-background text-foreground antialiased">
25
25
  <div id="root"></div>
@@ -0,0 +1,117 @@
1
+ import { useEffect, useLayoutEffect, useRef, useState } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { ChevronDown } from "lucide-react";
4
+ import { cn } from "../lib/cn";
5
+ import type { TzOption } from "../i18n/timezones";
6
+
7
+ // Searchable timezone picker. Shows offset-prefixed labels
8
+ // ("(GMT-03:00) America/Argentina/Buenos_Aires"), filters as you type, and
9
+ // commits the raw IANA value. Mirrors the ModelCombobox portal pattern so the
10
+ // list escapes any scrolling container.
11
+ export function TimezoneSelect({
12
+ value,
13
+ onChange,
14
+ options,
15
+ placeholder,
16
+ className,
17
+ }: {
18
+ value: string;
19
+ onChange: (v: string) => void;
20
+ options: TzOption[];
21
+ placeholder?: string;
22
+ className?: string;
23
+ }) {
24
+ const labelFor = (v: string) => options.find((o) => o.value === v)?.label ?? v;
25
+ const [open, setOpen] = useState(false);
26
+ const [query, setQuery] = useState(labelFor(value));
27
+ const wrapRef = useRef<HTMLDivElement | null>(null);
28
+ const listRef = useRef<HTMLUListElement | null>(null);
29
+ const [menuRect, setMenuRect] = useState<{ top: number; left: number; width: number } | null>(null);
30
+
31
+ useEffect(() => { setQuery(labelFor(value)); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [value, options]);
32
+
33
+ useLayoutEffect(() => {
34
+ if (!open) return;
35
+ const compute = () => {
36
+ const el = wrapRef.current;
37
+ if (!el) return;
38
+ const r = el.getBoundingClientRect();
39
+ setMenuRect({ top: r.bottom + 4, left: r.left, width: r.width });
40
+ };
41
+ compute();
42
+ window.addEventListener("scroll", compute, true);
43
+ window.addEventListener("resize", compute);
44
+ return () => {
45
+ window.removeEventListener("scroll", compute, true);
46
+ window.removeEventListener("resize", compute);
47
+ };
48
+ }, [open]);
49
+
50
+ useEffect(() => {
51
+ if (!open) return;
52
+ const onDoc = (e: MouseEvent) => {
53
+ const target = e.target as Node;
54
+ if (wrapRef.current?.contains(target)) return;
55
+ if (listRef.current?.contains(target)) return;
56
+ setQuery(labelFor(value)); // discard half-typed query on outside click
57
+ setOpen(false);
58
+ };
59
+ document.addEventListener("mousedown", onDoc);
60
+ return () => document.removeEventListener("mousedown", onDoc);
61
+ /* eslint-disable-next-line react-hooks/exhaustive-deps */
62
+ }, [open, value, options]);
63
+
64
+ const q = query.trim().toLowerCase();
65
+ const isUntouched = query === labelFor(value);
66
+ const filtered = q && !isUntouched
67
+ ? options.filter((o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q))
68
+ : options;
69
+
70
+ const pick = (o: TzOption) => { onChange(o.value); setQuery(o.label); setOpen(false); };
71
+
72
+ return (
73
+ <div ref={wrapRef} className={cn("relative", className)}>
74
+ <div className="flex items-center gap-1 rounded-lg border border-input bg-transparent px-2.5 transition-colors focus-within:border-ring focus-within:ring-1 focus-within:ring-ring dark:bg-input/30 dark:hover:bg-input/50">
75
+ <input
76
+ value={query}
77
+ placeholder={placeholder}
78
+ onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
79
+ onFocus={() => setOpen(true)}
80
+ className="w-full bg-transparent py-1.5 text-sm outline-none placeholder:text-muted-fg/60"
81
+ />
82
+ <button
83
+ type="button"
84
+ tabIndex={-1}
85
+ onClick={() => setOpen((v) => !v)}
86
+ className="shrink-0 text-muted-fg hover:text-foreground"
87
+ >
88
+ <ChevronDown className="size-4" />
89
+ </button>
90
+ </div>
91
+
92
+ {open && filtered.length > 0 && menuRect && createPortal(
93
+ <ul
94
+ ref={listRef}
95
+ style={{ position: "fixed", top: menuRect.top, left: menuRect.left, width: menuRect.width }}
96
+ className="z-[1000] max-h-60 overflow-y-auto rounded-lg bg-popover p-1 shadow-md ring-1 ring-foreground/10"
97
+ >
98
+ {filtered.map((o) => (
99
+ <li key={o.value}>
100
+ <button
101
+ type="button"
102
+ onMouseDown={(e) => { e.preventDefault(); pick(o); }}
103
+ className={cn(
104
+ "flex w-full items-center rounded-md px-2 py-1 text-left text-sm hover:bg-accent hover:text-accent-fg",
105
+ o.value === value && "bg-accent/50",
106
+ )}
107
+ >
108
+ <span className="truncate font-mono text-xs">{o.label}</span>
109
+ </button>
110
+ </li>
111
+ ))}
112
+ </ul>,
113
+ document.body,
114
+ )}
115
+ </div>
116
+ );
117
+ }
@@ -1,10 +1,11 @@
1
1
  import { useState } from "react";
2
2
  import { QrCode } from "lucide-react";
3
3
  import { Section } from "../Section";
4
- import { Badge, Button, Empty, Loading } from "../ui";
4
+ import { Badge, Button, Empty, Field, Input, Loading } from "../ui";
5
5
  import { useToast } from "../Toast";
6
6
  import { useDevices } from "../../hooks/useDevices";
7
- import { Pair } from "../../lib/api";
7
+ import { Pair, getToken, setToken } from "../../lib/api";
8
+ import { STORAGE } from "../../constants";
8
9
  import { PairDeviceDialog } from "./PairDeviceDialog";
9
10
  import { t } from "../../i18n";
10
11
 
@@ -12,6 +13,7 @@ export function DevicesPanel() {
12
13
  const toast = useToast();
13
14
  const { clients, isLoading, mutate } = useDevices();
14
15
  const [pairOpen, setPairOpen] = useState(false);
16
+ const [draftToken, setDraftToken] = useState("");
15
17
 
16
18
  const revoke = async (id: string) => {
17
19
  if (!confirm(t("settings.devices_revoke_confirm", { id }))) return;
@@ -24,37 +26,66 @@ export function DevicesPanel() {
24
26
  }
25
27
  };
26
28
 
29
+ const saveToken = () => {
30
+ const v = draftToken.trim();
31
+ if (!v) return;
32
+ setToken(v);
33
+ try { localStorage.setItem(STORAGE.token, v); } catch { /* quota */ }
34
+ setDraftToken("");
35
+ toast.success(t("settings.token_saved"));
36
+ };
37
+
27
38
  return (
28
- <Section
29
- title={t("settings.devices")}
30
- description={t("settings.devices_sub")}
31
- action={
32
- <Button size="sm" variant="primary" onClick={() => setPairOpen(true)}>
33
- <QrCode size={14} /> {t("settings.devices_pair_btn")}
34
- </Button>
35
- }
36
- >
37
- {isLoading && <Loading />}
38
- {!isLoading && clients.length === 0 && (
39
- <Empty>{t("settings.devices_empty")}</Empty>
40
- )}
41
- {clients.length > 0 && (
42
- <ul className="space-y-2 text-sm">
43
- {clients.map((c) => (
44
- <li key={c.id} className="flex items-center gap-3 rounded-md border border-border bg-muted/30 px-3 py-2">
45
- <span className="font-medium">{c.label || c.id}</span>
46
- <Badge tone={c.kind === "web" ? "info" : c.kind === "deck" ? "success" : "muted"}>{c.kind}</Badge>
47
- <span className="font-mono text-xs text-muted-fg">…{c.token_suffix}</span>
48
- <span className="ml-auto text-xs text-muted-fg">
49
- {t("settings.devices_last_seen")} {c.last_seen ? new Date(c.last_seen).toLocaleString() : t("settings.devices_never")}
50
- </span>
51
- <Button size="sm" variant="destructive" onClick={() => revoke(c.id)}>{t("settings.devices_revoke")}</Button>
52
- </li>
53
- ))}
54
- </ul>
55
- )}
39
+ <div className="space-y-6">
40
+ <Section
41
+ title={t("settings.devices")}
42
+ description={t("settings.devices_sub")}
43
+ action={
44
+ <Button size="sm" variant="primary" onClick={() => setPairOpen(true)}>
45
+ <QrCode size={14} /> {t("settings.devices_pair_btn")}
46
+ </Button>
47
+ }
48
+ >
49
+ {isLoading && <Loading />}
50
+ {!isLoading && clients.length === 0 && (
51
+ <Empty>{t("settings.devices_empty")}</Empty>
52
+ )}
53
+ {clients.length > 0 && (
54
+ <ul className="space-y-2 text-sm">
55
+ {clients.map((c) => (
56
+ <li key={c.id} className="flex items-center gap-3 rounded-md border border-border bg-muted/30 px-3 py-2">
57
+ <span className="font-medium">{c.label || c.id}</span>
58
+ <Badge tone={c.kind === "web" ? "info" : c.kind === "deck" ? "success" : "muted"}>{c.kind}</Badge>
59
+ <span className="font-mono text-xs text-muted-fg">…{c.token_suffix}</span>
60
+ <span className="ml-auto text-xs text-muted-fg">
61
+ {t("settings.devices_last_seen")} {c.last_seen ? new Date(c.last_seen).toLocaleString() : t("settings.devices_never")}
62
+ </span>
63
+ <Button size="sm" variant="destructive" onClick={() => revoke(c.id)}>{t("settings.devices_revoke")}</Button>
64
+ </li>
65
+ ))}
66
+ </ul>
67
+ )}
68
+
69
+ <PairDeviceDialog open={pairOpen} onClose={() => setPairOpen(false)} onPaired={() => mutate()} />
70
+ </Section>
56
71
 
57
- <PairDeviceDialog open={pairOpen} onClose={() => setPairOpen(false)} onPaired={() => mutate()} />
58
- </Section>
72
+ {/* Session token lives next to Devices: it's the bearer this very web
73
+ client authenticates with — a fallback when auto-load didn't work. */}
74
+ <Section title={t("settings.token")} description={t("settings.token_sub")}>
75
+ <Field label={t("settings_ui.bearer_label")}>
76
+ <Input
77
+ type="password"
78
+ placeholder={getToken() ? t("settings.token_active") : t("settings.token_paste")}
79
+ value={draftToken}
80
+ onChange={(e) => setDraftToken(e.target.value)}
81
+ className="font-mono"
82
+ onKeyDown={(e) => { if (e.key === "Enter") saveToken(); }}
83
+ />
84
+ </Field>
85
+ <div className="mt-2">
86
+ <Button variant="primary" onClick={saveToken}>{t("common.save")}</Button>
87
+ </div>
88
+ </Section>
89
+ </div>
59
90
  );
60
91
  }
@@ -1,21 +1,27 @@
1
- import { useEffect, useState } from "react";
1
+ import { useEffect, useMemo, useState } from "react";
2
2
  import { Section } from "../Section";
3
3
  import { Button, Empty, Field, Input, Loading, Textarea } from "../ui";
4
4
  import { UiSelect } from "../UiSelect";
5
+ import { TimezoneSelect } from "../TimezoneSelect";
5
6
  import { useToast } from "../Toast";
6
7
  import { useIdentity } from "../../hooks/useIdentity";
7
8
  import { t } from "../../i18n";
9
+ import { languageOptions } from "../../i18n/languages";
10
+ import { timezoneOptions, detectTimezone } from "../../i18n/timezones";
8
11
  import type { Identity } from "../../types/daemon";
9
12
 
10
- const LANGS = ["es", "en", "pt", "fr", "it", "de"] as const;
11
-
12
13
  export function IdentityPanel() {
13
14
  const toast = useToast();
14
15
  const { identity, isLoading, save } = useIdentity();
15
16
  const [draft, setDraft] = useState<Identity>({});
16
17
  const [busy, setBusy] = useState(false);
18
+ const tzOptions = useMemo(() => timezoneOptions(), []);
17
19
 
18
- useEffect(() => { setDraft(identity); }, [identity]);
20
+ // Default the timezone to the detected OS zone (e.g. Buenos Aires) when the
21
+ // identity has none saved yet, so the field starts on a sensible pick.
22
+ useEffect(() => {
23
+ setDraft({ ...identity, timezone: identity?.timezone || detectTimezone() });
24
+ }, [identity]);
19
25
 
20
26
  if (isLoading) return <Loading />;
21
27
 
@@ -45,11 +51,15 @@ export function IdentityPanel() {
45
51
  <UiSelect
46
52
  value={draft.language || "es"}
47
53
  onChange={(v) => setDraft({ ...draft, language: v })}
48
- options={LANGS.map((l) => ({ value: l, label: l }))}
54
+ options={languageOptions()}
49
55
  />
50
56
  </Field>
51
57
  <Field label={t("settings.identity.timezone")} hint={t("settings.identity.timezone_hint")}>
52
- <Input value={draft.timezone || ""} onChange={(e) => setDraft({ ...draft, timezone: e.target.value })} />
58
+ <TimezoneSelect
59
+ value={draft.timezone || detectTimezone()}
60
+ onChange={(v) => setDraft({ ...draft, timezone: v })}
61
+ options={tzOptions}
62
+ />
53
63
  </Field>
54
64
  </div>
55
65
  <div className="mt-3">
@@ -0,0 +1,45 @@
1
+ import { useState } from "react";
2
+ import { Section } from "../Section";
3
+ import { Button } from "../ui";
4
+ import { useTheme } from "../../hooks/useTheme";
5
+ import { t, setLocale, getLocale, LOCALES, type Locale } from "../../i18n";
6
+
7
+ // Settings for the web panel itself: visual appearance (theme) + UI language.
8
+ // This is panel-local UX, distinct from the agent's identity.
9
+ export function WebPanel() {
10
+ const { preference, set } = useTheme();
11
+ const [locale, setLocaleState] = useState<Locale>(getLocale());
12
+
13
+ const changeLocale = (l: Locale) => {
14
+ setLocale(l);
15
+ setLocaleState(l);
16
+ // Reload so all rendered strings pick up the new locale.
17
+ window.location.reload();
18
+ };
19
+
20
+ return (
21
+ <div className="grid gap-6 xl:grid-cols-2 xl:items-start">
22
+ <Section title={t("settings.appearance")}>
23
+ <div className="flex items-center gap-2">
24
+ <Button variant={preference === "light" ? "primary" : "secondary"} onClick={() => set("light")}>{t("settings.light_mode")}</Button>
25
+ <Button variant={preference === "dark" ? "primary" : "secondary"} onClick={() => set("dark")}>{t("settings.dark_mode")}</Button>
26
+ <Button variant={preference === "system" ? "primary" : "secondary"} onClick={() => set("system")}>{t("settings.system_mode")}</Button>
27
+ </div>
28
+ </Section>
29
+
30
+ <Section title={t("settings.language")}>
31
+ <div className="flex items-center gap-2">
32
+ {LOCALES.map((lo) => (
33
+ <Button
34
+ key={lo.value}
35
+ variant={locale === lo.value ? "primary" : "secondary"}
36
+ onClick={() => changeLocale(lo.value)}
37
+ >
38
+ {lo.label}
39
+ </Button>
40
+ ))}
41
+ </div>
42
+ </Section>
43
+ </div>
44
+ );
45
+ }
@@ -1,6 +1,10 @@
1
1
  // Theme controller. The CSS in styles.css already supports both modes via
2
2
  // the `.dark` class on <html>. State lives in ThemeProvider so every
3
3
  // useTheme() consumer (Logo, TopBar, Settings) stays in sync.
4
+ //
5
+ // `preference` is what the user picked (light | dark | system); `theme` is the
6
+ // *resolved* mode actually applied (light | dark). System tracks the OS via
7
+ // matchMedia and re-applies live when the OS scheme flips.
4
8
  import {
5
9
  createContext,
6
10
  useCallback,
@@ -12,39 +16,67 @@ import {
12
16
  } from "react";
13
17
  import { STORAGE } from "../constants";
14
18
 
15
- type Theme = "light" | "dark";
19
+ type Resolved = "light" | "dark";
20
+ type Preference = "light" | "dark" | "system";
16
21
 
17
- function readInitial(): Theme {
22
+ function systemPrefersDark(): boolean {
23
+ return typeof window !== "undefined"
24
+ && typeof window.matchMedia === "function"
25
+ && window.matchMedia("(prefers-color-scheme: dark)").matches;
26
+ }
27
+
28
+ function resolve(pref: Preference): Resolved {
29
+ if (pref === "system") return systemPrefersDark() ? "dark" : "light";
30
+ return pref;
31
+ }
32
+
33
+ function readPreference(): Preference {
18
34
  if (typeof window === "undefined") return "dark";
19
35
  const saved = localStorage.getItem(STORAGE.theme);
20
- if (saved === "light" || saved === "dark") return saved;
21
- return document.documentElement.classList.contains("dark") ? "dark" : "light";
36
+ if (saved === "light" || saved === "dark" || saved === "system") return saved;
37
+ return "dark";
22
38
  }
23
39
 
24
40
  type ThemeContextValue = {
25
- theme: Theme;
41
+ /** Resolved mode actually applied — "light" | "dark". */
42
+ theme: Resolved;
43
+ /** User selection — "light" | "dark" | "system". */
44
+ preference: Preference;
26
45
  toggle: () => void;
27
- set: (t: Theme) => void;
46
+ set: (p: Preference) => void;
28
47
  };
29
48
 
30
49
  const ThemeContext = createContext<ThemeContextValue | null>(null);
31
50
 
32
51
  export function ThemeProvider({ children }: { children: ReactNode }) {
33
- const [theme, setTheme] = useState<Theme>(readInitial);
52
+ const [preference, setPreference] = useState<Preference>(readPreference);
53
+ const [theme, setTheme] = useState<Resolved>(() => resolve(readPreference()));
34
54
 
35
55
  useEffect(() => {
36
- const root = document.documentElement;
37
- root.classList.toggle("dark", theme === "dark");
38
- try { localStorage.setItem(STORAGE.theme, theme); } catch { /* ignore quota */ }
39
- }, [theme]);
56
+ const apply = () => {
57
+ const r = resolve(preference);
58
+ setTheme(r);
59
+ document.documentElement.classList.toggle("dark", r === "dark");
60
+ };
61
+ apply();
62
+ try { localStorage.setItem(STORAGE.theme, preference); } catch { /* ignore quota */ }
63
+
64
+ // While on "system", follow the OS scheme live.
65
+ if (preference === "system" && typeof window.matchMedia === "function") {
66
+ const mq = window.matchMedia("(prefers-color-scheme: dark)");
67
+ mq.addEventListener("change", apply);
68
+ return () => mq.removeEventListener("change", apply);
69
+ }
70
+ }, [preference]);
40
71
 
72
+ // Quick toggle (TopBar): flip the *visible* mode to its opposite.
41
73
  const toggle = useCallback(() => {
42
- setTheme((t) => (t === "dark" ? "light" : "dark"));
74
+ setPreference((p) => (resolve(p) === "dark" ? "light" : "dark"));
43
75
  }, []);
44
76
 
45
- const value = useMemo(
46
- () => ({ theme, toggle, set: setTheme }),
47
- [theme, toggle],
77
+ const value = useMemo<ThemeContextValue>(
78
+ () => ({ theme, preference, toggle, set: setPreference }),
79
+ [theme, preference, toggle],
48
80
  );
49
81
 
50
82
  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
@@ -0,0 +1,37 @@
1
+ // Localized language picker for the super-agent's preferred language.
2
+ //
3
+ // The list is ISO 639-1 codes; the human-readable name is produced at render
4
+ // time by the native Intl.DisplayNames API in the active UI locale (es/en).
5
+ // That keeps the dropdown showing "Español", "Inglés"… (or "Spanish",
6
+ // "English"…) without us hand-maintaining a name table per language.
7
+ import { getLocale } from "./index";
8
+
9
+ // ISO 639-1 codes offered as the identity's preferred language. This only
10
+ // drives the agent's identity — it is NOT the app UI language (that stays es/en).
11
+ export const IDENTITY_LANG_CODES = [
12
+ "es", "en", "pt", "fr", "it", "de", "ca", "gl", "eu",
13
+ "nl", "sv", "no", "da", "fi", "is",
14
+ "pl", "cs", "sk", "sl", "hr", "sr", "uk", "ru", "bg", "ro", "hu", "el",
15
+ "tr", "ar", "he", "fa", "hi", "bn", "ta", "ur",
16
+ "id", "ms", "vi", "th", "ko", "ja", "zh",
17
+ ] as const;
18
+
19
+ function capitalize(s: string): string {
20
+ return s ? s.charAt(0).toLocaleUpperCase() + s.slice(1) : s;
21
+ }
22
+
23
+ // Builds { value, label } options with names localized to the active UI locale,
24
+ // sorted alphabetically by that localized name.
25
+ export function languageOptions(): { value: string; label: string }[] {
26
+ const locale = getLocale();
27
+ let display: Intl.DisplayNames | null = null;
28
+ try {
29
+ display = new Intl.DisplayNames([locale], { type: "language" });
30
+ } catch {
31
+ display = null; // ancient runtime — fall back to raw codes
32
+ }
33
+ return IDENTITY_LANG_CODES.map((code) => {
34
+ const name = display?.of(code);
35
+ return { value: code, label: name ? capitalize(name) : code };
36
+ }).sort((a, b) => a.label.localeCompare(b.label, locale));
37
+ }
@@ -0,0 +1,64 @@
1
+ // Timezone picker data. Native Intl is the "vendor" here: it ships the
2
+ // canonical IANA zone list (Intl.supportedValuesOf) and the current GMT offset
3
+ // per zone (Intl.DateTimeFormat longOffset) — no library needed.
4
+ //
5
+ // Stored value is the raw IANA id ("America/Argentina/Buenos_Aires"); the label
6
+ // shown is "(GMT-03:00) America/Argentina/Buenos_Aires", sorted by offset.
7
+
8
+ // A small fallback for ancient runtimes without Intl.supportedValuesOf.
9
+ const FALLBACK_ZONES = [
10
+ "UTC", "America/Argentina/Buenos_Aires", "America/Sao_Paulo", "America/New_York",
11
+ "America/Los_Angeles", "America/Mexico_City", "Europe/London", "Europe/Madrid",
12
+ "Europe/Berlin", "Asia/Tokyo", "Asia/Shanghai", "Australia/Sydney",
13
+ ];
14
+
15
+ function zoneList(): string[] {
16
+ try {
17
+ const f = (Intl as { supportedValuesOf?: (k: string) => string[] }).supportedValuesOf;
18
+ if (typeof f === "function") return f("timeZone");
19
+ } catch { /* ignore */ }
20
+ return FALLBACK_ZONES;
21
+ }
22
+
23
+ // Detected browser/OS zone (e.g. America/Argentina/Buenos_Aires in BA).
24
+ export function detectTimezone(): string {
25
+ try { return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; }
26
+ catch { return "UTC"; }
27
+ }
28
+
29
+ // Current GMT offset (minutes) for a zone, DST-aware as of `at`.
30
+ function offsetMinutes(tz: string, at: Date): number {
31
+ try {
32
+ const part = new Intl.DateTimeFormat("en-US", { timeZone: tz, timeZoneName: "longOffset" })
33
+ .formatToParts(at)
34
+ .find((p) => p.type === "timeZoneName")?.value ?? "GMT";
35
+ const m = /GMT([+-])(\d{1,2})(?::(\d{2}))?/.exec(part);
36
+ if (!m) return 0; // "GMT" with no offset → 0
37
+ const sign = m[1] === "-" ? -1 : 1;
38
+ return sign * (parseInt(m[2], 10) * 60 + (m[3] ? parseInt(m[3], 10) : 0));
39
+ } catch { return 0; }
40
+ }
41
+
42
+ function formatOffset(min: number): string {
43
+ const sign = min < 0 ? "-" : "+";
44
+ const abs = Math.abs(min);
45
+ const h = String(Math.floor(abs / 60)).padStart(2, "0");
46
+ const m = String(abs % 60).padStart(2, "0");
47
+ return `GMT${sign}${h}:${m}`;
48
+ }
49
+
50
+ export interface TzOption { value: string; label: string }
51
+
52
+ let cached: TzOption[] | null = null;
53
+
54
+ // All IANA zones as { value, label } sorted by GMT offset then name.
55
+ // Computed once per page load (offsets are stable enough across a session).
56
+ export function timezoneOptions(): TzOption[] {
57
+ if (cached) return cached;
58
+ const now = new Date();
59
+ cached = zoneList()
60
+ .map((tz) => ({ value: tz, label: tz, off: offsetMinutes(tz, now) }))
61
+ .sort((a, b) => a.off - b.off || a.value.localeCompare(b.value))
62
+ .map(({ value, off }) => ({ value, label: `(${formatOffset(off)}) ${value}` }));
63
+ return cached;
64
+ }
@@ -1,7 +1,7 @@
1
1
  import { type ReactElement } from "react";
2
2
  import { useLocation, useNavigate } from "react-router-dom";
3
3
  import {
4
- Bot, Cpu, Database, KeyRound, LayoutGrid, MessageCircle, Mic, Monitor, Palette, ScrollText, Send, Smartphone, Sparkles, User,
4
+ Bot, Cpu, Database, Globe, KeyRound, LayoutGrid, MessageCircle, Mic, Monitor, ScrollText, Send, Smartphone, Sparkles, User,
5
5
  } from "lucide-react";
6
6
  import { useNavCollapse, type TabSection } from "../components/common/TabNav";
7
7
  import { TabLayout } from "../components/common/TabLayout";
@@ -13,7 +13,7 @@ import { ModelsTab } from "./base/ModelsTab";
13
13
  import { TelegramSettingsTabs } from "../components/settings/TelegramSettingsTabs";
14
14
  import { DevicesPanel } from "../components/settings/DevicesPanel";
15
15
  import { AdvancedPanel } from "../components/settings/AdvancedPanel";
16
- import { AppearancePanel } from "../components/settings/AppearancePanel";
16
+ import { WebPanel } from "../components/settings/WebPanel";
17
17
  import { DesktopSettingsPanel } from "../components/settings/DesktopSettingsPanel";
18
18
  import { VoiceScreen } from "./modules/VoiceScreen";
19
19
  import { DeckScreen } from "./modules/DeckScreen";
@@ -22,14 +22,13 @@ import { t } from "../i18n";
22
22
 
23
23
  type TabKey =
24
24
  | "identity" | "super_agent" | "engines" | "memory" | "skills" | "telegram" | "devices"
25
- | "voice" | "deck" | "desktop" | "appearance" | "advanced";
25
+ | "voice" | "deck" | "desktop" | "web" | "advanced";
26
26
 
27
27
  const SECTIONS: TabSection[] = [
28
28
  {
29
29
  title: t("settings.account_section"),
30
30
  items: [
31
31
  { key: "identity", label: t("settings.tabs.identity"), icon: User },
32
- { key: "appearance", label: t("settings.appearance"), icon: Palette },
33
32
  ],
34
33
  },
35
34
  {
@@ -54,6 +53,7 @@ const SECTIONS: TabSection[] = [
54
53
  { key: "voice", label: t("nav.modules.voice"), icon: Mic },
55
54
  { key: "desktop", label: t("nav.modules.desktop"), icon: Monitor },
56
55
  { key: "deck", label: t("nav.modules.deck"), icon: LayoutGrid },
56
+ { key: "web", label: t("nav.modules.web"), icon: Globe },
57
57
  ],
58
58
  },
59
59
  {
@@ -68,7 +68,7 @@ const SECTIONS: TabSection[] = [
68
68
  // on xl (and so wants full available width). Single-section panels (identity,
69
69
  // super agent, devices, advanced) keep a cosier reading width so wide displays
70
70
  // don't blow form fields up to absurd widths.
71
- const WIDE_TABS = new Set<TabKey>(["engines", "telegram", "memory", "skills", "appearance", "voice"]);
71
+ const WIDE_TABS = new Set<TabKey>(["engines", "telegram", "memory", "skills", "web", "voice"]);
72
72
 
73
73
  const PANELS: Record<TabKey, () => ReactElement> = {
74
74
  identity: () => <IdentityPanel />,
@@ -81,7 +81,7 @@ const PANELS: Record<TabKey, () => ReactElement> = {
81
81
  voice: () => <VoiceScreen />,
82
82
  deck: () => <DeckScreen />,
83
83
  desktop: () => <DesktopSettingsPanel />,
84
- appearance: () => <AppearancePanel />,
84
+ web: () => <WebPanel />,
85
85
  advanced: () => <AdvancedPanel />,
86
86
  };
87
87
 
@@ -121,7 +121,8 @@ function tabFromPath(pathname: string): TabKey {
121
121
  case "voice": return "voice";
122
122
  case "deck": return "deck";
123
123
  case "desktop": return "desktop";
124
- case "appearance": return "appearance";
124
+ case "web": return "web";
125
+ case "appearance": return "web"; // legacy route → Web module
125
126
  case "config":
126
127
  case "advanced": return "advanced";
127
128
  default: return "identity";