Files
CC-Switch/src/components/AppSwitcher.tsx
T
Jason f5f4281d06 refactor(ui): always show icon-only app switcher, drop auto-compact
The toolbar app switcher used a ResizeObserver-based overflow detection
(useAutoCompact) to collapse app labels when space ran out. With the
number of managed apps growing, the labels are collapsed in practice
anyway, so remove the mechanism and render icons only. Buttons now carry
title/aria-label so app names remain discoverable via tooltip and
accessible to screen readers.
2026-07-30 08:52:48 +08:00

124 lines
3.6 KiB
TypeScript

import type { AppId } from "@/lib/api";
import type { VisibleApps } from "@/types";
import { ProviderIcon } from "@/components/ProviderIcon";
import { cn } from "@/lib/utils";
import { Monitor, Terminal } from "lucide-react";
const APP_BADGE_ICON: Partial<
Record<AppId, { icon: typeof Terminal; offsetY?: number }>
> = {
claude: { icon: Terminal },
"claude-desktop": { icon: Monitor, offsetY: 0.5 },
};
interface AppSwitcherProps {
activeApp: AppId;
onSwitch: (app: AppId) => void;
visibleApps?: VisibleApps;
}
const ALL_APPS: AppId[] = [
"claude",
"claude-desktop",
"codex",
"gemini",
"grokbuild",
"opencode",
"openclaw",
"hermes",
];
const STORAGE_KEY = "cc-switch-last-app";
export function AppSwitcher({
activeApp,
onSwitch,
visibleApps,
}: AppSwitcherProps) {
const handleSwitch = (app: AppId) => {
if (app === activeApp) return;
localStorage.setItem(STORAGE_KEY, app);
onSwitch(app);
};
const iconSize = 20;
const appIconName: Record<AppId, string> = {
claude: "claude",
"claude-desktop": "claude",
codex: "openai",
gemini: "gemini",
grokbuild: "grok",
opencode: "opencode",
openclaw: "openclaw",
hermes: "hermes",
};
const appDisplayName: Record<AppId, string> = {
claude: "Claude Code",
"claude-desktop": "Claude Desktop",
codex: "Codex",
gemini: "Gemini",
grokbuild: "Grok Build",
opencode: "OpenCode",
openclaw: "OpenClaw",
hermes: "Hermes",
};
// Filter apps based on visibility settings (default all visible)
const appsToShow = ALL_APPS.filter((app) => {
if (!visibleApps) return true;
return visibleApps[app];
});
return (
<div className="inline-flex bg-muted rounded-xl p-1 gap-1">
{appsToShow.map((app) => {
const badgeConfig = APP_BADGE_ICON[app];
const BadgeIcon = badgeConfig?.icon;
const isActive = activeApp === app;
return (
<button
key={app}
type="button"
onClick={() => handleSwitch(app)}
title={appDisplayName[app]}
aria-label={appDisplayName[app]}
className={cn(
"group inline-flex items-center px-3 h-8 rounded-md text-sm font-medium transition-all duration-200",
isActive
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/50",
)}
>
<span className="relative inline-flex shrink-0">
<ProviderIcon
icon={appIconName[app]}
name={appDisplayName[app]}
size={iconSize}
/>
{BadgeIcon && (
<span
className={cn(
"absolute -bottom-0.5 -right-0.5 flex items-center justify-center rounded-[3px] border h-[11px] w-[11px]",
isActive
? "bg-background border-border text-foreground"
: "bg-muted border-background text-muted-foreground group-hover:bg-background group-hover:text-foreground",
)}
aria-hidden="true"
>
<BadgeIcon
className="h-[8px] w-[8px]"
strokeWidth={2.5}
style={
badgeConfig?.offsetY
? { transform: `translateY(${badgeConfig.offsetY}px)` }
: undefined
}
/>
</span>
)}
</span>
</button>
);
})}
</div>
);
}