import {
lazy,
Suspense,
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { Moon, PanelLeft, ShieldCheck, Sun, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { channelUiPresentation } from "@/channel-plugins/registry";
import { Sidebar } from "@/components/Sidebar";
import type { SettingsSectionKey } from "@/components/settings/SettingsView";
import { ThreadShell } from "@/components/thread/ThreadShell";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
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 { usePageVisibility } from "@/hooks/usePageVisibility";
import { ThemeProvider, useTheme } from "@/hooks/useTheme";
import { logoFallbackUrls } from "@/lib/provider-brand";
import { cn } from "@/lib/utils";
import {
BootstrapAuthRequiredError,
clearSavedSecret,
consumeUrlBootstrapSecret,
deriveWsUrl,
fetchBootstrap,
loadSavedSecret,
saveSecret,
} from "@/lib/bootstrap";
import { displayTitle } from "@/lib/chat-groups";
import { deriveTitle } from "@/lib/format";
import { NanobotClient } from "@/lib/nanobot-client";
import { ClientProvider, useClient } from "@/providers/ClientProvider";
import type {
BootstrapResponse,
ChatSummary,
RuntimeSurface,
PairingRequestInfo,
SessionAutomationJob,
SettingsPayload,
WorkspaceScopePayload,
WorkspacesPayload,
} from "@/lib/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
fetchPairingRequests,
fetchSettings,
fetchWorkspaces,
runPairingAction,
} from "@/lib/api";
import {
createRuntimeHost,
toRuntimeSurface,
} from "@/lib/runtime";
import { projectNameFromPath } from "@/lib/workspace";
type BootState =
| { status: "loading" }
| { status: "error"; message: string }
| { status: "auth"; failed?: boolean }
| {
status: "ready";
client: NanobotClient;
token: string;
tokenExpiresAt: number;
modelName: string | null;
ingressLimits: BootstrapResponse["limits"] | null;
runtimeSurface: RuntimeSurface;
};
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_IDLE_POLL_INTERVAL_MS = 15_000;
const PAIRING_DISMISS_SNOOZE_MS = 30_000;
type ShellView = "chat" | "settings" | "apps" | "automations" | "skills";
type ShellRoute = {
view: ShellView;
activeKey: string | null;
settingsSection: SettingsSectionKey;
};
const loadSettingsView = () => import("@/components/settings/SettingsView");
const SettingsView = lazy(async () => {
const module = await loadSettingsView();
return { default: module.SettingsView };
});
const SessionSearchDialog = lazy(async () => {
const module = await import("@/components/SessionSearchDialog");
return { default: module.SessionSearchDialog };
});
const DeleteConfirm = lazy(async () => {
const module = await import("@/components/DeleteConfirm");
return { default: module.DeleteConfirm };
});
const RenameChatDialog = lazy(async () => {
const module = await import("@/components/RenameChatDialog");
return { default: module.RenameChatDialog };
});
function SurfaceLoadingFallback() {
return (
);
}
const SETTINGS_SECTION_KEYS: SettingsSectionKey[] = [
"overview",
"appearance",
"models",
"image",
"voice",
"browser",
"channels",
"apps",
"automations",
"skills",
"runtime",
"advanced",
];
function isSettingsSectionKey(value: string | null): value is SettingsSectionKey {
return SETTINGS_SECTION_KEYS.includes(value as SettingsSectionKey);
}
function defaultShellRoute(): ShellRoute {
return { view: "chat", activeKey: null, settingsSection: "overview" };
}
function shellViewForSettingsSection(section: SettingsSectionKey): ShellView {
if (section === "apps" || section === "automations" || section === "skills") return section;
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 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);
const params = new URLSearchParams(query);
const rawSettingsSection = params.get("section");
const settingsSection = isSettingsSectionKey(rawSettingsSection)
? rawSettingsSection
: "overview";
const activeKey = params.get("chat")?.trim() || null;
if (path === "/settings") {
return {
view: shellViewForSettingsSection(settingsSection),
activeKey,
settingsSection,
};
}
if (path === "/apps") {
return { view: "apps", activeKey, settingsSection: "apps" };
}
if (path === "/automations") {
return { view: "automations", activeKey, settingsSection: "automations" };
}
if (path === "/skills") {
return { view: "skills", activeKey, settingsSection: "skills" };
}
if (path.startsWith("/chat/")) {
const encoded = path.slice("/chat/".length);
try {
const key = decodeURIComponent(encoded).trim();
return key
? { view: "chat", activeKey: key, settingsSection: "overview" }
: defaultShellRoute();
} catch {
return defaultShellRoute();
}
}
return defaultShellRoute();
}
function shellRouteHash(route: ShellRoute): string {
if (route.view === "chat") {
return route.activeKey
? `#/chat/${encodeURIComponent(route.activeKey)}`
: "#/new";
}
const params = new URLSearchParams();
if (route.activeKey) params.set("chat", route.activeKey);
if (route.view === "settings" && route.settingsSection !== "overview") {
params.set("section", route.settingsSection);
}
const query = params.toString();
return `#/${route.view}${query ? `?${query}` : ""}`;
}
function writeShellRoute(route: ShellRoute, replace = false): void {
if (typeof window === "undefined") return;
const nextHash = shellRouteHash(route);
if (window.location.hash === nextHash) return;
if (replace) {
window.history.replaceState(
null,
"",
`${window.location.pathname}${window.location.search}${nextHash}`,
);
return;
}
window.location.hash = nextHash;
}
function bootstrapTokenExpiresAt(expiresInSeconds: number): number {
return Date.now() + Math.max(0, expiresInSeconds) * 1000;
}
function tokenRefreshDelayMs(expiresAt: number): number {
const remaining = Math.max(0, expiresAt - Date.now());
const margin = Math.min(
TOKEN_REFRESH_MARGIN_MS,
Math.max(1_000, remaining / 2),
);
return Math.max(TOKEN_REFRESH_MIN_DELAY_MS, remaining - margin);
}
function AuthForm({
failed,
onSecret,
}: {
failed: boolean;
onSecret: (secret: string) => void;
}) {
const { t } = useTranslation();
const [value, setValue] = useState("");
const [submitting, setSubmitting] = useState(false);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const secret = value.trim();
if (!secret) return;
setSubmitting(true);
onSecret(secret);
};
return (
);
}
function readSidebarOpen(): boolean {
if (typeof window === "undefined") return true;
try {
const raw = window.localStorage.getItem(SIDEBAR_STORAGE_KEY);
if (raw === null) return true;
return raw === "1";
} catch {
return true;
}
}
function readSessionUpdateChatIds(): Set {
if (typeof window === "undefined") return new Set();
try {
const raw =
window.localStorage.getItem(SESSION_UPDATES_STORAGE_KEY)
?? window.localStorage.getItem(LEGACY_COMPLETED_RUNS_STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : [];
if (!Array.isArray(parsed)) return new Set();
return new Set(parsed.filter((item): item is string => typeof item === "string"));
} catch {
return new Set();
}
}
function writeSessionUpdateChatIds(chatIds: Set): void {
try {
window.localStorage.setItem(
SESSION_UPDATES_STORAGE_KEY,
JSON.stringify(Array.from(chatIds)),
);
} catch {
// ignore storage errors (private mode, etc.)
}
}
function normalizeWorkspaceScope(scope: WorkspaceScopePayload): WorkspaceScopePayload {
const accessMode = scope.access_mode === "restricted" ? "restricted" : "full";
return {
...scope,
project_name: scope.project_name ?? projectNameFromPath(scope.project_path),
access_mode: accessMode,
restrict_to_workspace: accessMode === "restricted",
};
}
function isBootstrapAuthRequired(error: unknown): boolean {
if (error instanceof BootstrapAuthRequiredError) return true;
const msg = error instanceof Error ? error.message : String(error);
return msg.includes("HTTP 401") || msg.includes("HTTP 403");
}
function HostChrome({
onToggleSidebar,
onSidebarPreviewEnter,
onSidebarPreviewLeave,
sidebarOpen = true,
rightAction,
}: {
onToggleSidebar?: () => void;
onSidebarPreviewEnter?: () => void;
onSidebarPreviewLeave?: () => void;
sidebarOpen?: boolean;
rightAction?: ReactNode;
}) {
const { t } = useTranslation();
return (
{onToggleSidebar ? (
) : null}
{rightAction ? (
{rightAction}
) : null}
);
}
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 (
{t("app.pairing.title", { defaultValue: "Pair a chat user" })}
{t("app.pairing.description", {
defaultValue: "Enter the pairing code shown in the chat.",
})}
setValue(formatPairingCodeInput(next))}
/>
{matchedRequest
? t("app.pairing.matched", {
defaultValue: "Matched {{channel}}. Connecting...",
channel: channelLabel(matchedRequest.channel),
})
: t("app.pairing.expiresInline", {
defaultValue: "Code expires {{expires}}.",
expires,
})}
{total > 1 ? (
{t("app.pairing.queueCount", {
defaultValue: "{{count}} pending",
count: total,
})}
) : null}
{showNoMatch ? (
{t("app.pairing.noMatch", {
defaultValue: "No pending request matches this code.",
})}
) : null}
{error ? (
{error}
) : null}
);
}
function PairingChannelBadge({ channel }: { channel: string }) {
const presentation = pairingChannelPresentation(channel);
const initials = presentation.initials;
const color = presentation.color;
const logoUrls = useMemo(
() => logoFallbackUrls(presentation?.logoUrl),
[presentation?.logoUrl],
);
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
return (
{logoUrl ? (

) : presentation ? (
{initials}
) : (
)}
);
}
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(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 (
{char || " "}
);
};
return (
inputRef.current?.focus()}
>
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"
/>
{slots.slice(0, 4).map((char, index) => renderSlot(char, index))}
{slots.slice(4).map((char, index) => renderSlot(char, index + 4))}
);
}
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 {
return pairingChannelPresentation(channel).label;
}
function pairingChannelPresentation(channel: string) {
const key = pairingChannelKey(channel);
const plugin = channelUiPresentation(key);
return {
label: plugin?.displayName ?? channel,
initials: plugin?.initials ?? channel.slice(0, 2).toUpperCase(),
color: plugin?.color ?? "#10B981",
logoUrl: plugin?.logoUrl,
};
}
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({ status: "loading" });
const bootstrapSecretRef = useRef("");
const refreshReadyClient = useCallback(
async (client: NanobotClient, fallbackSurface: RuntimeSurface) => {
const boot = await fetchBootstrap("", bootstrapSecretRef.current);
const url = deriveWsUrl(boot.ws_path, boot.token, boot.ws_url);
const runtimeSurface = boot.runtime_surface
? toRuntimeSurface(boot.runtime_surface)
: fallbackSurface;
const runtimeHost = createRuntimeHost(runtimeSurface, boot.runtime_capabilities);
const tokenExpiresAt = bootstrapTokenExpiresAt(boot.expires_in);
if (runtimeHost.socketFactory) {
client.updateUrl(url, runtimeHost.socketFactory);
} else {
client.updateUrl(url);
}
setState((current) =>
current.status === "ready" && current.client === client
? {
...current,
token: boot.api_token,
tokenExpiresAt,
modelName: boot.model_name ?? current.modelName,
ingressLimits: boot.limits ?? current.ingressLimits,
runtimeSurface,
}
: current,
);
return { token: boot.api_token, url };
},
[],
);
const bootstrapWithSecret = useCallback(
(secret: string) => {
let cancelled = false;
(async () => {
setState({ status: "loading" });
try {
const boot = await fetchBootstrap("", secret);
if (cancelled) return;
if (secret) saveSecret(secret);
const url = deriveWsUrl(boot.ws_path, boot.token, boot.ws_url);
const runtimeSurface = toRuntimeSurface(boot.runtime_surface);
const runtimeHost = createRuntimeHost(runtimeSurface, boot.runtime_capabilities);
const client = new NanobotClient({
url,
socketFactory: runtimeHost.socketFactory,
onReauth: async () => {
try {
const refreshed = await refreshReadyClient(client, runtimeSurface);
return refreshed.url;
} catch {
return null;
}
},
});
bootstrapSecretRef.current = secret;
client.connect();
setState({
status: "ready",
client,
token: boot.api_token,
tokenExpiresAt: bootstrapTokenExpiresAt(boot.expires_in),
modelName: boot.model_name ?? null,
ingressLimits: boot.limits ?? null,
runtimeSurface,
});
} catch (e) {
if (cancelled) return;
if (isBootstrapAuthRequired(e)) {
setState({ status: "auth", failed: !!secret });
} else {
setState({
status: "error",
message: e instanceof Error ? e.message : String(e),
});
}
}
})();
return () => {
cancelled = true;
};
},
[refreshReadyClient],
);
useEffect(() => {
if (state.status !== "ready") return;
const client = state.client;
const timer = window.setTimeout(async () => {
try {
await refreshReadyClient(client, state.runtimeSurface);
} catch (e) {
if (isBootstrapAuthRequired(e)) {
setState({ status: "auth", failed: !!bootstrapSecretRef.current });
}
}
}, tokenRefreshDelayMs(state.tokenExpiresAt));
return () => window.clearTimeout(timer);
}, [refreshReadyClient, state]);
useEffect(() => {
const saved = consumeUrlBootstrapSecret() || loadSavedSecret();
return bootstrapWithSecret(saved);
}, [bootstrapWithSecret]);
if (state.status === "loading") {
return (
{t("app.loading.connecting")}
);
}
if (state.status === "auth") {
return (
bootstrapWithSecret(s)}
/>
);
}
if (state.status === "error") {
return (
{t("app.error.title")}
{state.message}
{t("app.error.gatewayHint")}
);
}
const handleModelNameChange = (modelName: string | null) => {
setState((current) =>
current.status === "ready" ? { ...current, modelName } : current,
);
};
const handleLogout = () => {
if (state.status === "ready") {
state.client.close();
}
clearSavedSecret();
setState({ status: "auth" });
};
const handleNativeEngineRestart = async (): Promise => {
const runtimeHost = createRuntimeHost(state.runtimeSurface);
if (!runtimeHost.restartEngine) {
throw new Error("native engine restart is unavailable");
}
rememberRestartRoute();
try {
window.localStorage.setItem(RESTART_STARTED_KEY, String(Date.now()));
} catch {
// ignore storage errors
}
try {
await runtimeHost.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 (
);
}
function Shell({
runtimeSurface,
onModelNameChange,
onLogout,
onNativeEngineRestart,
}: {
runtimeSurface: RuntimeSurface;
onModelNameChange: (modelName: string | null) => void;
onLogout: () => void;
onNativeEngineRestart: () => Promise;
}) {
const { t, i18n } = useTranslation();
const { client, token } = useClient();
const { theme, toggle } = useTheme();
const {
sessions,
loading,
refresh,
createChat,
forkChat,
deleteChat,
getSessionAutomations,
} = useSessions();
const { state: sidebarState, update: updateSidebarState } =
useSidebarState(sessions, !loading);
const initialRouteRef = useRef(null);
if (!initialRouteRef.current) initialRouteRef.current = readShellRoute();
const [activeKey, setActiveKey] = useState(
initialRouteRef.current.activeKey,
);
const [view, setView] = useState(initialRouteRef.current.view);
const [settingsInitialSection, setSettingsInitialSection] =
useState(initialRouteRef.current.settingsSection);
const [hostSidebarOpen, setHostSidebarOpen] =
useState(readSidebarOpen);
const [hostSidebarPreviewOpen, setHostSidebarPreviewOpen] = useState(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [sessionSearchOpen, setSessionSearchOpen] = useState(false);
const [pendingDelete, setPendingDelete] = useState<{
key: string;
label: string;
automations?: SessionAutomationJob[];
} | null>(null);
const [pendingRename, setPendingRename] = useState<{
key: string;
label: string;
} | null>(null);
const [pendingProjectRename, setPendingProjectRename] = useState<{
key: string;
label: string;
} | null>(null);
const restartSawDisconnectRef = useRef(false);
const [restartToast, setRestartToast] = useState(null);
const [isRestarting, setIsRestarting] = useState(false);
const [pairingRequests, setPairingRequests] = useState([]);
const [pairingBusyCode, setPairingBusyCode] = useState(null);
const [pairingError, setPairingError] = useState(null);
const [snoozedPairingCodes, setSnoozedPairingCodes] = useState