feat(webui): add guided setup flows
* feat(channels): add guided setup flows * test(channels): preserve setup config values * fix(channels): reflect saved setup state * refactor(channels): simplify setup state metadata * fix(channels): harden setup lifecycle * refactor(channels): centralize setup contracts * fix(channels): route setup actions through webui shim * fix(channels): adapt settings for compact screens * fix(models): preserve default preset display * feat(models): add curated Codex catalog * fix(webui): stop attached gateway on interrupt * fix(webui): simplify apps catalog * docs(webui): clarify apps and runtime features * feat(settings): add guided capability setup * fix(webui): harden setup and managed services * test: keep managed runtime checks portable * test: scope POSIX runtime coverage * fix(webui): simplify file settings * feat(files): bundle document reading * fix(webui): harden setup request boundaries * fix(webui): prevent channel setup status squeeze * fix(settings): group provider compatibility aliases * refactor(settings): remove redundant setup surfaces * fix(webui): harden guided setup lifecycle * fix(webui): preserve channel setup compatibility
This commit is contained in:
+540
-6
@@ -6,7 +6,7 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Moon, PanelLeft, Sun } from "lucide-react";
|
||||
import { Moon, PanelLeft, ShieldCheck, Sun, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DeleteConfirm } from "@/components/DeleteConfirm";
|
||||
import { RenameChatDialog } from "@/components/RenameChatDialog";
|
||||
@@ -20,7 +20,9 @@ import { useSessions } from "@/hooks/useSessions";
|
||||
import { useDeferredTitleRefresh } from "@/hooks/useDeferredTitleRefresh";
|
||||
import { useSidebarState } from "@/hooks/useSidebarState";
|
||||
import { useSkills } from "@/hooks/useSkills";
|
||||
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
||||
import { ThemeProvider, useTheme } from "@/hooks/useTheme";
|
||||
import { logoFallbackUrls } from "@/lib/provider-brand";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
BootstrapAuthRequiredError,
|
||||
@@ -38,6 +40,7 @@ import { ClientProvider, useClient } from "@/providers/ClientProvider";
|
||||
import type {
|
||||
ChatSummary,
|
||||
RuntimeSurface,
|
||||
PairingRequestInfo,
|
||||
SessionAutomationJob,
|
||||
SettingsPayload,
|
||||
WorkspaceScopePayload,
|
||||
@@ -45,7 +48,12 @@ import type {
|
||||
} from "@/lib/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { fetchSettings, fetchWorkspaces } from "@/lib/api";
|
||||
import {
|
||||
fetchPairingRequests,
|
||||
fetchSettings,
|
||||
fetchWorkspaces,
|
||||
runPairingAction,
|
||||
} from "@/lib/api";
|
||||
import {
|
||||
createRuntimeHost,
|
||||
getHostApi,
|
||||
@@ -70,11 +78,15 @@ const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar";
|
||||
const SESSION_UPDATES_STORAGE_KEY = "nanobot-webui.sidebar.session-updates.v1";
|
||||
const LEGACY_COMPLETED_RUNS_STORAGE_KEY = "nanobot-webui.sidebar.completed-runs.v1";
|
||||
const RESTART_STARTED_KEY = "nanobot-webui.restartStartedAt";
|
||||
const RESTART_ROUTE_KEY = "nanobot-webui.restartRoute";
|
||||
const RESTART_ROUTE_TTL_MS = 5 * 60 * 1000;
|
||||
const SIDEBAR_WIDTH = 272;
|
||||
const SIDEBAR_RAIL_WIDTH = 56;
|
||||
const MOBILE_SIDEBAR_WIDTH = `min(${SIDEBAR_WIDTH}px, calc(100vw - 0.75rem))`;
|
||||
const TOKEN_REFRESH_MARGIN_MS = 30_000;
|
||||
const TOKEN_REFRESH_MIN_DELAY_MS = 5_000;
|
||||
const PAIRING_POLL_INTERVAL_MS = 5_000;
|
||||
const PAIRING_DISMISS_SNOOZE_MS = 30_000;
|
||||
type ShellView = "chat" | "settings" | "apps" | "automations" | "skills";
|
||||
type ShellRoute = {
|
||||
view: ShellView;
|
||||
@@ -82,6 +94,106 @@ type ShellRoute = {
|
||||
settingsSection: SettingsSectionKey;
|
||||
};
|
||||
|
||||
type PairingChannelPresentation = {
|
||||
label: string;
|
||||
initials: string;
|
||||
color: string;
|
||||
logoUrl?: string;
|
||||
};
|
||||
|
||||
const PAIRING_CHANNEL_PRESENTATION: Record<string, PairingChannelPresentation> = {
|
||||
dingtalk: {
|
||||
label: "DingTalk",
|
||||
initials: "DT",
|
||||
color: "#FF6A00",
|
||||
logoUrl: "https://www.dingtalk.com/favicon.ico",
|
||||
},
|
||||
discord: {
|
||||
label: "Discord",
|
||||
initials: "DC",
|
||||
color: "#5865F2",
|
||||
logoUrl: "https://discord.com/favicon.ico",
|
||||
},
|
||||
email: {
|
||||
label: "Email",
|
||||
initials: "EM",
|
||||
color: "#EA4335",
|
||||
logoUrl: "https://gmail.com/favicon.ico",
|
||||
},
|
||||
feishu: {
|
||||
label: "Feishu",
|
||||
initials: "FS",
|
||||
color: "#3370FF",
|
||||
logoUrl: "https://www.feishu.cn/favicon.ico",
|
||||
},
|
||||
lark: {
|
||||
label: "Lark",
|
||||
initials: "LK",
|
||||
color: "#3370FF",
|
||||
logoUrl: "https://www.larksuite.com/favicon.ico",
|
||||
},
|
||||
matrix: {
|
||||
label: "Matrix",
|
||||
initials: "M",
|
||||
color: "#111827",
|
||||
logoUrl: "https://matrix.org/favicon.ico",
|
||||
},
|
||||
msteams: {
|
||||
label: "Microsoft Teams",
|
||||
initials: "MT",
|
||||
color: "#6264A7",
|
||||
logoUrl: "https://www.microsoft.com/favicon.ico",
|
||||
},
|
||||
napcat: {
|
||||
label: "NapCat",
|
||||
initials: "NC",
|
||||
color: "#7C3AED",
|
||||
logoUrl: "https://napneko.github.io/favicon.ico",
|
||||
},
|
||||
qq: {
|
||||
label: "QQ",
|
||||
initials: "QQ",
|
||||
color: "#12B7F5",
|
||||
logoUrl: "https://im.qq.com/favicon.ico",
|
||||
},
|
||||
signal: {
|
||||
label: "Signal",
|
||||
initials: "SG",
|
||||
color: "#3A76F0",
|
||||
logoUrl: "https://signal.org/favicon.ico",
|
||||
},
|
||||
slack: {
|
||||
label: "Slack",
|
||||
initials: "SL",
|
||||
color: "#611F69",
|
||||
logoUrl: "https://slack.com/favicon.ico",
|
||||
},
|
||||
telegram: {
|
||||
label: "Telegram",
|
||||
initials: "TG",
|
||||
color: "#229ED9",
|
||||
logoUrl: "https://telegram.org/favicon.ico",
|
||||
},
|
||||
wecom: {
|
||||
label: "WeCom",
|
||||
initials: "WC",
|
||||
color: "#2F7DFF",
|
||||
logoUrl: "https://work.weixin.qq.com/favicon.ico",
|
||||
},
|
||||
weixin: {
|
||||
label: "WeChat",
|
||||
initials: "WX",
|
||||
color: "#07C160",
|
||||
logoUrl: "https://weixin.qq.com/favicon.ico",
|
||||
},
|
||||
whatsapp: {
|
||||
label: "WhatsApp",
|
||||
initials: "WA",
|
||||
color: "#25D366",
|
||||
logoUrl: "https://www.whatsapp.com/favicon.ico",
|
||||
},
|
||||
};
|
||||
|
||||
const SETTINGS_SECTION_KEYS: SettingsSectionKey[] = [
|
||||
"overview",
|
||||
"appearance",
|
||||
@@ -89,6 +201,7 @@ const SETTINGS_SECTION_KEYS: SettingsSectionKey[] = [
|
||||
"image",
|
||||
"voice",
|
||||
"browser",
|
||||
"channels",
|
||||
"apps",
|
||||
"automations",
|
||||
"skills",
|
||||
@@ -109,11 +222,47 @@ function shellViewForSettingsSection(section: SettingsSectionKey): ShellView {
|
||||
return "settings";
|
||||
}
|
||||
|
||||
function fallbackRestartHash(hash: string): boolean {
|
||||
return !hash || hash === "/" || hash === "/new";
|
||||
}
|
||||
|
||||
function rememberRestartRoute(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(RESTART_ROUTE_KEY, window.location.hash || "#/new");
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRestoreRestartHash(hash: string): string {
|
||||
if (typeof window === "undefined" || !fallbackRestartHash(hash)) return hash;
|
||||
try {
|
||||
const startedAt = Number(window.localStorage.getItem(RESTART_STARTED_KEY) ?? "0");
|
||||
const storedHash = window.localStorage.getItem(RESTART_ROUTE_KEY);
|
||||
if (!startedAt || !storedHash || Date.now() - startedAt > RESTART_ROUTE_TTL_MS) {
|
||||
window.localStorage.removeItem(RESTART_ROUTE_KEY);
|
||||
return hash;
|
||||
}
|
||||
window.localStorage.removeItem(RESTART_ROUTE_KEY);
|
||||
const nextHash = storedHash.startsWith("#") ? storedHash : `#${storedHash}`;
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${window.location.pathname}${window.location.search}${nextHash}`,
|
||||
);
|
||||
return nextHash.slice(1);
|
||||
} catch {
|
||||
return hash;
|
||||
}
|
||||
}
|
||||
|
||||
function readShellRoute(): ShellRoute {
|
||||
if (typeof window === "undefined") return defaultShellRoute();
|
||||
const hash = window.location.hash.startsWith("#")
|
||||
const currentHash = window.location.hash.startsWith("#")
|
||||
? window.location.hash.slice(1)
|
||||
: window.location.hash;
|
||||
const hash = maybeRestoreRestartHash(currentHash);
|
||||
if (!hash || hash === "/" || hash === "/new") return defaultShellRoute();
|
||||
|
||||
const [path, query = ""] = hash.split("?", 2);
|
||||
@@ -346,6 +495,286 @@ function HostChrome({
|
||||
);
|
||||
}
|
||||
|
||||
function PairingCodePopup({
|
||||
requests,
|
||||
total,
|
||||
busyCode,
|
||||
error,
|
||||
onApprove,
|
||||
onDismiss,
|
||||
}: {
|
||||
requests: PairingRequestInfo[];
|
||||
total: number;
|
||||
busyCode: string | null;
|
||||
error: string | null;
|
||||
onApprove: (code: string) => void;
|
||||
onDismiss: (code: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = useState("");
|
||||
const normalizedCode = normalizePairingCode(value);
|
||||
const matchedRequest = useMemo(
|
||||
() => requests.find((request) => request.code === normalizedCode) ?? null,
|
||||
[normalizedCode, requests],
|
||||
);
|
||||
const firstRequest = requests[0] ?? null;
|
||||
const displayRequest = matchedRequest ?? firstRequest;
|
||||
const expires = formatPairingExpiry(firstRequest?.expires_in_seconds);
|
||||
const isCompleteCode = normalizedCode.length === 9;
|
||||
const showNoMatch = isCompleteCode && !matchedRequest && !busyCode;
|
||||
|
||||
useEffect(() => {
|
||||
if (!matchedRequest || busyCode) return;
|
||||
onApprove(matchedRequest.code);
|
||||
}, [busyCode, matchedRequest, onApprove]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!requests.length) setValue("");
|
||||
}, [requests.length]);
|
||||
|
||||
if (!firstRequest) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-live="polite"
|
||||
aria-label={t("app.pairing.title", { defaultValue: "Pair a chat user" })}
|
||||
className={cn(
|
||||
"fixed right-4 top-[calc(0.75rem+env(safe-area-inset-top))] z-[70]",
|
||||
"w-[min(calc(100vw-2rem),24rem)] rounded-[24px]",
|
||||
"border border-border/70 bg-popover/95 p-4 text-popover-foreground",
|
||||
"shadow-[0_24px_70px_rgba(15,23,42,0.20)] backdrop-blur-xl",
|
||||
"animate-in fade-in-0 slide-in-from-top-2 duration-200",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<PairingChannelBadge channel={displayRequest.channel} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[15px] font-semibold tracking-[-0.01em]">
|
||||
{t("app.pairing.title", { defaultValue: "Pair a chat user" })}
|
||||
</p>
|
||||
<p className="mt-1 text-[13px] leading-5 text-muted-foreground">
|
||||
{t("app.pairing.description", {
|
||||
defaultValue: "Enter the pairing code shown in the chat.",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("common.close", { defaultValue: "Close" })}
|
||||
onClick={() => onDismiss(firstRequest.code)}
|
||||
className="rounded-full p-1 text-muted-foreground transition hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="mt-4 block text-[12.5px] font-medium text-foreground">
|
||||
{t("app.pairing.code", { defaultValue: "Pairing code" })}
|
||||
</label>
|
||||
<PairingCodeSlots
|
||||
value={value}
|
||||
disabled={Boolean(busyCode)}
|
||||
matched={Boolean(matchedRequest)}
|
||||
invalid={showNoMatch}
|
||||
ariaLabel={t("app.pairing.code", { defaultValue: "Pairing code" })}
|
||||
onChange={(next) => setValue(formatPairingCodeInput(next))}
|
||||
/>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between gap-3 text-[12.5px] text-muted-foreground">
|
||||
<span>
|
||||
{matchedRequest
|
||||
? t("app.pairing.matched", {
|
||||
defaultValue: "Matched {{channel}}. Connecting...",
|
||||
channel: channelLabel(matchedRequest.channel),
|
||||
})
|
||||
: t("app.pairing.expiresInline", {
|
||||
defaultValue: "Code expires {{expires}}.",
|
||||
expires,
|
||||
})}
|
||||
</span>
|
||||
{total > 1 ? (
|
||||
<span className="shrink-0">
|
||||
{t("app.pairing.queueCount", {
|
||||
defaultValue: "{{count}} pending",
|
||||
count: total,
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{showNoMatch ? (
|
||||
<p className="mt-2 text-[12px] leading-5 text-destructive">
|
||||
{t("app.pairing.noMatch", {
|
||||
defaultValue: "No pending request matches this code.",
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p className="mt-2 text-[12px] leading-5 text-destructive">{error}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PairingChannelBadge({ channel }: { channel: string }) {
|
||||
const key = pairingChannelKey(channel);
|
||||
const presentation = PAIRING_CHANNEL_PRESENTATION[key];
|
||||
const label = presentation?.label ?? channelLabel(channel);
|
||||
const initials = presentation?.initials ?? label.slice(0, 2).toUpperCase();
|
||||
const color = presentation?.color ?? "#10B981";
|
||||
const logoUrls = useMemo(
|
||||
() => logoFallbackUrls(presentation?.logoUrl),
|
||||
[presentation?.logoUrl],
|
||||
);
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mt-0.5 grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-2xl border bg-background shadow-sm"
|
||||
style={{
|
||||
borderColor: `${color}30`,
|
||||
boxShadow: `inset 0 0 0 1px ${color}14, 0 1px 2px rgba(15,23,42,0.06)`,
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
{logoUrl ? (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
className="h-6 w-6 object-contain"
|
||||
onLoad={onLogoLoad}
|
||||
onError={onLogoError}
|
||||
/>
|
||||
) : presentation ? (
|
||||
<span className="text-[11px] font-bold tracking-[-0.02em]" style={{ color }}>
|
||||
{initials}
|
||||
</span>
|
||||
) : (
|
||||
<ShieldCheck className="h-5 w-5" style={{ color }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PairingCodeSlots({
|
||||
value,
|
||||
disabled,
|
||||
matched,
|
||||
invalid,
|
||||
ariaLabel,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
disabled: boolean;
|
||||
matched: boolean;
|
||||
invalid: boolean;
|
||||
ariaLabel: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const compact = compactPairingCode(value);
|
||||
const activeIndex = Math.min(compact.length, 7);
|
||||
const slots = Array.from({ length: 8 }, (_, index) => compact[index] ?? "");
|
||||
const renderSlot = (char: string, index: number) => {
|
||||
const highlighted = focused && index === activeIndex && !matched && !invalid;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"grid h-10 w-7 place-items-center rounded-xl border",
|
||||
"bg-background/80 font-mono text-[16px] font-semibold uppercase",
|
||||
"text-foreground shadow-[0_1px_1px_rgba(15,23,42,0.04)] transition",
|
||||
matched
|
||||
? "border-emerald-500/45 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
|
||||
: invalid
|
||||
? "border-destructive/55 bg-destructive/5 text-destructive"
|
||||
: highlighted
|
||||
? "border-foreground/30 bg-background text-foreground"
|
||||
: char
|
||||
? "border-border/80 bg-background text-foreground"
|
||||
: "border-border/55 bg-muted/35 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{char || " "}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative mt-2 rounded-2xl border border-transparent p-1",
|
||||
"transition duration-150",
|
||||
focused && !disabled ? "border-ring/20 bg-muted/35" : "bg-transparent",
|
||||
)}
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
aria-label={ariaLabel}
|
||||
inputMode="text"
|
||||
autoCapitalize="characters"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
maxLength={9}
|
||||
disabled={disabled}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="absolute inset-0 z-10 h-full w-full cursor-text opacity-0 disabled:cursor-default"
|
||||
/>
|
||||
<div className="pointer-events-none flex items-center gap-1.5">
|
||||
{slots.slice(0, 4).map((char, index) => renderSlot(char, index))}
|
||||
<div className="mx-0.5 h-px w-2.5 rounded-full bg-muted-foreground/35" />
|
||||
{slots.slice(4).map((char, index) => renderSlot(char, index + 4))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function compactPairingCode(raw: string): string {
|
||||
return raw.replace(/[^a-zA-Z0-9]/g, "").slice(0, 8).toUpperCase();
|
||||
}
|
||||
|
||||
function formatPairingCodeInput(raw: string): string {
|
||||
const compact = compactPairingCode(raw);
|
||||
if (compact.length <= 4) return compact;
|
||||
return `${compact.slice(0, 4)}-${compact.slice(4)}`;
|
||||
}
|
||||
|
||||
function normalizePairingCode(raw: string): string {
|
||||
return formatPairingCodeInput(raw);
|
||||
}
|
||||
|
||||
function pairingChannelKey(channel: string): string {
|
||||
const raw = channel.trim().toLowerCase();
|
||||
if (!raw) return "";
|
||||
return raw.split(/[.:]/)[0] ?? raw;
|
||||
}
|
||||
|
||||
function channelLabel(channel: string): string {
|
||||
const key = pairingChannelKey(channel);
|
||||
return PAIRING_CHANNEL_PRESENTATION[key]?.label ?? channel;
|
||||
}
|
||||
|
||||
function formatPairingExpiry(seconds: number | null | undefined): string {
|
||||
if (seconds == null) return "soon";
|
||||
if (seconds <= 0) return "expired";
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
return `${Math.ceil(seconds / 60)} min`;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<BootState>({ status: "loading" });
|
||||
@@ -510,9 +939,24 @@ export default function App() {
|
||||
if (!hostApi?.restartEngine) {
|
||||
throw new Error("native engine restart is unavailable");
|
||||
}
|
||||
await hostApi.restartEngine();
|
||||
const refreshed = await refreshReadyClient(state.client, state.runtimeSurface);
|
||||
return refreshed.token;
|
||||
rememberRestartRoute();
|
||||
try {
|
||||
window.localStorage.setItem(RESTART_STARTED_KEY, String(Date.now()));
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
try {
|
||||
await hostApi.restartEngine();
|
||||
const refreshed = await refreshReadyClient(state.client, state.runtimeSurface);
|
||||
return refreshed.token;
|
||||
} finally {
|
||||
try {
|
||||
window.localStorage.removeItem(RESTART_STARTED_KEY);
|
||||
window.localStorage.removeItem(RESTART_ROUTE_KEY);
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -585,6 +1029,12 @@ function Shell({
|
||||
const restartSawDisconnectRef = useRef(false);
|
||||
const [restartToast, setRestartToast] = useState<string | null>(null);
|
||||
const [isRestarting, setIsRestarting] = useState(false);
|
||||
const [pairingRequests, setPairingRequests] = useState<PairingRequestInfo[]>([]);
|
||||
const [pairingBusyCode, setPairingBusyCode] = useState<string | null>(null);
|
||||
const [pairingError, setPairingError] = useState<string | null>(null);
|
||||
const [snoozedPairingCodes, setSnoozedPairingCodes] = useState<Map<string, number>>(
|
||||
() => new Map(),
|
||||
);
|
||||
const [runningChatIds, setRunningChatIds] = useState<Set<string>>(() => new Set());
|
||||
const [updatedChatIds, setUpdatedChatIds] = useState<Set<string>>(readSessionUpdateChatIds);
|
||||
const [workspaces, setWorkspaces] = useState<WorkspacesPayload | null>(null);
|
||||
@@ -657,6 +1107,36 @@ function Shell({
|
||||
writeSessionUpdateChatIds(updatedChatIds);
|
||||
}, [updatedChatIds]);
|
||||
|
||||
const refreshPairingRequests = useCallback(async () => {
|
||||
try {
|
||||
const payload = await fetchPairingRequests(token);
|
||||
const requests = Array.isArray(payload.requests) ? payload.requests : [];
|
||||
setPairingRequests(requests);
|
||||
setSnoozedPairingCodes((current) => {
|
||||
if (current.size === 0) return current;
|
||||
const activeCodes = new Set(requests.map((request) => request.code));
|
||||
const now = Date.now();
|
||||
const next = new Map(
|
||||
Array.from(current).filter(
|
||||
([code, snoozedUntil]) => activeCodes.has(code) && snoozedUntil > now,
|
||||
),
|
||||
);
|
||||
return next.size === current.size ? current : next;
|
||||
});
|
||||
} catch {
|
||||
// Pairing is an opportunistic WebUI affordance. The slash command path
|
||||
// remains available if this polling request fails.
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshPairingRequests();
|
||||
const timer = window.setInterval(() => {
|
||||
void refreshPairingRequests();
|
||||
}, PAIRING_POLL_INTERVAL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [refreshPairingRequests]);
|
||||
|
||||
const activeSession = useMemo<ChatSummary | null>(() => {
|
||||
if (!activeKey) return null;
|
||||
return sessions.find((s) => s.key === activeKey) ?? null;
|
||||
@@ -1237,6 +1717,7 @@ function Shell({
|
||||
if (!chatId) return;
|
||||
restartSawDisconnectRef.current = false;
|
||||
setIsRestarting(true);
|
||||
rememberRestartRoute();
|
||||
try {
|
||||
window.localStorage.setItem(RESTART_STARTED_KEY, String(Date.now()));
|
||||
} catch {
|
||||
@@ -1302,6 +1783,7 @@ function Shell({
|
||||
if (!restartSawDisconnectRef.current && elapsedMs < 1500) return;
|
||||
try {
|
||||
window.localStorage.removeItem(RESTART_STARTED_KEY);
|
||||
window.localStorage.removeItem(RESTART_ROUTE_KEY);
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
@@ -1357,6 +1839,50 @@ function Shell({
|
||||
setPendingDelete({ key, label, automations });
|
||||
}, [getSessionAutomations]);
|
||||
|
||||
const visiblePairingRequests = useMemo(
|
||||
() => {
|
||||
const now = Date.now();
|
||||
return pairingRequests.filter((request) => {
|
||||
const snoozedUntil = snoozedPairingCodes.get(request.code);
|
||||
return !snoozedUntil || snoozedUntil <= now;
|
||||
});
|
||||
},
|
||||
[pairingRequests, snoozedPairingCodes],
|
||||
);
|
||||
|
||||
const onPairingAction = useCallback(
|
||||
async (action: "approve" | "deny", code: string) => {
|
||||
setPairingBusyCode(code);
|
||||
setPairingError(null);
|
||||
try {
|
||||
const payload = await runPairingAction(token, action, code);
|
||||
setPairingRequests(Array.isArray(payload.requests) ? payload.requests : []);
|
||||
setSnoozedPairingCodes((current) => {
|
||||
if (!current.has(code)) return current;
|
||||
const next = new Map(current);
|
||||
next.delete(code);
|
||||
return next;
|
||||
});
|
||||
} catch (e) {
|
||||
setPairingError((e as Error).message);
|
||||
void refreshPairingRequests();
|
||||
} finally {
|
||||
setPairingBusyCode(null);
|
||||
}
|
||||
},
|
||||
[refreshPairingRequests, token],
|
||||
);
|
||||
|
||||
const onDismissPairingRequest = useCallback((code: string) => {
|
||||
setSnoozedPairingCodes((current) => {
|
||||
const snoozedUntil = Date.now() + PAIRING_DISMISS_SNOOZE_MS;
|
||||
if (current.get(code) === snoozedUntil) return current;
|
||||
const next = new Map(current);
|
||||
next.set(code, snoozedUntil);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const headerTitle = activeSession
|
||||
? sidebarState.title_overrides[activeSession.key] ||
|
||||
activeSession.title ||
|
||||
@@ -1653,6 +2179,14 @@ function Shell({
|
||||
{restartToast}
|
||||
</div>
|
||||
) : null}
|
||||
<PairingCodePopup
|
||||
requests={visiblePairingRequests}
|
||||
total={visiblePairingRequests.length}
|
||||
busyCode={pairingBusyCode}
|
||||
error={pairingError}
|
||||
onApprove={(code) => void onPairingAction("approve", code)}
|
||||
onDismiss={onDismissPairingRequest}
|
||||
/>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user