mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-26 01:34:51 +08:00
fix(ui): show compact mode-colored connection badge in header (#8634)
This commit is contained in:
1 parent
1ef08d5e7d
commit
c1fd26bde7
10 files changed
+130
-43
No files matched your search
+15
-1
@@ -171,6 +171,8 @@ function App() {
|
||||
const [isAddOpen, setIsAddOpen] = useState(false);
|
||||
// 供应商搜索面板:页头按钮和 ⌘F 都能打开;换应用或离开供应商页就收起
|
||||
const [providerSearchOpen, setProviderSearchOpen] = useState(false);
|
||||
const [connectionStatusSlot, setConnectionStatusSlot] =
|
||||
useState<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
setProviderSearchOpen(false);
|
||||
}, [activeApp, currentView]);
|
||||
@@ -1144,8 +1146,19 @@ function App() {
|
||||
variant="app"
|
||||
icon={<AppGlyph app={activeApp} size={20} badgeClassName="bg-app" />}
|
||||
title={APP_DISPLAY_NAME[activeApp]}
|
||||
truncateTitle={currentView === "providers" && Boolean(modeAppId)}
|
||||
titleExtra={
|
||||
currentView === "providers" && modeAppId ? (
|
||||
<div
|
||||
ref={setConnectionStatusSlot}
|
||||
className="ms-2 flex min-w-0 max-w-[280px] items-center"
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
subtitle={
|
||||
activeApp === "openclaw" || activeApp === "hermes"
|
||||
activeApp === "openclaw" ||
|
||||
activeApp === "hermes" ||
|
||||
(currentView === "providers" && modeAppId)
|
||||
? undefined
|
||||
: appPageName
|
||||
}
|
||||
@@ -1269,6 +1282,7 @@ function App() {
|
||||
<SwitchModePanel
|
||||
key={modeAppId}
|
||||
app={modeAppId}
|
||||
statusSlot={connectionStatusSlot}
|
||||
providers={providers}
|
||||
currentProviderId={currentProviderId}
|
||||
isLoading={isLoading}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
const MODE_ICON = { direct: Plug, route: Route, stack: Layers } as const;
|
||||
|
||||
/** 模式色只用在 tab、侧栏标签、当前卡片和激活条的 CTA 上(B4.7)。 */
|
||||
/** 模式色用于页头状态、tab、侧栏标签、当前卡片和激活条的 CTA。 */
|
||||
export const MODE_TONE: Record<
|
||||
AppMode,
|
||||
{ soft: string; text: string; dot: string; border: string }
|
||||
@@ -43,8 +43,6 @@ interface ModeTabsProps {
|
||||
active: AppMode;
|
||||
view: AppMode;
|
||||
onView: (mode: AppMode) => void;
|
||||
/** 状态行:「前导 + 值」,空间不够先截前导;值仅在独自超出整栏时才截断 */
|
||||
status?: { lead: string; value: string };
|
||||
failover?: {
|
||||
enabled: boolean;
|
||||
disabled?: boolean;
|
||||
@@ -67,7 +65,6 @@ export function ModeTabs({
|
||||
active,
|
||||
view,
|
||||
onView,
|
||||
status,
|
||||
failover,
|
||||
onOpenRouteSettings,
|
||||
onOpenStackSettings,
|
||||
@@ -140,15 +137,7 @@ export function ModeTabs({
|
||||
))}
|
||||
</HelpTip>
|
||||
|
||||
{status && (status.lead || status.value) && (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1 text-caption text-fg-2">
|
||||
{status.lead && <span className="truncate">{status.lead}</span>}
|
||||
<span className="max-w-full shrink-0 truncate" title={status.value}>
|
||||
{status.value}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!status && <div className="flex-1" />}
|
||||
<div className="flex-1" />
|
||||
{searchSlotRef && (
|
||||
<div
|
||||
ref={searchSlotRef}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { createPortal } from "react-dom";
|
||||
import { badgeVariants } from "@/components/ui/badge";
|
||||
import { HoverTip } from "@/components/ui/hover-tip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEffect, useMemo, useState, type RefObject } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
@@ -40,7 +44,7 @@ import {
|
||||
queueMoveSortUpdates,
|
||||
} from "@/components/providers/presentation";
|
||||
import { APP_DISPLAY_NAME } from "@/components/shell/AppGlyph";
|
||||
import { ModeTabs } from "./ModeTabs";
|
||||
import { ModeTabs, MODE_TONE } from "./ModeTabs";
|
||||
import { ModeDialog, type ModeDialogState } from "./ModeDialog";
|
||||
import {
|
||||
sameStackSubagent,
|
||||
@@ -67,6 +71,7 @@ type ListCallbacks = Pick<
|
||||
|
||||
interface SwitchModePanelProps extends ListCallbacks {
|
||||
app: ModeAppId;
|
||||
statusSlot?: HTMLDivElement | null;
|
||||
providers: Record<string, Provider>;
|
||||
currentProviderId: string;
|
||||
isLoading: boolean;
|
||||
@@ -94,6 +99,7 @@ const modesFor = (app: ModeAppId): AppMode[] =>
|
||||
* tab 只负责查看;要生效必须点写明后果的按钮(确认框),回到直连也一样。
|
||||
*/
|
||||
export function SwitchModePanel({
|
||||
statusSlot,
|
||||
app,
|
||||
providers,
|
||||
currentProviderId,
|
||||
@@ -278,7 +284,7 @@ export function SwitchModePanel({
|
||||
},
|
||||
};
|
||||
|
||||
// 状态行:只在看生效的那格时显示;路由只写目标,不写本机地址和端口
|
||||
// 页头始终显示实际生效的连接,不跟随正在浏览的模式页签。
|
||||
const routeTarget = failoverOn ? t("mode.status.byQueue") : nameOf(routeId);
|
||||
const otherMembers = (stack?.members ?? []).filter(
|
||||
(member) => member.providerId !== routeId,
|
||||
@@ -288,23 +294,21 @@ export function SwitchModePanel({
|
||||
0,
|
||||
);
|
||||
const status =
|
||||
view !== active
|
||||
? undefined
|
||||
: active === "direct"
|
||||
? { lead: t("mode.status.directLead"), value: nameOf(directId) }
|
||||
: active === "route"
|
||||
? {
|
||||
lead: t("mode.status.routeLead"),
|
||||
value: `→ ${routeTarget}`,
|
||||
}
|
||||
: {
|
||||
lead: t("mode.status.stackLead"),
|
||||
value: t("mode.status.stackValue", {
|
||||
name: nameOf(routeId),
|
||||
count: otherMembers.length,
|
||||
models: otherModels,
|
||||
}),
|
||||
};
|
||||
active === "direct"
|
||||
? { lead: t("mode.status.directLead"), value: nameOf(directId) }
|
||||
: active === "route"
|
||||
? {
|
||||
lead: t("mode.status.routeLead"),
|
||||
value: routeTarget,
|
||||
}
|
||||
: {
|
||||
lead: t("mode.status.stackLead"),
|
||||
value: t("mode.status.stackValue", {
|
||||
name: nameOf(routeId),
|
||||
count: otherMembers.length,
|
||||
models: otherModels,
|
||||
}),
|
||||
};
|
||||
|
||||
const handleFailoverChange = (enabled: boolean) => {
|
||||
if (enabled && !settings?.failoverConfirmed) {
|
||||
@@ -536,12 +540,50 @@ export function SwitchModePanel({
|
||||
|
||||
return (
|
||||
<>
|
||||
{statusSlot &&
|
||||
createPortal(
|
||||
<HoverTip content={`${status.lead} ${status.value}`}>
|
||||
<span
|
||||
tabIndex={0}
|
||||
aria-label={`${status.lead} ${status.value}`}
|
||||
className={cn(
|
||||
badgeVariants({ variant: "outline" }),
|
||||
"max-w-full gap-1 border-border bg-surface px-2.5 py-1 text-body font-normal",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"me-1 h-1.5 w-1.5 shrink-0 rounded-full",
|
||||
(
|
||||
active === "direct"
|
||||
? Boolean(directId && providers[directId])
|
||||
: serviceRunning &&
|
||||
modeView?.attached &&
|
||||
Boolean(routeId && providers[routeId])
|
||||
)
|
||||
? MODE_TONE[active].dot
|
||||
: "bg-fg-3",
|
||||
)}
|
||||
/>
|
||||
<span className="shrink-0 text-fg-2">
|
||||
{t(`mode.names.${active}`)}
|
||||
</span>
|
||||
<span aria-hidden="true" className="text-fg-3">
|
||||
·
|
||||
</span>
|
||||
<span className="min-w-0 truncate font-medium text-fg-1">
|
||||
{active === "stack" ? nameOf(routeId) : status.value}
|
||||
</span>
|
||||
</span>
|
||||
</HoverTip>,
|
||||
statusSlot,
|
||||
)}
|
||||
<ModeTabs
|
||||
modes={modes}
|
||||
active={active}
|
||||
view={view}
|
||||
onView={setView}
|
||||
status={status}
|
||||
failover={
|
||||
hasFailover && view === "route" && active === "route"
|
||||
? {
|
||||
|
||||
@@ -81,7 +81,7 @@ export function AppPageHeader({
|
||||
{title}
|
||||
</h1>
|
||||
{(subtitle || titleExtra) && (
|
||||
<div className="flex items-center gap-1" style={NO_DRAG}>
|
||||
<div className="flex min-w-0 items-center gap-1" style={NO_DRAG}>
|
||||
{subtitle && (
|
||||
<span className="whitespace-nowrap text-body text-fg-3">
|
||||
{subtitle}
|
||||
|
||||
@@ -4228,7 +4228,7 @@
|
||||
},
|
||||
"active": "active",
|
||||
"activeTitle": "{{mode}} is active",
|
||||
"noProvider": "(none)",
|
||||
"noProvider": "none",
|
||||
"failover": "Failover",
|
||||
"routeSettings": "Routing settings",
|
||||
"routeSettingsTitle": "Routing settings",
|
||||
|
||||
@@ -4228,7 +4228,7 @@
|
||||
},
|
||||
"active": "有効",
|
||||
"activeTitle": "{{mode}}が有効",
|
||||
"noProvider": "(未選択)",
|
||||
"noProvider": "未選択",
|
||||
"failover": "フェイルオーバー",
|
||||
"routeSettings": "ルーティング設定",
|
||||
"routeSettingsTitle": "ルーティング設定",
|
||||
|
||||
@@ -4135,7 +4135,7 @@
|
||||
},
|
||||
"active": "aktif",
|
||||
"activeTitle": "{{mode}} aktif",
|
||||
"noProvider": "(yok)",
|
||||
"noProvider": "yok",
|
||||
"failover": "Yedek sağlayıcıya geçiş",
|
||||
"routeSettings": "Yönlendirme ayarları",
|
||||
"routeSettingsTitle": "Yönlendirme ayarları",
|
||||
|
||||
@@ -4229,7 +4229,7 @@
|
||||
},
|
||||
"active": "生效中",
|
||||
"activeTitle": "{{mode}}生效中",
|
||||
"noProvider": "(未選擇)",
|
||||
"noProvider": "未選擇",
|
||||
"failover": "故障轉移",
|
||||
"routeSettings": "路由設定",
|
||||
"routeSettingsTitle": "路由設定",
|
||||
|
||||
@@ -4228,7 +4228,7 @@
|
||||
},
|
||||
"active": "生效中",
|
||||
"activeTitle": "{{mode}}生效中",
|
||||
"noProvider": "(未选择)",
|
||||
"noProvider": "未选择",
|
||||
"failover": "故障转移",
|
||||
"routeSettings": "路由设置",
|
||||
"routeSettingsTitle": "路由设置",
|
||||
|
||||
@@ -86,10 +86,18 @@ function renderPanel(
|
||||
onSwitch = vi.fn(),
|
||||
) {
|
||||
const queryClient = createTestQueryClient();
|
||||
const statusSlot = document.createElement("div");
|
||||
const view = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<div
|
||||
data-testid="connection-header"
|
||||
ref={(node) => {
|
||||
node?.appendChild(statusSlot);
|
||||
}}
|
||||
/>
|
||||
<SwitchModePanel
|
||||
app={app}
|
||||
statusSlot={statusSlot}
|
||||
providers={providers}
|
||||
currentProviderId={Object.keys(providers)[0] ?? ""}
|
||||
isLoading={false}
|
||||
@@ -380,7 +388,9 @@ describe("SwitchModePanel — Claude Desktop", () => {
|
||||
map: provider("map"),
|
||||
});
|
||||
|
||||
expect(await screen.findByText("→ map")).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByLabelText("mode.status.routeLead map"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /mode\.names\.stack/ }),
|
||||
).toBeInTheDocument();
|
||||
@@ -792,7 +802,7 @@ describe("SwitchModePanel — mode layer", () => {
|
||||
renderPanel("claude", { a: provider("a"), kimi: provider("kimi") });
|
||||
|
||||
// 等模式读到了再换页签:生效的模式一变,查看的那格会跳回生效的那格
|
||||
await screen.findByText("→ a");
|
||||
await screen.findByLabelText("mode.status.routeLead a");
|
||||
fireEvent.click(screen.getByRole("button", { name: /mode\.names\.stack/ }));
|
||||
// 默认和路由目标是同一个指针:路由中改它就是当场换路由,只能在切换时的确认框里选
|
||||
expect(await screen.findByTestId("menu-setDefault-kimi")).toBeDisabled();
|
||||
@@ -864,6 +874,31 @@ describe("SwitchModePanel — mode layer", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["route", "stack"] as const)(
|
||||
"keeps %s status gray when the service runs but the app is detached",
|
||||
async (mode) => {
|
||||
mockMode(mode, "a");
|
||||
server.use(
|
||||
http.post(`${TAURI_ENDPOINT}/get_app_mode`, () =>
|
||||
HttpResponse.json({
|
||||
mode,
|
||||
attached: false,
|
||||
routeProviderId: "a",
|
||||
directProviderId: "a",
|
||||
}),
|
||||
),
|
||||
);
|
||||
renderPanel("claude", { a: provider("a") });
|
||||
await screen.findByLabelText(new RegExp(`mode.status.${mode}Lead`));
|
||||
expect(
|
||||
screen.getByTestId("connection-header").querySelector(".bg-fg-3"),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
screen.getByTestId("connection-header").querySelector(`.bg-${mode}`),
|
||||
).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("shows only the route target in the status line, not the local address", async () => {
|
||||
mockMode("route", "route");
|
||||
server.use(
|
||||
@@ -873,7 +908,14 @@ describe("SwitchModePanel — mode layer", () => {
|
||||
);
|
||||
renderPanel("gemini", { route: provider("route") });
|
||||
|
||||
expect(await screen.findByText("→ route")).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByLabelText("mode.status.routeLead route"),
|
||||
).toHaveTextContent("route");
|
||||
expect(screen.getByTestId("connection-header")).toHaveTextContent("route");
|
||||
fireEvent.click(screen.getByRole("button", { name: "mode.names.direct" }));
|
||||
expect(
|
||||
screen.getByLabelText("mode.status.routeLead route"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(/15721/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user