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 { useProviderActions } from "@/hooks/useProviderActions";
import { openclawKeys } from "@/hooks/useOpenClaw"; import { openclawKeys } from "@/hooks/useOpenClaw";
import { useProxyStatus } from "@/hooks/useProxyStatus"; import { useProxyStatus } from "@/hooks/useProxyStatus";
import { useAutoCompact } from "@/hooks/useAutoCompact";
import { useLastValidValue } from "@/hooks/useLastValidValue"; import { useLastValidValue } from "@/hooks/useLastValidValue";
import { extractErrorMessage } from "@/utils/errorUtils"; import { extractErrorMessage } from "@/utils/errorUtils";
import { isTextEditableTarget } from "@/utils/domUtils"; import { isTextEditableTarget } from "@/utils/domUtils";
@@ -200,6 +201,9 @@ function App() {
const effectiveEditingProvider = useLastValidValue(editingProvider); const effectiveEditingProvider = useLastValidValue(editingProvider);
const effectiveUsageProvider = useLastValidValue(usageProvider); const effectiveUsageProvider = useLastValidValue(usageProvider);
const toolbarRef = useRef<HTMLDivElement>(null);
const isToolbarCompact = useAutoCompact(toolbarRef);
const promptPanelRef = useRef<any>(null); const promptPanelRef = useRef<any>(null);
const mcpPanelRef = useRef<any>(null); const mcpPanelRef = useRef<any>(null);
const skillsPageRef = useRef<any>(null); const skillsPageRef = useRef<any>(null);
@@ -961,250 +965,252 @@ function App() {
</div> </div>
<div <div
className="flex items-center gap-1.5 h-[32px]" ref={toolbarRef}
style={{ WebkitAppRegion: "no-drag" } as any} className="flex flex-1 min-w-0 overflow-hidden justify-end items-center h-[32px]"
> >
{currentView === "prompts" && ( <div
<Button className="flex shrink-0 items-center gap-1.5 h-full"
variant="ghost" style={{ WebkitAppRegion: "no-drag" } as any}
size="sm" >
onClick={() => promptPanelRef.current?.openAdd()} {currentView === "prompts" && (
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Plus className="w-4 h-4 mr-2" />
{t("prompts.add")}
</Button>
)}
{currentView === "mcp" && (
<>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => mcpPanelRef.current?.openImport()} onClick={() => promptPanelRef.current?.openAdd()}
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" className="hover:bg-black/5 dark:hover:bg-white/5"
> >
<Plus className="w-4 h-4 mr-2" /> <Plus className="w-4 h-4 mr-2" />
{t("mcp.addMcp")} {t("prompts.add")}
</Button> </Button>
</> )}
)} {currentView === "mcp" && (
{currentView === "skills" && ( <>
<> <Button
<Button variant="ghost"
variant="ghost" size="sm"
size="sm" onClick={() => mcpPanelRef.current?.openImport()}
onClick={() => className="hover:bg-black/5 dark:hover:bg-white/5"
unifiedSkillsPanelRef.current?.openInstallFromZip() >
} <Download className="w-4 h-4 mr-2" />
className="hover:bg-black/5 dark:hover:bg-white/5" {t("mcp.importExisting")}
> </Button>
<FolderArchive className="w-4 h-4 mr-2" /> <Button
{t("skills.installFromZip.button")} variant="ghost"
</Button> size="sm"
<Button onClick={() => mcpPanelRef.current?.openAdd()}
variant="ghost" className="hover:bg-black/5 dark:hover:bg-white/5"
size="sm" >
onClick={() => unifiedSkillsPanelRef.current?.openImport()} <Plus className="w-4 h-4 mr-2" />
className="hover:bg-black/5 dark:hover:bg-white/5" {t("mcp.addMcp")}
> </Button>
<Download className="w-4 h-4 mr-2" /> </>
{t("skills.import")} )}
</Button> {currentView === "skills" && (
<Button <>
variant="ghost" <Button
size="sm" variant="ghost"
onClick={() => setCurrentView("skillsDiscovery")} size="sm"
className="hover:bg-black/5 dark:hover:bg-white/5" onClick={() =>
> unifiedSkillsPanelRef.current?.openInstallFromZip()
<Search className="w-4 h-4 mr-2" /> }
{t("skills.discover")} className="hover:bg-black/5 dark:hover:bg-white/5"
</Button> >
</> <FolderArchive className="w-4 h-4 mr-2" />
)} {t("skills.installFromZip.button")}
{currentView === "skillsDiscovery" && ( </Button>
<> <Button
<Button variant="ghost"
variant="ghost" size="sm"
size="sm" onClick={() => unifiedSkillsPanelRef.current?.openImport()}
onClick={() => skillsPageRef.current?.refresh()} className="hover:bg-black/5 dark:hover:bg-white/5"
className="hover:bg-black/5 dark:hover:bg-white/5" >
> <Download className="w-4 h-4 mr-2" />
<RefreshCw className="w-4 h-4 mr-2" /> {t("skills.import")}
{t("skills.refresh")} </Button>
</Button> <Button
<Button variant="ghost"
variant="ghost" size="sm"
size="sm" onClick={() => setCurrentView("skillsDiscovery")}
onClick={() => skillsPageRef.current?.openRepoManager()} className="hover:bg-black/5 dark:hover:bg-white/5"
className="hover:bg-black/5 dark:hover:bg-white/5" >
> <Search className="w-4 h-4 mr-2" />
<Settings className="w-4 h-4 mr-2" /> {t("skills.discover")}
{t("skills.repoManager")} </Button>
</Button> </>
</> )}
)} {currentView === "skillsDiscovery" && (
{currentView === "providers" && ( <>
<> <Button
{activeApp !== "opencode" && variant="ghost"
activeApp !== "openclaw" && size="sm"
settingsData?.enableLocalProxy && ( onClick={() => skillsPageRef.current?.refresh()}
<> className="hover:bg-black/5 dark:hover:bg-white/5"
<ProxyToggle activeApp={activeApp} /> >
<div <RefreshCw className="w-4 h-4 mr-2" />
className={cn( {t("skills.refresh")}
"transition-all duration-300 ease-in-out overflow-hidden", </Button>
isCurrentAppTakeoverActive <Button
? "opacity-100 max-w-[100px] scale-100" variant="ghost"
: "opacity-0 max-w-0 scale-75 pointer-events-none", 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} /> {activeApp === "openclaw" ? (
</div> <>
</> <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 <Button
activeApp={activeApp} onClick={() => setIsAddOpen(true)}
onSwitch={setActiveApp} size="icon"
visibleApps={visibleApps} className={`ml-2 ${addActionButtonClass}`}
compact={ >
isCurrentAppTakeoverActive && <Plus className="w-5 h-5" />
Object.values(visibleApps).filter(Boolean).length >= 4 </Button>
} </>
/> )}
</div>
<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>
</>
)}
</div> </div>
</div> </div>
</header> </header>
+5 -1
View File
@@ -63,7 +63,11 @@ export function AppSwitcher({
name={appDisplayName[app]} name={appDisplayName[app]}
size={iconSize} size={iconSize}
/> />
{!compact && <span>{appDisplayName[app]}</span>} {!compact && (
<span className="transition-all duration-200 whitespace-nowrap">
{appDisplayName[app]}
</span>
)}
</button> </button>
))} ))}
</div> </div>
+43
View File
@@ -0,0 +1,43 @@
import { useEffect, useRef, useState, type RefObject } from "react";
/**
* Detects whether the container's children overflow the available width
* and returns a `compact` flag for the AppSwitcher.
*
* Uses ResizeObserver on a flex-constrained container. The container
* must have `flex-1 min-w-0 overflow-hidden` so its width is determined
* by the parent layout, not its own content — avoiding the oscillation
* problem when toggling compact mode.
*/
export function useAutoCompact(
containerRef: RefObject<HTMLDivElement | null>,
): boolean {
const [compact, setCompact] = useState(false);
const normalWidthRef = useRef(0);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver(() => {
if (!compact) {
// Cache the total content width in normal mode
normalWidthRef.current = el.scrollWidth;
// Overflow detected → switch to compact
if (el.scrollWidth > el.clientWidth + 1) {
setCompact(true);
}
} else if (normalWidthRef.current > 0) {
// In compact mode: only recover to normal if
// available space >= what normal mode needed
if (el.clientWidth >= normalWidthRef.current) {
setCompact(false);
}
}
});
ro.observe(el);
return () => ro.disconnect();
}, [compact]);
return compact;
}