feat(toolbar): auto-compact AppSwitcher based on available width

Replace hardcoded app count threshold with ResizeObserver-based
detection. Uses a two-layer layout (overflow-hidden outer + shrink-0
inner) to avoid the compact/normal oscillation problem.
This commit is contained in:
Jason
2026-02-21 20:24:49 +08:00
parent 2e676e5f53
commit ce92f37ef0
3 changed files with 288 additions and 235 deletions
+240 -234
View File
@@ -35,6 +35,7 @@ import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env";
import { useProviderActions } from "@/hooks/useProviderActions";
import { openclawKeys } from "@/hooks/useOpenClaw";
import { useProxyStatus } from "@/hooks/useProxyStatus";
import { useAutoCompact } from "@/hooks/useAutoCompact";
import { useLastValidValue } from "@/hooks/useLastValidValue";
import { extractErrorMessage } from "@/utils/errorUtils";
import { isTextEditableTarget } from "@/utils/domUtils";
@@ -200,6 +201,9 @@ function App() {
const effectiveEditingProvider = useLastValidValue(editingProvider);
const effectiveUsageProvider = useLastValidValue(usageProvider);
const toolbarRef = useRef<HTMLDivElement>(null);
const isToolbarCompact = useAutoCompact(toolbarRef);
const promptPanelRef = useRef<any>(null);
const mcpPanelRef = useRef<any>(null);
const skillsPageRef = useRef<any>(null);
@@ -961,250 +965,252 @@ function App() {
</div>
<div
className="flex items-center gap-1.5 h-[32px]"
style={{ WebkitAppRegion: "no-drag" } as any}
ref={toolbarRef}
className="flex flex-1 min-w-0 overflow-hidden justify-end items-center h-[32px]"
>
{currentView === "prompts" && (
<Button
variant="ghost"
size="sm"
onClick={() => promptPanelRef.current?.openAdd()}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Plus className="w-4 h-4 mr-2" />
{t("prompts.add")}
</Button>
)}
{currentView === "mcp" && (
<>
<div
className="flex shrink-0 items-center gap-1.5 h-full"
style={{ WebkitAppRegion: "no-drag" } as any}
>
{currentView === "prompts" && (
<Button
variant="ghost"
size="sm"
onClick={() => mcpPanelRef.current?.openImport()}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Download className="w-4 h-4 mr-2" />
{t("mcp.importExisting")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => mcpPanelRef.current?.openAdd()}
onClick={() => promptPanelRef.current?.openAdd()}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Plus className="w-4 h-4 mr-2" />
{t("mcp.addMcp")}
{t("prompts.add")}
</Button>
</>
)}
{currentView === "skills" && (
<>
<Button
variant="ghost"
size="sm"
onClick={() =>
unifiedSkillsPanelRef.current?.openInstallFromZip()
}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<FolderArchive className="w-4 h-4 mr-2" />
{t("skills.installFromZip.button")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => unifiedSkillsPanelRef.current?.openImport()}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Download className="w-4 h-4 mr-2" />
{t("skills.import")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("skillsDiscovery")}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Search className="w-4 h-4 mr-2" />
{t("skills.discover")}
</Button>
</>
)}
{currentView === "skillsDiscovery" && (
<>
<Button
variant="ghost"
size="sm"
onClick={() => skillsPageRef.current?.refresh()}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<RefreshCw className="w-4 h-4 mr-2" />
{t("skills.refresh")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => skillsPageRef.current?.openRepoManager()}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Settings className="w-4 h-4 mr-2" />
{t("skills.repoManager")}
</Button>
</>
)}
{currentView === "providers" && (
<>
{activeApp !== "opencode" &&
activeApp !== "openclaw" &&
settingsData?.enableLocalProxy && (
<>
<ProxyToggle activeApp={activeApp} />
<div
className={cn(
"transition-all duration-300 ease-in-out overflow-hidden",
isCurrentAppTakeoverActive
? "opacity-100 max-w-[100px] scale-100"
: "opacity-0 max-w-0 scale-75 pointer-events-none",
)}
)}
{currentView === "mcp" && (
<>
<Button
variant="ghost"
size="sm"
onClick={() => mcpPanelRef.current?.openImport()}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Download className="w-4 h-4 mr-2" />
{t("mcp.importExisting")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => mcpPanelRef.current?.openAdd()}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Plus className="w-4 h-4 mr-2" />
{t("mcp.addMcp")}
</Button>
</>
)}
{currentView === "skills" && (
<>
<Button
variant="ghost"
size="sm"
onClick={() =>
unifiedSkillsPanelRef.current?.openInstallFromZip()
}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<FolderArchive className="w-4 h-4 mr-2" />
{t("skills.installFromZip.button")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => unifiedSkillsPanelRef.current?.openImport()}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Download className="w-4 h-4 mr-2" />
{t("skills.import")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("skillsDiscovery")}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Search className="w-4 h-4 mr-2" />
{t("skills.discover")}
</Button>
</>
)}
{currentView === "skillsDiscovery" && (
<>
<Button
variant="ghost"
size="sm"
onClick={() => skillsPageRef.current?.refresh()}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<RefreshCw className="w-4 h-4 mr-2" />
{t("skills.refresh")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => skillsPageRef.current?.openRepoManager()}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Settings className="w-4 h-4 mr-2" />
{t("skills.repoManager")}
</Button>
</>
)}
{currentView === "providers" && (
<>
{activeApp !== "opencode" &&
activeApp !== "openclaw" &&
settingsData?.enableLocalProxy && (
<>
<ProxyToggle activeApp={activeApp} />
<div
className={cn(
"transition-all duration-300 ease-in-out overflow-hidden",
isCurrentAppTakeoverActive
? "opacity-100 max-w-[100px] scale-100"
: "opacity-0 max-w-0 scale-75 pointer-events-none",
)}
>
<FailoverToggle activeApp={activeApp} />
</div>
</>
)}
<AppSwitcher
activeApp={activeApp}
onSwitch={setActiveApp}
visibleApps={visibleApps}
compact={isToolbarCompact}
/>
<div className="flex items-center gap-1 p-1 bg-muted rounded-xl">
<AnimatePresence mode="wait">
<motion.div
key={activeApp === "openclaw" ? "openclaw" : "default"}
className="flex items-center gap-1"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<FailoverToggle activeApp={activeApp} />
</div>
</>
)}
{activeApp === "openclaw" ? (
<>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("workspace")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("workspace.manage")}
>
<FolderOpen className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("openclawEnv")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("openclaw.env.title")}
>
<KeyRound className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("openclawTools")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("openclaw.tools.title")}
>
<Shield className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("openclawAgents")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("openclaw.agents.title")}
>
<Cpu className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("sessions")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("sessionManager.title")}
>
<History className="w-4 h-4" />
</Button>
</>
) : (
<>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("skills")}
className={cn(
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5",
"transition-all duration-200 ease-in-out overflow-hidden",
hasSkillsSupport
? "opacity-100 w-8 scale-100 px-2"
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
)}
title={t("skills.manage")}
>
<Wrench className="flex-shrink-0 w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("prompts")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("prompts.manage")}
>
<Book className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("sessions")}
className={cn(
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5",
"transition-all duration-200 ease-in-out overflow-hidden",
hasSessionSupport
? "opacity-100 w-8 scale-100 px-2"
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
)}
title={t("sessionManager.title")}
>
<History className="flex-shrink-0 w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("mcp")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("mcp.title")}
>
<McpIcon size={16} />
</Button>
</>
)}
</motion.div>
</AnimatePresence>
</div>
<AppSwitcher
activeApp={activeApp}
onSwitch={setActiveApp}
visibleApps={visibleApps}
compact={
isCurrentAppTakeoverActive &&
Object.values(visibleApps).filter(Boolean).length >= 4
}
/>
<div className="flex items-center gap-1 p-1 bg-muted rounded-xl">
<AnimatePresence mode="wait">
<motion.div
key={activeApp === "openclaw" ? "openclaw" : "default"}
className="flex items-center gap-1"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
{activeApp === "openclaw" ? (
<>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("workspace")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("workspace.manage")}
>
<FolderOpen className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("openclawEnv")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("openclaw.env.title")}
>
<KeyRound className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("openclawTools")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("openclaw.tools.title")}
>
<Shield className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("openclawAgents")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("openclaw.agents.title")}
>
<Cpu className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("sessions")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("sessionManager.title")}
>
<History className="w-4 h-4" />
</Button>
</>
) : (
<>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("skills")}
className={cn(
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5",
"transition-all duration-200 ease-in-out overflow-hidden",
hasSkillsSupport
? "opacity-100 w-8 scale-100 px-2"
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
)}
title={t("skills.manage")}
>
<Wrench className="flex-shrink-0 w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("prompts")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("prompts.manage")}
>
<Book className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("sessions")}
className={cn(
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5",
"transition-all duration-200 ease-in-out overflow-hidden",
hasSessionSupport
? "opacity-100 w-8 scale-100 px-2"
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
)}
title={t("sessionManager.title")}
>
<History className="flex-shrink-0 w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("mcp")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("mcp.title")}
>
<McpIcon size={16} />
</Button>
</>
)}
</motion.div>
</AnimatePresence>
</div>
<Button
onClick={() => setIsAddOpen(true)}
size="icon"
className={`ml-2 ${addActionButtonClass}`}
>
<Plus className="w-5 h-5" />
</Button>
</>
)}
<Button
onClick={() => setIsAddOpen(true)}
size="icon"
className={`ml-2 ${addActionButtonClass}`}
>
<Plus className="w-5 h-5" />
</Button>
</>
)}
</div>
</div>
</div>
</header>