fix(ui): show compact mode-colored connection badge in header (#8634)

This commit is contained in:
SaladDay authored and GitHub committed 2026-10-21 15:17:42 +08:00
1 parent 1ef08d5e7d
commit c1fd26bde7
10 files changed
+130 -43

No files matched your search

+15 -1
View File
@@ -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}
+2 -13
View File
@@ -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"
? {
+1 -1
View File
@@ -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}
+1 -1
View File
@@ -4228,7 +4228,7 @@
},
"active": "active",
"activeTitle": "{{mode}} is active",
"noProvider": "(none)",
"noProvider": "none",
"failover": "Failover",
"routeSettings": "Routing settings",
"routeSettingsTitle": "Routing settings",
+1 -1
View File
@@ -4228,7 +4228,7 @@
},
"active": "有効",
"activeTitle": "{{mode}}が有効",
"noProvider": "(未選択)",
"noProvider": "未選択",
"failover": "フェイルオーバー",
"routeSettings": "ルーティング設定",
"routeSettingsTitle": "ルーティング設定",
+1 -1
View File
@@ -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ı",
+1 -1
View File
@@ -4229,7 +4229,7 @@
},
"active": "生效中",
"activeTitle": "{{mode}}生效中",
"noProvider": "(未選擇)",
"noProvider": "未選擇",
"failover": "故障轉移",
"routeSettings": "路由設定",
"routeSettingsTitle": "路由設定",
+1 -1
View File
@@ -4228,7 +4228,7 @@
},
"active": "生效中",
"activeTitle": "{{mode}}生效中",
"noProvider": "(未选择)",
"noProvider": "未选择",
"failover": "故障转移",
"routeSettings": "路由设置",
"routeSettingsTitle": "路由设置",
+45 -3
View File
@@ -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();
});
});