@agentprojectcontext/apx 1.52.0 → 1.53.1
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.
- package/package.json +1 -1
- package/src/host/daemon/plugins/desktop/index.js +7 -2
- package/src/interfaces/desktop/renderer.js +4 -2
- package/src/interfaces/web/dist/assets/index-BwnMDZaD.css +1 -0
- package/src/interfaces/web/dist/assets/{index-adeTv5sA.js → index-vuz4PKLd.js} +137 -137
- package/src/interfaces/web/dist/assets/index-vuz4PKLd.js.map +1 -0
- package/src/interfaces/web/dist/index.html +2 -2
- package/src/interfaces/web/src/components/TimezoneSelect.tsx +117 -0
- package/src/interfaces/web/src/components/settings/DevicesPanel.tsx +63 -32
- package/src/interfaces/web/src/components/settings/IdentityPanel.tsx +16 -6
- package/src/interfaces/web/src/components/settings/WebPanel.tsx +45 -0
- package/src/interfaces/web/src/hooks/useTheme.tsx +47 -15
- package/src/interfaces/web/src/i18n/languages.ts +37 -0
- package/src/interfaces/web/src/i18n/timezones.ts +64 -0
- package/src/interfaces/web/src/screens/SettingsScreen.tsx +8 -7
- package/src/interfaces/web/dist/assets/index-L1pXYFUg.css +0 -1
- package/src/interfaces/web/dist/assets/index-adeTv5sA.js.map +0 -1
- package/src/interfaces/web/src/components/settings/AppearancePanel.tsx +0 -72
|
@@ -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-
|
|
22
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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
|
-
<
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
<
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
<
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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={
|
|
54
|
+
options={languageOptions()}
|
|
49
55
|
/>
|
|
50
56
|
</Field>
|
|
51
57
|
<Field label={t("settings.identity.timezone")} hint={t("settings.identity.timezone_hint")}>
|
|
52
|
-
<
|
|
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
|
|
19
|
+
type Resolved = "light" | "dark";
|
|
20
|
+
type Preference = "light" | "dark" | "system";
|
|
16
21
|
|
|
17
|
-
function
|
|
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
|
|
36
|
+
if (saved === "light" || saved === "dark" || saved === "system") return saved;
|
|
37
|
+
return "dark";
|
|
22
38
|
}
|
|
23
39
|
|
|
24
40
|
type ThemeContextValue = {
|
|
25
|
-
|
|
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: (
|
|
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 [
|
|
52
|
+
const [preference, setPreference] = useState<Preference>(readPreference);
|
|
53
|
+
const [theme, setTheme] = useState<Resolved>(() => resolve(readPreference()));
|
|
34
54
|
|
|
35
55
|
useEffect(() => {
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
74
|
+
setPreference((p) => (resolve(p) === "dark" ? "light" : "dark"));
|
|
43
75
|
}, []);
|
|
44
76
|
|
|
45
|
-
const value = useMemo(
|
|
46
|
-
() => ({ theme, toggle, set:
|
|
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,
|
|
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 {
|
|
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" | "
|
|
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", "
|
|
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
|
-
|
|
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 "
|
|
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";
|