feat(desktop): polish desktop shell and shared WebUI surfaces (#4195)
* feat(desktop): add native host scaffold * feat(webui): track turns and usage in gateway * feat(webui): polish desktop chat experience * feat(apps): add ArcGIS and Joplin logos * feat(desktop): polish shell and shared surfaces * fix(webui): avoid preview chips for glob references * test: align CI expectations for token fallback * feat(webui): preview prompt rail entries * feat(webui): add prompt navigator drawer * style(webui): refine prompt navigator placement * style(webui): align prompt navigator with header actions * style(webui): simplify prompt navigator header * refactor(webui): clean thread resource refresh * feat(desktop): add native reply notifications * fix(webui): preserve desktop restart and replay state * fix(desktop): harden gateway proxy startup * fix(web): fall back when readability is unavailable * fix(desktop): hide window instead of closing on macos * fix(webui): unify desktop header actions * fix(webui): simplify prompt history rows * fix(desktop): log notification delivery failures * chore(desktop): clean source package artifacts * fix(cron): support one-time relative reminders * fix(webui): reveal scroll button in place * Revert "fix(cron): support one-time relative reminders" This reverts commit 4c4661da120a3c7283e0768412bae48604e7390b. * refactor(webui): extract token usage heatmap * docs(desktop): clarify contributor guides --------- Co-authored-by: chengyongru <2755839590@qq.com>
This commit is contained in:
+258
-119
@@ -1,5 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Menu, Moon, Sun } from "lucide-react";
|
||||
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";
|
||||
@@ -12,6 +19,7 @@ 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 {
|
||||
@@ -36,6 +44,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { fetchSettings, fetchWorkspaces } from "@/lib/api";
|
||||
import {
|
||||
createRuntimeHost,
|
||||
getHostApi,
|
||||
toRuntimeSurface,
|
||||
} from "@/lib/runtime";
|
||||
import { projectNameFromPath } from "@/lib/workspace";
|
||||
@@ -60,7 +69,7 @@ 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";
|
||||
type ShellView = "chat" | "settings" | "apps" | "skills";
|
||||
type ShellRoute = {
|
||||
view: ShellView;
|
||||
activeKey: string | null;
|
||||
@@ -74,6 +83,7 @@ const SETTINGS_SECTION_KEYS: SettingsSectionKey[] = [
|
||||
"image",
|
||||
"browser",
|
||||
"apps",
|
||||
"skills",
|
||||
"runtime",
|
||||
"advanced",
|
||||
];
|
||||
@@ -86,6 +96,11 @@ 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("#")
|
||||
@@ -102,11 +117,18 @@ function readShellRoute(): ShellRoute {
|
||||
const activeKey = params.get("chat")?.trim() || null;
|
||||
|
||||
if (path === "/settings") {
|
||||
return { view: "settings", activeKey, settingsSection };
|
||||
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 {
|
||||
@@ -264,51 +286,43 @@ function normalizeWorkspaceScope(scope: WorkspaceScopePayload): WorkspaceScopePa
|
||||
|
||||
function HostChrome({
|
||||
onToggleSidebar,
|
||||
theme,
|
||||
onToggleTheme,
|
||||
showThemeButton = true,
|
||||
onSidebarPreviewEnter,
|
||||
onSidebarPreviewLeave,
|
||||
sidebarOpen = true,
|
||||
rightAction,
|
||||
}: {
|
||||
onToggleSidebar?: () => void;
|
||||
theme: "light" | "dark";
|
||||
onToggleTheme: () => void;
|
||||
showThemeButton?: boolean;
|
||||
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 flex h-11 items-start justify-between bg-transparent px-3 pt-2 text-foreground/90">
|
||||
<div className="flex min-w-[8rem] items-center">
|
||||
{onToggleSidebar ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("thread.header.toggleSidebar")}
|
||||
onClick={onToggleSidebar}
|
||||
className="host-no-drag pointer-events-auto ml-[88px] h-8 w-8 rounded-xl text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
|
||||
>
|
||||
<Menu className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{showThemeButton ? (
|
||||
<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.toggleTheme")}
|
||||
onClick={onToggleTheme}
|
||||
className="host-no-drag pointer-events-auto h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
|
||||
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"
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<Sun className="h-4 w-4" />
|
||||
) : (
|
||||
<Moon className="h-4 w-4" />
|
||||
)}
|
||||
<PanelLeft className="h-[15px] w-[15px]" strokeWidth={1.75} />
|
||||
</Button>
|
||||
) : (
|
||||
<div aria-hidden className="host-no-drag pointer-events-none h-8 w-8" />
|
||||
)}
|
||||
) : null}
|
||||
{rightAction ? (
|
||||
<div className="host-no-drag pointer-events-auto absolute right-3 top-2">
|
||||
{rightAction}
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -318,6 +332,36 @@ export default function App() {
|
||||
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;
|
||||
@@ -335,37 +379,8 @@ export default function App() {
|
||||
socketFactory: runtimeHost.socketFactory,
|
||||
onReauth: async () => {
|
||||
try {
|
||||
const refreshed = await fetchBootstrap("", bootstrapSecretRef.current);
|
||||
const refreshedUrl = deriveWsUrl(
|
||||
refreshed.ws_path,
|
||||
refreshed.token,
|
||||
refreshed.ws_url,
|
||||
);
|
||||
const refreshedSurface = refreshed.runtime_surface
|
||||
? toRuntimeSurface(refreshed.runtime_surface)
|
||||
: runtimeSurface;
|
||||
const refreshedHost = createRuntimeHost(
|
||||
refreshedSurface,
|
||||
refreshed.runtime_capabilities,
|
||||
);
|
||||
const tokenExpiresAt = bootstrapTokenExpiresAt(refreshed.expires_in);
|
||||
if (refreshedHost.socketFactory) {
|
||||
client.updateUrl(refreshedUrl, refreshedHost.socketFactory);
|
||||
} else {
|
||||
client.updateUrl(refreshedUrl);
|
||||
}
|
||||
setState((current) =>
|
||||
current.status === "ready" && current.client === client
|
||||
? {
|
||||
...current,
|
||||
token: refreshed.token,
|
||||
tokenExpiresAt,
|
||||
modelName: refreshed.model_name ?? current.modelName,
|
||||
runtimeSurface: refreshedSurface,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
return refreshedUrl;
|
||||
const refreshed = await refreshReadyClient(client, runtimeSurface);
|
||||
return refreshed.url;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -395,7 +410,7 @@ export default function App() {
|
||||
cancelled = true;
|
||||
};
|
||||
},
|
||||
[],
|
||||
[refreshReadyClient],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -403,29 +418,7 @@ export default function App() {
|
||||
const client = state.client;
|
||||
const timer = window.setTimeout(async () => {
|
||||
try {
|
||||
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)
|
||||
: state.runtimeSurface;
|
||||
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,
|
||||
);
|
||||
await refreshReadyClient(client, state.runtimeSurface);
|
||||
} catch (e) {
|
||||
const msg = (e as Error).message;
|
||||
if (msg.includes("HTTP 401") || msg.includes("HTTP 403")) {
|
||||
@@ -434,7 +427,7 @@ export default function App() {
|
||||
}
|
||||
}, tokenRefreshDelayMs(state.tokenExpiresAt));
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [state]);
|
||||
}, [refreshReadyClient, state]);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = loadSavedSecret();
|
||||
@@ -492,6 +485,16 @@ export default function App() {
|
||||
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}
|
||||
@@ -502,6 +505,7 @@ export default function App() {
|
||||
runtimeSurface={state.runtimeSurface}
|
||||
onModelNameChange={handleModelNameChange}
|
||||
onLogout={handleLogout}
|
||||
onNativeEngineRestart={handleNativeEngineRestart}
|
||||
/>
|
||||
</ClientProvider>
|
||||
);
|
||||
@@ -511,10 +515,12 @@ 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();
|
||||
@@ -532,6 +538,7 @@ function Shell({
|
||||
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<{
|
||||
@@ -552,6 +559,7 @@ function Shell({
|
||||
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] =
|
||||
@@ -560,6 +568,11 @@ function Shell({
|
||||
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 }) => {
|
||||
@@ -745,13 +758,74 @@ function Shell({
|
||||
});
|
||||
}, [client, loading, sessions]);
|
||||
|
||||
const closeHostSidebar = useCallback(() => {
|
||||
setHostSidebarOpen(false);
|
||||
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);
|
||||
@@ -762,11 +836,12 @@ function Shell({
|
||||
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) => {
|
||||
@@ -1041,16 +1116,26 @@ function Shell({
|
||||
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: section === "apps" ? "apps" : "settings",
|
||||
view: shellViewForSettingsSection(section),
|
||||
activeKey,
|
||||
settingsSection: section,
|
||||
});
|
||||
@@ -1202,6 +1287,12 @@ function Shell({
|
||||
});
|
||||
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");
|
||||
@@ -1223,8 +1314,9 @@ function Shell({
|
||||
onNewChatInProject,
|
||||
onOpenSettings,
|
||||
onOpenApps,
|
||||
onOpenSkills,
|
||||
onOpenSearch: onOpenSessionSearch,
|
||||
activeUtility: view === "apps" ? "apps" as const : null,
|
||||
activeUtility: view === "apps" || view === "skills" ? view : null,
|
||||
onToggleArchived,
|
||||
pinnedKeys: sidebarState.pinned_keys,
|
||||
archivedKeys: sidebarState.archived_keys,
|
||||
@@ -1238,11 +1330,13 @@ function Shell({
|
||||
archivedCount: sidebarState.archived_keys.length,
|
||||
defaultWorkspacePath: workspaces?.default_scope.project_path ?? null,
|
||||
};
|
||||
const effectiveRuntimeSurface =
|
||||
settingsSnapshot?.surface ?? settingsSnapshot?.runtime_surface ?? runtimeSurface;
|
||||
const isNativeHostSetupSurface = effectiveRuntimeSurface === "native";
|
||||
const showHostChrome = isNativeHostSetupSurface;
|
||||
const showMainSidebar = view !== "settings";
|
||||
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);
|
||||
@@ -1261,9 +1355,28 @@ function Shell({
|
||||
>
|
||||
{showHostChrome ? (
|
||||
<HostChrome
|
||||
onToggleSidebar={showMainSidebar ? toggleSidebar : undefined}
|
||||
theme={theme}
|
||||
onToggleTheme={toggle}
|
||||
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
|
||||
@@ -1274,25 +1387,47 @@ function Shell({
|
||||
{/* 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: hostSidebarOpen ? SIDEBAR_WIDTH : SIDEBAR_RAIL_WIDTH,
|
||||
width: hostSidebarFlowWidth,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-y-0 left-0 h-full w-full overflow-hidden",
|
||||
showHostChrome
|
||||
? "host-sidebar-glass"
|
||||
: "bg-sidebar shadow-inner-right",
|
||||
)}
|
||||
>
|
||||
{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}
|
||||
collapsed={!hostSidebarOpen}
|
||||
hostChromeInset={showHostChrome}
|
||||
onCollapse={closeHostSidebar}
|
||||
onExpand={openHostSidebar}
|
||||
@@ -1335,7 +1470,7 @@ function Shell({
|
||||
<main
|
||||
className={cn(
|
||||
"relative flex h-full min-w-0 flex-1 flex-col overflow-hidden bg-background",
|
||||
showHostChrome && "border-l border-border/55",
|
||||
showHostChrome && hostSidebarOpen && "border-l border-border/55",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
@@ -1354,7 +1489,7 @@ function Shell({
|
||||
theme={theme}
|
||||
onToggleTheme={toggle}
|
||||
hideSidebarToggleForHostChrome
|
||||
hideThemeButton={showHostChrome}
|
||||
hostChromeTitleInset={hostSidebarCollapsed}
|
||||
hideHeader={false}
|
||||
workspaceScope={activeWorkspaceScope}
|
||||
workspaceDefaultScope={workspaces?.default_scope ?? null}
|
||||
@@ -1363,6 +1498,7 @@ function Shell({
|
||||
workspaceError={workspaceError}
|
||||
onWorkspaceScopeChange={applyWorkspaceScope}
|
||||
settingsSnapshot={settingsSnapshot}
|
||||
onOpenModelSettings={onOpenModelSettings}
|
||||
/>
|
||||
</div>
|
||||
{view !== "chat" && (
|
||||
@@ -1370,15 +1506,18 @@ function Shell({
|
||||
<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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user