nanobot/webui/src/App.tsx
2026-06-10 04:26:06 +08:00

1592 lines
51 KiB
TypeScript

import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { Moon, PanelLeft, Sun } from "lucide-react";
import { useTranslation } from "react-i18next";
import { DeleteConfirm } from "@/components/DeleteConfirm";
import { RenameChatDialog } from "@/components/RenameChatDialog";
import { Sidebar } from "@/components/Sidebar";
import { SessionSearchDialog } from "@/components/SessionSearchDialog";
import { SettingsView, 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 { ThemeProvider, useTheme } from "@/hooks/useTheme";
import { cn } from "@/lib/utils";
import {
clearSavedSecret,
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 {
ChatSummary,
RuntimeSurface,
SettingsPayload,
WorkspaceScopePayload,
WorkspacesPayload,
} from "@/lib/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { fetchSettings, fetchWorkspaces } from "@/lib/api";
import {
createRuntimeHost,
getHostApi,
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;
runtimeSurface: RuntimeSurface;
};
const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar";
const COMPLETED_RUNS_STORAGE_KEY = "nanobot-webui.sidebar.completed-runs.v1";
const RESTART_STARTED_KEY = "nanobot-webui.restartStartedAt";
const SIDEBAR_WIDTH = 272;
const SIDEBAR_RAIL_WIDTH = 56;
const TOKEN_REFRESH_MARGIN_MS = 30_000;
const TOKEN_REFRESH_MIN_DELAY_MS = 5_000;
type ShellView = "chat" | "settings" | "apps" | "skills";
type ShellRoute = {
view: ShellView;
activeKey: string | null;
settingsSection: SettingsSectionKey;
};
const SETTINGS_SECTION_KEYS: SettingsSectionKey[] = [
"overview",
"appearance",
"models",
"image",
"voice",
"browser",
"apps",
"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 === "skills") return section;
return "settings";
}
function readShellRoute(): ShellRoute {
if (typeof window === "undefined") return defaultShellRoute();
const hash = window.location.hash.startsWith("#")
? window.location.hash.slice(1)
: window.location.hash;
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 === "/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 (
<div className="flex h-full w-full items-center justify-center px-6">
<form
onSubmit={handleSubmit}
className="flex w-full max-w-sm flex-col gap-4"
>
<div className="flex flex-col items-center gap-1 text-center">
<p className="text-lg font-semibold">{t("app.auth.title")}</p>
<p className="text-sm text-muted-foreground">{t("app.auth.hint")}</p>
</div>
{failed && (
<p className="text-center text-sm text-destructive">
{t("app.auth.invalid")}
</p>
)}
<Input
type="password"
placeholder={t("app.auth.placeholder")}
value={value}
onChange={(e) => setValue(e.target.value)}
disabled={submitting}
autoFocus
/>
<Button
type="submit"
className="w-full"
disabled={!value.trim() || submitting}
>
{t("app.auth.submit")}
</Button>
</form>
</div>
);
}
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 readCompletedRunChatIds(): Set<string> {
if (typeof window === "undefined") return new Set();
try {
const raw = window.localStorage.getItem(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 writeCompletedRunChatIds(chatIds: Set<string>): void {
try {
window.localStorage.setItem(
COMPLETED_RUNS_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 HostChrome({
onToggleSidebar,
onSidebarPreviewEnter,
onSidebarPreviewLeave,
sidebarOpen = true,
rightAction,
}: {
onToggleSidebar?: () => void;
onSidebarPreviewEnter?: () => void;
onSidebarPreviewLeave?: () => void;
sidebarOpen?: boolean;
rightAction?: ReactNode;
}) {
const { t } = useTranslation();
return (
<header className="host-drag-region pointer-events-none absolute inset-x-0 top-0 z-40 h-11 bg-transparent text-foreground/90">
{onToggleSidebar ? (
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t("thread.header.toggleSidebar")}
data-testid="host-sidebar-toggle"
onClick={onToggleSidebar}
onFocus={!sidebarOpen ? onSidebarPreviewEnter : undefined}
onBlur={!sidebarOpen ? onSidebarPreviewLeave : undefined}
onMouseEnter={!sidebarOpen ? onSidebarPreviewEnter : undefined}
onMouseLeave={!sidebarOpen ? onSidebarPreviewLeave : undefined}
className="host-no-drag pointer-events-auto absolute left-[88px] top-[8px] h-7 w-7 rounded-lg bg-transparent text-muted-foreground/85 shadow-none hover:bg-transparent hover:text-foreground"
>
<PanelLeft className="h-[15px] w-[15px]" strokeWidth={1.75} />
</Button>
) : null}
{rightAction ? (
<div className="host-no-drag pointer-events-auto absolute right-3 top-2">
{rightAction}
</div>
) : null}
</header>
);
}
export default function App() {
const { t } = useTranslation();
const [state, setState] = useState<BootState>({ 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.token,
tokenExpiresAt,
modelName: boot.model_name ?? current.modelName,
runtimeSurface,
}
: current,
);
return { token: boot.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.token,
tokenExpiresAt: bootstrapTokenExpiresAt(boot.expires_in),
modelName: boot.model_name ?? null,
runtimeSurface,
});
} catch (e) {
if (cancelled) return;
const msg = (e as Error).message;
if (msg.includes("HTTP 401") || msg.includes("HTTP 403")) {
setState({ status: "auth", failed: true });
} else {
setState({ status: "error", message: msg });
}
}
})();
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) {
const msg = (e as Error).message;
if (msg.includes("HTTP 401") || msg.includes("HTTP 403")) {
setState({ status: "auth", failed: true });
}
}
}, tokenRefreshDelayMs(state.tokenExpiresAt));
return () => window.clearTimeout(timer);
}, [refreshReadyClient, state]);
useEffect(() => {
const saved = loadSavedSecret();
return bootstrapWithSecret(saved);
}, [bootstrapWithSecret]);
if (state.status === "loading") {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="flex flex-col items-center gap-3 animate-in fade-in-0 duration-300">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-foreground/40" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-foreground/60" />
</span>
{t("app.loading.connecting")}
</div>
</div>
</div>
);
}
if (state.status === "auth") {
return (
<AuthForm
failed={!!state.failed}
onSecret={(s) => bootstrapWithSecret(s)}
/>
);
}
if (state.status === "error") {
return (
<div className="flex h-full w-full items-center justify-center px-4 text-center">
<div className="flex max-w-md flex-col items-center gap-3">
<p className="text-lg font-semibold">{t("app.error.title")}</p>
<p className="text-sm text-muted-foreground">{state.message}</p>
<p className="text-xs text-muted-foreground">
{t("app.error.gatewayHint")}
</p>
</div>
</div>
);
}
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<string> => {
const hostApi = getHostApi();
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;
};
return (
<ClientProvider
client={state.client}
token={state.token}
modelName={state.modelName}
>
<Shell
runtimeSurface={state.runtimeSurface}
onModelNameChange={handleModelNameChange}
onLogout={handleLogout}
onNativeEngineRestart={handleNativeEngineRestart}
/>
</ClientProvider>
);
}
function Shell({
runtimeSurface,
onModelNameChange,
onLogout,
onNativeEngineRestart,
}: {
runtimeSurface: RuntimeSurface;
onModelNameChange: (modelName: string | null) => void;
onLogout: () => void;
onNativeEngineRestart: () => Promise<string>;
}) {
const { t, i18n } = useTranslation();
const { client, token } = useClient();
const { theme, toggle } = useTheme();
const { sessions, loading, refresh, createChat, forkChat, deleteChat } = useSessions();
const { state: sidebarState, update: updateSidebarState } =
useSidebarState(sessions, !loading);
const initialRouteRef = useRef<ShellRoute | null>(null);
if (!initialRouteRef.current) initialRouteRef.current = readShellRoute();
const [activeKey, setActiveKey] = useState<string | null>(
initialRouteRef.current.activeKey,
);
const [view, setView] = useState<ShellView>(initialRouteRef.current.view);
const [settingsInitialSection, setSettingsInitialSection] =
useState<SettingsSectionKey>(initialRouteRef.current.settingsSection);
const [hostSidebarOpen, setHostSidebarOpen] =
useState<boolean>(readSidebarOpen);
const [hostSidebarPreviewOpen, setHostSidebarPreviewOpen] = useState(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [sessionSearchOpen, setSessionSearchOpen] = useState(false);
const [pendingDelete, setPendingDelete] = useState<{
key: string;
label: string;
} | 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<string | null>(null);
const [isRestarting, setIsRestarting] = useState(false);
const [runningChatIds, setRunningChatIds] = useState<Set<string>>(() => new Set());
const [completedChatIds, setCompletedChatIds] = useState<Set<string>>(readCompletedRunChatIds);
const [workspaces, setWorkspaces] = useState<WorkspacesPayload | null>(null);
const skills = useSkills(token);
const [settingsSnapshot, setSettingsSnapshot] = useState<SettingsPayload | null>(null);
const [workspaceError, setWorkspaceError] = useState<string | null>(null);
const [draftWorkspaceScope, setDraftWorkspaceScope] =
useState<WorkspaceScopePayload | null>(null);
const [workspaceOverrides, setWorkspaceOverrides] =
useState<Record<string, WorkspaceScopePayload>>({});
const runningChatIdsRef = useRef<Set<string>>(new Set());
const activeChatIdRef = useRef<string | null>(null);
const hostSidebarPreviewCloseTimerRef = useRef<number | null>(null);
const effectiveRuntimeSurface =
settingsSnapshot?.surface ?? settingsSnapshot?.runtime_surface ?? runtimeSurface;
const showHostChrome = effectiveRuntimeSurface === "native";
const showMainSidebar = view !== "settings";
const navigate = useCallback(
(route: ShellRoute, options?: { replace?: boolean }) => {
setActiveKey(route.activeKey);
setView(route.view);
setSettingsInitialSection(route.settingsSection);
writeShellRoute(route, options?.replace);
},
[],
);
useEffect(() => {
const applyRoute = () => {
const route = readShellRoute();
setActiveKey(route.activeKey);
setView(route.view);
setSettingsInitialSection(route.settingsSection);
setWorkspaceError(null);
if (route.view === "chat" && !route.activeKey) {
setDraftWorkspaceScope(null);
}
};
window.addEventListener("hashchange", applyRoute);
return () => window.removeEventListener("hashchange", applyRoute);
}, []);
useEffect(() => {
let cancelled = false;
fetchSettings(token)
.then((payload) => {
if (!cancelled) setSettingsSnapshot(payload);
})
.catch(() => {
if (!cancelled) setSettingsSnapshot(null);
});
return () => {
cancelled = true;
};
}, [token]);
useEffect(() => {
try {
window.localStorage.setItem(
SIDEBAR_STORAGE_KEY,
hostSidebarOpen ? "1" : "0",
);
} catch {
// ignore storage errors (private mode, etc.)
}
}, [hostSidebarOpen]);
useEffect(() => {
writeCompletedRunChatIds(completedChatIds);
}, [completedChatIds]);
const activeSession = useMemo<ChatSummary | null>(() => {
if (!activeKey) return null;
return sessions.find((s) => s.key === activeKey) ?? null;
}, [sessions, activeKey]);
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
const completedChatIdList = useMemo(() => Array.from(completedChatIds), [completedChatIds]);
const activeChatId = activeSession?.chatId ?? null;
useEffect(() => {
activeChatIdRef.current = activeChatId;
if (!activeChatId) return;
setCompletedChatIds((current) => {
if (!current.has(activeChatId)) return current;
const next = new Set(current);
next.delete(activeChatId);
return next;
});
}, [activeChatId]);
const activeWorkspaceScope = useMemo<WorkspaceScopePayload | null>(() => {
if (activeChatId && workspaceOverrides[activeChatId]) {
return workspaceOverrides[activeChatId];
}
if (activeSession?.workspaceScope) {
return activeSession.workspaceScope;
}
return draftWorkspaceScope ?? workspaces?.default_scope ?? null;
}, [
activeChatId,
activeSession?.workspaceScope,
draftWorkspaceScope,
workspaceOverrides,
workspaces?.default_scope,
]);
const activeChatRunning = activeChatId ? runningChatIds.has(activeChatId) : false;
const refreshWorkspaces = useCallback(async () => {
try {
const payload = await fetchWorkspaces(token);
setWorkspaces(payload);
} catch {
setWorkspaces(null);
}
}, [token]);
useEffect(() => {
void refreshWorkspaces();
}, [refreshWorkspaces]);
useEffect(() => {
if (loading) return;
const knownChatIds = new Set(sessions.map((session) => session.chatId));
setCompletedChatIds((current) => {
const next = new Set(
Array.from(current).filter((chatId) => knownChatIds.has(chatId)),
);
return next.size === current.size ? current : next;
});
setWorkspaceOverrides((current) => {
const entries = Object.entries(current).filter(([chatId]) => knownChatIds.has(chatId));
return entries.length === Object.keys(current).length ? current : Object.fromEntries(entries);
});
}, [loading, sessions]);
useEffect(() => {
if (loading || !activeKey) return;
if (sessions.some((session) => session.key === activeKey)) return;
const currentRoute = readShellRoute();
navigate(
currentRoute.view === "chat"
? defaultShellRoute()
: {
...currentRoute,
activeKey: null,
},
{ replace: true },
);
}, [activeKey, loading, navigate, sessions]);
useEffect(() => {
return client.onSessionUpdate((_chatId, _scope, workspaceScope) => {
if (!workspaceScope) return;
const next = normalizeWorkspaceScope(workspaceScope);
setWorkspaceOverrides((current) => ({
...current,
[_chatId]: next,
}));
setDraftWorkspaceScope(next);
setWorkspaceError(null);
void refreshWorkspaces();
});
}, [client, refreshWorkspaces]);
useEffect(() => {
return client.onError((error) => {
if (error.kind !== "workspace_scope_rejected") return;
setWorkspaceError(t("errors.workspaceScopeRejected.body"));
void refreshWorkspaces();
});
}, [client, refreshWorkspaces, t]);
useEffect(() => {
if (loading) return;
const activeRunIds = sessions
.filter((session) => typeof session.runStartedAt === "number")
.map((session) => session.chatId);
if (activeRunIds.length === 0) return;
for (const chatId of activeRunIds) {
client.attach(chatId);
}
setRunningChatIds((current) => {
let changed = false;
const next = new Set(current);
for (const chatId of activeRunIds) {
if (!next.has(chatId)) changed = true;
next.add(chatId);
}
if (!changed) return current;
runningChatIdsRef.current = next;
return next;
});
setCompletedChatIds((current) => {
let changed = false;
const next = new Set(current);
for (const chatId of activeRunIds) {
if (next.delete(chatId)) changed = true;
}
return changed ? next : current;
});
}, [client, loading, sessions]);
const clearHostSidebarPreviewCloseTimer = useCallback(() => {
if (hostSidebarPreviewCloseTimerRef.current === null) return;
window.clearTimeout(hostSidebarPreviewCloseTimerRef.current);
hostSidebarPreviewCloseTimerRef.current = null;
}, []);
const closeHostSidebarPreview = useCallback(() => {
clearHostSidebarPreviewCloseTimer();
setHostSidebarPreviewOpen(false);
}, [clearHostSidebarPreviewCloseTimer]);
const openHostSidebarPreview = useCallback(() => {
if (!showHostChrome || !showMainSidebar || hostSidebarOpen) return;
clearHostSidebarPreviewCloseTimer();
setHostSidebarPreviewOpen(true);
}, [
clearHostSidebarPreviewCloseTimer,
hostSidebarOpen,
showHostChrome,
showMainSidebar,
]);
const scheduleHostSidebarPreviewClose = useCallback(() => {
clearHostSidebarPreviewCloseTimer();
if (!showHostChrome || !showMainSidebar || hostSidebarOpen) {
setHostSidebarPreviewOpen(false);
return;
}
hostSidebarPreviewCloseTimerRef.current = window.setTimeout(() => {
setHostSidebarPreviewOpen(false);
hostSidebarPreviewCloseTimerRef.current = null;
}, 160);
}, [
clearHostSidebarPreviewCloseTimer,
hostSidebarOpen,
showHostChrome,
showMainSidebar,
]);
useEffect(() => {
return () => clearHostSidebarPreviewCloseTimer();
}, [clearHostSidebarPreviewCloseTimer]);
useEffect(() => {
if (!showHostChrome || !showMainSidebar || hostSidebarOpen) {
closeHostSidebarPreview();
}
}, [
closeHostSidebarPreview,
hostSidebarOpen,
showHostChrome,
showMainSidebar,
]);
const closeHostSidebar = useCallback(() => {
closeHostSidebarPreview();
setHostSidebarOpen(false);
}, [closeHostSidebarPreview]);
const openHostSidebar = useCallback(() => {
closeHostSidebarPreview();
setHostSidebarOpen(true);
}, [closeHostSidebarPreview]);
const toggleHostSidebar = useCallback(() => {
closeHostSidebarPreview();
setHostSidebarOpen((v) => !v);
}, [closeHostSidebarPreview]);
const closeMobileSidebar = useCallback(() => {
setMobileSidebarOpen(false);
}, []);
const toggleSidebar = useCallback(() => {
const isNativeHost =
typeof window !== "undefined" &&
window.matchMedia("(min-width: 1024px)").matches;
if (isNativeHost) {
closeHostSidebarPreview();
setHostSidebarOpen((v) => !v);
} else {
setMobileSidebarOpen((v) => !v);
}
}, [closeHostSidebarPreview]);
const applyWorkspaceScope = useCallback(
(scope: WorkspaceScopePayload) => {
const next = normalizeWorkspaceScope(scope);
setWorkspaceError(null);
if (activeChatId) {
if (!activeChatRunning) {
client.setWorkspaceScope(activeChatId, next);
}
return;
}
setDraftWorkspaceScope(next);
},
[activeChatId, activeChatRunning, client],
);
const onCreateChat = useCallback(async (workspaceScope?: WorkspaceScopePayload | null) => {
try {
const scope = workspaceScope ?? activeWorkspaceScope;
const chatId = await createChat(scope);
navigate({
view: "chat",
activeKey: `websocket:${chatId}`,
settingsSection: "overview",
});
setMobileSidebarOpen(false);
if (scope) {
setWorkspaceOverrides((current) => ({
...current,
[chatId]: normalizeWorkspaceScope(scope),
}));
}
return chatId;
} catch (e) {
console.error("Failed to create chat", e);
if (e instanceof Error && e.message.startsWith("workspace_scope_rejected:")) {
setWorkspaceError(t("errors.workspaceScopeRejected.body"));
}
return null;
}
}, [activeWorkspaceScope, createChat, navigate, t]);
const onForkChat = useCallback(async (
sourceChatId: string,
beforeUserIndex: number,
) => {
try {
const sourceSession = sessions.find((session) => session.chatId === sourceChatId);
const sourceTitle = sourceSession
? displayTitle(sourceSession, sidebarState.title_overrides, t("chat.newChat"))
: t("chat.newChat");
const chatId = await forkChat(
sourceChatId,
beforeUserIndex,
t("chat.forkTitle", { title: sourceTitle }),
);
navigate({
view: "chat",
activeKey: `websocket:${chatId}`,
settingsSection: "overview",
});
setMobileSidebarOpen(false);
return chatId;
} catch (e) {
console.error("Failed to fork chat", e);
return null;
}
}, [forkChat, navigate, sessions, sidebarState.title_overrides, t]);
const onNewChat = useCallback(() => {
navigate(defaultShellRoute());
setDraftWorkspaceScope(null);
setWorkspaceError(null);
setSessionSearchOpen(false);
setMobileSidebarOpen(false);
}, [navigate]);
const onNewChatInProject = useCallback(
(projectPath: string, projectName: string) => {
const base = workspaces?.default_scope ?? activeWorkspaceScope;
const trimmed = projectPath.trim();
if (!base || !trimmed) {
onNewChat();
return;
}
navigate(defaultShellRoute());
setDraftWorkspaceScope(normalizeWorkspaceScope({
project_path: trimmed,
project_name: projectName || projectNameFromPath(trimmed),
access_mode: base.access_mode,
restrict_to_workspace: base.access_mode === "restricted",
}));
setWorkspaceError(null);
setMobileSidebarOpen(false);
},
[activeWorkspaceScope, navigate, onNewChat, workspaces?.default_scope],
);
const onSelectChat = useCallback(
(key: string) => {
const selected = sessions.find((session) => session.key === key);
const selectedChatId = selected?.chatId;
if (selectedChatId) {
setCompletedChatIds((current) => {
if (!current.has(selectedChatId)) return current;
const next = new Set(current);
next.delete(selectedChatId);
return next;
});
}
if (selected?.workspaceScope) {
setDraftWorkspaceScope(normalizeWorkspaceScope(selected.workspaceScope));
} else {
setDraftWorkspaceScope(null);
}
setWorkspaceError(null);
navigate({ view: "chat", activeKey: key, settingsSection: "overview" });
setMobileSidebarOpen(false);
},
[navigate, sessions],
);
const onTogglePin = useCallback(
(key: string) => {
void updateSidebarState((current) => {
const pinned = new Set(current.pinned_keys);
if (pinned.has(key)) {
pinned.delete(key);
} else {
pinned.add(key);
}
return {
...current,
pinned_keys: Array.from(pinned),
};
});
},
[updateSidebarState],
);
const onRequestRename = useCallback((key: string, label: string) => {
setPendingRename({ key, label });
}, []);
const onConfirmRename = useCallback(
(title: string) => {
if (!pendingRename) return;
const key = pendingRename.key;
setPendingRename(null);
void updateSidebarState((current) => {
const titleOverrides = { ...current.title_overrides };
const cleaned = title.trim();
if (cleaned) {
titleOverrides[key] = cleaned;
} else {
delete titleOverrides[key];
}
return {
...current,
title_overrides: titleOverrides,
};
});
},
[pendingRename, updateSidebarState],
);
const onToggleGroup = useCallback(
(groupId: string) => {
void updateSidebarState((current) => {
const collapsedGroups = { ...current.collapsed_groups };
if (groupId === "workspace:chats" || groupId === "date:all") {
if (collapsedGroups[groupId] === false) {
delete collapsedGroups[groupId];
} else {
collapsedGroups[groupId] = false;
}
return {
...current,
collapsed_groups: collapsedGroups,
};
}
if (collapsedGroups[groupId]) {
delete collapsedGroups[groupId];
} else {
collapsedGroups[groupId] = true;
}
return {
...current,
collapsed_groups: collapsedGroups,
};
});
},
[updateSidebarState],
);
const onRequestRenameProject = useCallback((key: string, label: string) => {
setPendingProjectRename({ key, label });
}, []);
const onConfirmProjectRename = useCallback(
(title: string) => {
if (!pendingProjectRename) return;
const key = pendingProjectRename.key;
setPendingProjectRename(null);
void updateSidebarState((current) => {
const projectNameOverrides = { ...current.project_name_overrides };
const cleaned = title.trim();
if (cleaned) {
projectNameOverrides[key] = cleaned;
} else {
delete projectNameOverrides[key];
}
return {
...current,
project_name_overrides: projectNameOverrides,
};
});
},
[pendingProjectRename, updateSidebarState],
);
const onToggleArchive = useCallback(
(key: string) => {
void updateSidebarState((current) => {
const archived = new Set(current.archived_keys);
const pinned = current.pinned_keys.filter((item) => item !== key);
if (archived.has(key)) {
archived.delete(key);
} else {
archived.add(key);
}
return {
...current,
pinned_keys: pinned,
archived_keys: Array.from(archived),
};
});
if (activeKey === key && !sidebarState.archived_keys.includes(key)) {
const archived = new Set([...sidebarState.archived_keys, key]);
const next = sessions.find((session) => !archived.has(session.key));
navigate({
view: "chat",
activeKey: next?.key ?? null,
settingsSection: "overview",
});
}
},
[activeKey, navigate, sessions, sidebarState.archived_keys, updateSidebarState],
);
const onToggleArchived = useCallback(() => {
void updateSidebarState((current) => ({
...current,
view: {
...current.view,
show_archived: !current.view.show_archived,
},
}));
}, [updateSidebarState]);
const onOpenSessionSearch = useCallback(() => {
setMobileSidebarOpen(false);
setSessionSearchOpen(true);
}, []);
useEffect(() => {
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.defaultPrevented) return;
const commandShiftO =
(event.metaKey || event.ctrlKey) && event.shiftKey && !event.altKey;
if (commandShiftO && event.key.toLowerCase() === "o") {
event.preventDefault();
onNewChat();
return;
}
const plainCommandK =
(event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey;
if (!plainCommandK) return;
if (event.key.toLowerCase() !== "k") return;
event.preventDefault();
onOpenSessionSearch();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onNewChat, onOpenSessionSearch]);
const onSelectSearchResult = useCallback(
(key: string) => {
setSessionSearchOpen(false);
onSelectChat(key);
},
[onSelectChat],
);
const onOpenSettings = useCallback((section: SettingsSectionKey = "overview") => {
setSessionSearchOpen(false);
navigate({ view: "settings", activeKey, settingsSection: section });
setMobileSidebarOpen(false);
}, [activeKey, navigate]);
const onOpenModelSettings = useCallback(() => {
onOpenSettings("models");
}, [onOpenSettings]);
const onOpenApps = useCallback(() => {
setSessionSearchOpen(false);
navigate({ view: "apps", activeKey, settingsSection: "apps" });
setMobileSidebarOpen(false);
}, [activeKey, navigate]);
const onOpenSkills = useCallback(() => {
setSessionSearchOpen(false);
navigate({ view: "skills", activeKey, settingsSection: "skills" });
setMobileSidebarOpen(false);
}, [activeKey, navigate]);
const onSettingsSectionChange = useCallback(
(section: SettingsSectionKey) => {
navigate({
view: shellViewForSettingsSection(section),
activeKey,
settingsSection: section,
});
},
[activeKey, navigate],
);
const onBackToChat = useCallback(() => {
setMobileSidebarOpen(false);
const nextKey = (() => {
if (!activeKey) return null;
if (sessions.some((session) => session.key === activeKey)) return activeKey;
return sessions[0]?.key ?? null;
})();
navigate({
view: "chat",
activeKey: nextKey,
settingsSection: "overview",
});
}, [activeKey, navigate, sessions]);
const onRestart = useCallback(() => {
const chatId = activeSession?.chatId ?? client.defaultChatId;
if (!chatId) return;
restartSawDisconnectRef.current = false;
setIsRestarting(true);
try {
window.localStorage.setItem(RESTART_STARTED_KEY, String(Date.now()));
} catch {
// ignore storage errors
}
client.sendMessage(chatId, "/restart");
}, [activeSession?.chatId, client]);
useEffect(() => {
return client.onRuntimeModelUpdate((modelName) => {
onModelNameChange(modelName);
});
}, [client, onModelNameChange]);
useEffect(() => {
return client.onRunStatus((chatId, startedAt) => {
if (startedAt != null) {
const nextRunning = new Set(runningChatIdsRef.current);
nextRunning.add(chatId);
runningChatIdsRef.current = nextRunning;
setRunningChatIds(nextRunning);
setCompletedChatIds((current) => {
if (!current.has(chatId)) return current;
const next = new Set(current);
next.delete(chatId);
return next;
});
return;
}
if (!runningChatIdsRef.current.has(chatId)) return;
const nextRunning = new Set(runningChatIdsRef.current);
nextRunning.delete(chatId);
runningChatIdsRef.current = nextRunning;
setRunningChatIds(nextRunning);
setCompletedChatIds((current) => {
const next = new Set(current);
if (activeChatIdRef.current === chatId) {
next.delete(chatId);
} else {
next.add(chatId);
}
return next;
});
});
}, [client]);
useEffect(() => {
return client.onStatus((status) => {
const startedAt = (() => {
try {
return Number(window.localStorage.getItem(RESTART_STARTED_KEY) ?? "0");
} catch {
return 0;
}
})();
if (!startedAt) return;
if (status !== "open") {
restartSawDisconnectRef.current = true;
return;
}
const elapsedMs = Date.now() - startedAt;
if (!restartSawDisconnectRef.current && elapsedMs < 1500) return;
try {
window.localStorage.removeItem(RESTART_STARTED_KEY);
} catch {
// ignore storage errors
}
setIsRestarting(false);
setRestartToast(t("app.restart.completed", { seconds: (elapsedMs / 1000).toFixed(1) }));
window.setTimeout(() => setRestartToast(null), 3_500);
});
}, [client, t]);
const onTurnEnd = useDeferredTitleRefresh(activeSession, refresh);
const onConfirmDelete = useCallback(async () => {
if (!pendingDelete) return;
const key = pendingDelete.key;
const deletingActive = activeKey === key;
const currentIndex = sessions.findIndex((s) => s.key === key);
const fallbackKey = deletingActive
? (sessions[currentIndex + 1]?.key ?? sessions[currentIndex - 1]?.key ?? null)
: activeKey;
setPendingDelete(null);
if (deletingActive) {
navigate({
view: "chat",
activeKey: fallbackKey,
settingsSection: "overview",
}, { replace: true });
}
try {
await deleteChat(key);
} catch (e) {
if (deletingActive) {
navigate({
view: "chat",
activeKey: key,
settingsSection: "overview",
}, { replace: true });
}
console.error("Failed to delete session", e);
}
}, [pendingDelete, deleteChat, activeKey, navigate, sessions]);
const headerTitle = activeSession
? sidebarState.title_overrides[activeSession.key] ||
activeSession.title ||
deriveTitle(activeSession.preview, t("chat.newChat"))
: t("app.brand");
useEffect(() => {
if (view === "settings") {
document.title = t("app.documentTitle.chat", {
title: t("settings.sidebar.title"),
});
return;
}
if (view === "apps") {
document.title = t("app.documentTitle.chat", {
title: t("settings.nav.apps", { defaultValue: "Apps" }),
});
return;
}
if (view === "skills") {
document.title = t("app.documentTitle.chat", {
title: t("settings.nav.skills", { defaultValue: "Skills" }),
});
return;
}
document.title = activeSession
? t("app.documentTitle.chat", { title: headerTitle })
: t("app.documentTitle.base");
}, [activeSession, headerTitle, i18n.resolvedLanguage, t, view]);
const sidebarProps = {
sessions,
activeKey,
loading,
onNewChat,
onSelect: onSelectChat,
onRequestDelete: (key: string, label: string) =>
setPendingDelete({ key, label }),
onTogglePin,
onRequestRename,
onToggleArchive,
onToggleGroup,
onRequestRenameProject,
onNewChatInProject,
onOpenSettings,
onOpenApps,
onOpenSkills,
onOpenSearch: onOpenSessionSearch,
activeUtility: view === "apps" || view === "skills" ? view : null,
onToggleArchived,
pinnedKeys: sidebarState.pinned_keys,
archivedKeys: sidebarState.archived_keys,
titleOverrides: sidebarState.title_overrides,
projectNameOverrides: sidebarState.project_name_overrides,
collapsedGroups: sidebarState.collapsed_groups,
runningChatIds: runningChatIdList,
completedChatIds: completedChatIdList,
viewState: sidebarState.view,
showArchived: sidebarState.view.show_archived,
archivedCount: sidebarState.archived_keys.length,
defaultWorkspacePath: workspaces?.default_scope.project_path ?? null,
};
const hostSidebarCollapsed = showHostChrome && !hostSidebarOpen;
const showHostSidebarPreview =
showMainSidebar && hostSidebarCollapsed && hostSidebarPreviewOpen;
const hostSidebarFlowWidth = showHostChrome
? (hostSidebarOpen ? SIDEBAR_WIDTH : 0)
: (hostSidebarOpen ? SIDEBAR_WIDTH : SIDEBAR_RAIL_WIDTH);
const renderHostSidebarFlowContent = !showHostChrome || hostSidebarOpen;
useEffect(() => {
document.documentElement.classList.toggle("native-host", showHostChrome);
return () => {
document.documentElement.classList.remove("native-host");
};
}, [showHostChrome]);
return (
<ThemeProvider theme={theme}>
<div
className={cn(
"relative h-full w-full overflow-hidden",
showHostChrome && "host-window-shell",
)}
>
{showHostChrome ? (
<HostChrome
onToggleSidebar={showMainSidebar ? toggleHostSidebar : undefined}
onSidebarPreviewEnter={openHostSidebarPreview}
onSidebarPreviewLeave={scheduleHostSidebarPreviewClose}
sidebarOpen={hostSidebarOpen}
rightAction={
view === "chat" ? undefined : (
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t("thread.header.toggleTheme")}
onClick={toggle}
className="h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
>
{theme === "dark" ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</Button>
)
}
/>
) : null}
<div
className={cn(
"relative flex h-full w-full overflow-hidden",
)}
>
{/* Host sidebar: in normal flow, so the thread area width stays honest. */}
{showMainSidebar ? (
<aside
data-testid="host-sidebar-flow"
className={cn(
"relative z-20 hidden shrink-0 overflow-hidden lg:block",
"transition-[width] duration-300 ease-out",
)}
style={{
width: hostSidebarFlowWidth,
}}
>
{renderHostSidebarFlowContent ? (
<div
className={cn(
"absolute inset-y-0 left-0 h-full w-full overflow-hidden",
showHostChrome
? "host-sidebar-glass"
: "bg-sidebar shadow-inner-right",
)}
>
<Sidebar
{...sidebarProps}
collapsed={!showHostChrome && !hostSidebarOpen}
hostChromeInset={showHostChrome}
onCollapse={closeHostSidebar}
onExpand={openHostSidebar}
/>
</div>
) : null}
</aside>
) : null}
{showHostSidebarPreview ? (
<aside
data-testid="host-sidebar-preview"
className="absolute inset-y-0 left-0 z-30 hidden overflow-hidden lg:block animate-in fade-in-0 slide-in-from-left-2 duration-150"
style={{ width: SIDEBAR_WIDTH }}
onMouseEnter={openHostSidebarPreview}
onMouseLeave={scheduleHostSidebarPreviewClose}
>
<div className="h-full w-full overflow-hidden host-sidebar-glass shadow-2xl">
<Sidebar
{...sidebarProps}
hostChromeInset={showHostChrome}
onCollapse={closeHostSidebar}
onExpand={openHostSidebar}
/>
</div>
</aside>
) : null}
{showMainSidebar ? (
<Sheet
open={mobileSidebarOpen}
onOpenChange={(open) => setMobileSidebarOpen(open)}
>
<SheetContent
side="left"
showCloseButton={false}
aria-describedby={undefined}
className="p-0 lg:hidden"
style={{ width: SIDEBAR_WIDTH, maxWidth: SIDEBAR_WIDTH }}
>
<SheetTitle className="sr-only">{t("sidebar.navigation")}</SheetTitle>
<Sidebar
{...sidebarProps}
onCollapse={closeMobileSidebar}
containActionMenus
/>
</SheetContent>
</Sheet>
) : null}
<SessionSearchDialog
open={sessionSearchOpen}
onOpenChange={setSessionSearchOpen}
sessions={sessions}
activeKey={activeKey}
loading={loading}
titleOverrides={sidebarState.title_overrides}
onSelect={onSelectSearchResult}
/>
<main
className={cn(
"relative flex h-full min-w-0 flex-1 flex-col overflow-hidden bg-background",
showHostChrome && hostSidebarOpen && "border-l border-border/55",
)}
>
<div
className={cn(
"absolute inset-0 flex flex-col",
view !== "chat" && "invisible pointer-events-none",
)}
>
<ThreadShell
session={activeSession}
title={headerTitle}
onToggleSidebar={toggleSidebar}
onNewChat={onNewChat}
onCreateChat={onCreateChat}
onForkChat={onForkChat}
onTurnEnd={onTurnEnd}
theme={theme}
onToggleTheme={toggle}
hideSidebarToggleForHostChrome
hostChromeTitleInset={hostSidebarCollapsed}
hideHeader={false}
workspaceScope={activeWorkspaceScope}
workspaceDefaultScope={workspaces?.default_scope ?? null}
workspaceControls={workspaces?.controls ?? null}
workspaceScopeDisabled={activeChatRunning}
workspaceError={workspaceError}
onWorkspaceScopeChange={applyWorkspaceScope}
settingsSnapshot={settingsSnapshot}
onOpenModelSettings={onOpenModelSettings}
/>
</div>
{view !== "chat" && (
<div className="absolute inset-0 flex flex-col">
<SettingsView
theme={theme}
initialSection={settingsInitialSection}
initialSettings={settingsSnapshot}
showSidebar={view === "settings"}
onToggleTheme={toggle}
onBackToChat={onBackToChat}
onModelNameChange={onModelNameChange}
onSettingsChange={setSettingsSnapshot}
skills={skills}
onWorkspaceSettingsChange={refreshWorkspaces}
onSectionChange={onSettingsSectionChange}
onLogout={onLogout}
onRestart={onRestart}
onNativeEngineRestart={onNativeEngineRestart}
isRestarting={isRestarting}
hostChromeInset={showHostChrome}
/>
</div>
)}
</main>
</div>
<DeleteConfirm
open={!!pendingDelete}
title={pendingDelete?.label ?? ""}
onCancel={() => setPendingDelete(null)}
onConfirm={onConfirmDelete}
/>
<RenameChatDialog
open={!!pendingRename}
title={pendingRename?.label ?? ""}
onCancel={() => setPendingRename(null)}
onConfirm={onConfirmRename}
/>
<RenameChatDialog
open={!!pendingProjectRename}
title={pendingProjectRename?.label ?? ""}
dialogTitle={t("chat.renameProjectTitle")}
description={t("chat.renameProjectDescription")}
placeholder={t("chat.renameProjectPlaceholder")}
onCancel={() => setPendingProjectRename(null)}
onConfirm={onConfirmProjectRename}
/>
{restartToast ? (
<div
role="status"
className="fixed left-1/2 top-4 z-50 -translate-x-1/2 rounded-full border border-border/70 bg-popover px-4 py-2 text-sm font-medium text-popover-foreground shadow-lg"
>
{restartToast}
</div>
) : null}
</div>
</ThemeProvider>
);
}