mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-29 02:01:50 +08:00
fix(ui): keep header actions visible when all apps are enabled
With every app tab, the profile switcher and the takeover toggles shown, the header overflowed and clipped the add-provider button. Restructure the header right side so primary actions live in a fixed shrink-0 cluster, and make AppSwitcher width-aware: apps that no longer fit collapse into a "more" popover, with the active app always kept visible.
This commit is contained in:
+14
-8
@@ -1308,9 +1308,21 @@ function App() {
|
||||
<ProfileSwitcher activeApp={activeApp} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-1 min-w-0 overflow-x-hidden items-center py-4">
|
||||
{/* 弹性中段:空间不足时由 AppSwitcher 自行收纳溢出应用;
|
||||
justify-end + overflow-hidden 只裁剪 resize 瞬间的过渡帧 */}
|
||||
<div className="flex flex-1 min-w-0 items-center justify-end overflow-hidden py-4">
|
||||
{currentView === "providers" && (
|
||||
<AppSwitcher
|
||||
activeApp={activeApp}
|
||||
onSwitch={setActiveApp}
|
||||
visibleApps={visibleApps}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* 固定右端:主操作(添加供应商等)shrink-0,任何配置下不被挤出 */}
|
||||
<div className="flex shrink-0 items-center py-4">
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-1.5 ml-auto"
|
||||
className="flex shrink-0 items-center gap-1.5"
|
||||
style={{ WebkitAppRegion: "no-drag" } as any}
|
||||
>
|
||||
{currentView === "prompts" && (
|
||||
@@ -1457,12 +1469,6 @@ function App() {
|
||||
)}
|
||||
{currentView === "providers" && (
|
||||
<>
|
||||
<AppSwitcher
|
||||
activeApp={activeApp}
|
||||
onSwitch={setActiveApp}
|
||||
visibleApps={visibleApps}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-1 p-1 bg-muted rounded-xl">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
|
||||
+166
-56
@@ -1,8 +1,15 @@
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AppId } from "@/lib/api";
|
||||
import type { VisibleApps } from "@/types";
|
||||
import { ProviderIcon } from "@/components/ProviderIcon";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Monitor, Terminal } from "lucide-react";
|
||||
import { Monitor, MoreHorizontal, Terminal } from "lucide-react";
|
||||
|
||||
const APP_BADGE_ICON: Partial<
|
||||
Record<AppId, { icon: typeof Terminal; offsetY?: number }>
|
||||
@@ -29,57 +36,147 @@ const ALL_APPS: AppId[] = [
|
||||
];
|
||||
const STORAGE_KEY = "cc-switch-last-app";
|
||||
|
||||
const APP_ICON_NAME: Record<AppId, string> = {
|
||||
claude: "claude",
|
||||
"claude-desktop": "claude",
|
||||
codex: "openai",
|
||||
gemini: "gemini",
|
||||
grokbuild: "grok",
|
||||
opencode: "opencode",
|
||||
openclaw: "openclaw",
|
||||
hermes: "hermes",
|
||||
};
|
||||
|
||||
const APP_DISPLAY_NAME: Record<AppId, string> = {
|
||||
claude: "Claude Code",
|
||||
"claude-desktop": "Claude Desktop",
|
||||
codex: "Codex",
|
||||
gemini: "Gemini",
|
||||
grokbuild: "Grok Build",
|
||||
opencode: "OpenCode",
|
||||
openclaw: "OpenClaw",
|
||||
hermes: "Hermes",
|
||||
};
|
||||
|
||||
/** 应用图标 + 角标(Claude Code / Desktop 用角标区分终端与桌面) */
|
||||
function AppGlyph({ app, isActive }: { app: AppId; isActive: boolean }) {
|
||||
const badgeConfig = APP_BADGE_ICON[app];
|
||||
const BadgeIcon = badgeConfig?.icon;
|
||||
return (
|
||||
<span className="relative inline-flex shrink-0">
|
||||
<ProviderIcon
|
||||
icon={APP_ICON_NAME[app]}
|
||||
name={APP_DISPLAY_NAME[app]}
|
||||
size={20}
|
||||
/>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppSwitcher({
|
||||
activeApp,
|
||||
onSwitch,
|
||||
visibleApps,
|
||||
}: AppSwitcherProps) {
|
||||
const { t } = useTranslation();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
|
||||
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];
|
||||
});
|
||||
const appCount = appsToShow.length;
|
||||
|
||||
const [visibleCount, setVisibleCount] = useState(appCount);
|
||||
|
||||
// 宽度必须取父弹性槽而非自身:自身宽度随可见数量变化,
|
||||
// 用它做输入会形成收起→变窄→再收起的反馈循环
|
||||
useLayoutEffect(() => {
|
||||
const root = rootRef.current;
|
||||
const slot = root?.parentElement;
|
||||
if (!root || !slot) return;
|
||||
|
||||
const compute = () => {
|
||||
const sample = root.querySelector("button");
|
||||
if (!sample) return;
|
||||
const itemWidth = sample.offsetWidth;
|
||||
// jsdom 或未完成布局时 offsetWidth 为 0,保持全部可见
|
||||
if (itemWidth <= 0) return;
|
||||
const rootStyle = window.getComputedStyle(root);
|
||||
const gap = parseFloat(rootStyle.columnGap) || 0;
|
||||
const padding =
|
||||
(parseFloat(rootStyle.paddingLeft) || 0) +
|
||||
(parseFloat(rootStyle.paddingRight) || 0);
|
||||
const available = slot.clientWidth;
|
||||
const widthAll = padding + appCount * itemWidth + (appCount - 1) * gap;
|
||||
if (widthAll <= available) {
|
||||
setVisibleCount(appCount);
|
||||
return;
|
||||
}
|
||||
// 「更多」按钮与应用按钮同宽(同 padding + 同尺寸图标)
|
||||
const fit = Math.floor(
|
||||
(available - padding - itemWidth) / (itemWidth + gap),
|
||||
);
|
||||
setVisibleCount(Math.max(1, Math.min(appCount - 1, fit)));
|
||||
};
|
||||
|
||||
compute();
|
||||
const observer = new ResizeObserver(compute);
|
||||
observer.observe(slot);
|
||||
return () => observer.disconnect();
|
||||
}, [appCount]);
|
||||
|
||||
const visibleList = appsToShow.slice(0, Math.max(1, visibleCount));
|
||||
// 激活应用被收进溢出区时,顶替最后一个可见位,保证始终可点亮
|
||||
if (appsToShow.includes(activeApp) && !visibleList.includes(activeApp)) {
|
||||
visibleList[visibleList.length - 1] = activeApp;
|
||||
}
|
||||
const overflowList = appsToShow.filter((app) => !visibleList.includes(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;
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="inline-flex bg-muted rounded-xl p-1 gap-1"
|
||||
style={{ WebkitAppRegion: "no-drag" } as any}
|
||||
>
|
||||
{visibleList.map((app) => {
|
||||
const isActive = activeApp === app;
|
||||
return (
|
||||
<button
|
||||
key={app}
|
||||
type="button"
|
||||
onClick={() => handleSwitch(app)}
|
||||
title={appDisplayName[app]}
|
||||
aria-label={appDisplayName[app]}
|
||||
title={APP_DISPLAY_NAME[app]}
|
||||
aria-label={APP_DISPLAY_NAME[app]}
|
||||
className={cn(
|
||||
"group inline-flex items-center px-3 h-8 rounded-md text-sm font-medium transition-all duration-200",
|
||||
isActive
|
||||
@@ -87,37 +184,50 @@ export function AppSwitcher({
|
||||
: "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>
|
||||
<AppGlyph app={app} isActive={isActive} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{overflowList.length > 0 && (
|
||||
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
title={t("appSwitcher.more")}
|
||||
aria-label={t("appSwitcher.more")}
|
||||
className={cn(
|
||||
"inline-flex items-center px-3 h-8 rounded-md transition-all duration-200",
|
||||
moreOpen
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-background/50",
|
||||
)}
|
||||
>
|
||||
<MoreHorizontal size={20} className="shrink-0" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="end"
|
||||
sideOffset={6}
|
||||
className="z-[100] w-56 p-1"
|
||||
>
|
||||
{overflowList.map((app) => (
|
||||
<button
|
||||
key={app}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMoreOpen(false);
|
||||
handleSwitch(app);
|
||||
}}
|
||||
className="group flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<AppGlyph app={app} isActive={false} />
|
||||
<span className="truncate">{APP_DISPLAY_NAME[app]}</span>
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -892,6 +892,9 @@
|
||||
"openclaw": "OpenClaw",
|
||||
"hermes": "Hermes"
|
||||
},
|
||||
"appSwitcher": {
|
||||
"more": "More apps"
|
||||
},
|
||||
"grokBuild": {
|
||||
"apiBackend": "API backend",
|
||||
"contextWindow": "Context window",
|
||||
|
||||
@@ -892,6 +892,9 @@
|
||||
"openclaw": "OpenClaw",
|
||||
"hermes": "Hermes"
|
||||
},
|
||||
"appSwitcher": {
|
||||
"more": "その他のアプリ"
|
||||
},
|
||||
"grokBuild": {
|
||||
"apiBackend": "API Backend",
|
||||
"contextWindow": "コンテキストウィンドウ",
|
||||
|
||||
@@ -893,6 +893,9 @@
|
||||
"openclaw": "OpenClaw",
|
||||
"hermes": "Hermes"
|
||||
},
|
||||
"appSwitcher": {
|
||||
"more": "更多應用程式"
|
||||
},
|
||||
"grokBuild": {
|
||||
"apiBackend": "API Backend",
|
||||
"contextWindow": "上下文視窗",
|
||||
|
||||
@@ -892,6 +892,9 @@
|
||||
"openclaw": "OpenClaw",
|
||||
"hermes": "Hermes"
|
||||
},
|
||||
"appSwitcher": {
|
||||
"more": "更多应用"
|
||||
},
|
||||
"grokBuild": {
|
||||
"apiBackend": "API Backend",
|
||||
"contextWindow": "上下文窗口",
|
||||
|
||||
Reference in New Issue
Block a user