diff --git a/src/App.tsx b/src/App.tsx index 73b5805cf..cb2abadab 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -171,6 +171,8 @@ function App() { const [isAddOpen, setIsAddOpen] = useState(false); // 供应商搜索面板:页头按钮和 ⌘F 都能打开;换应用或离开供应商页就收起 const [providerSearchOpen, setProviderSearchOpen] = useState(false); + const [connectionStatusSlot, setConnectionStatusSlot] = + useState(null); useEffect(() => { setProviderSearchOpen(false); }, [activeApp, currentView]); @@ -1144,8 +1146,19 @@ function App() { variant="app" icon={} title={APP_DISPLAY_NAME[activeApp]} + truncateTitle={currentView === "providers" && Boolean(modeAppId)} + titleExtra={ + currentView === "providers" && modeAppId ? ( +
+ ) : undefined + } subtitle={ - activeApp === "openclaw" || activeApp === "hermes" + activeApp === "openclaw" || + activeApp === "hermes" || + (currentView === "providers" && modeAppId) ? undefined : appPageName } @@ -1269,6 +1282,7 @@ function App() { 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({ ))} - {status && (status.lead || status.value) && ( -
- {status.lead && {status.lead}} - - {status.value} - -
- )} - {!status &&
} +
{searchSlotRef && (
; 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( + + + + , + statusSlot, + )} {(subtitle || titleExtra) && ( -
+
{subtitle && ( {subtitle} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 07dc65298..89e3e7fd5 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -4228,7 +4228,7 @@ }, "active": "active", "activeTitle": "{{mode}} is active", - "noProvider": "(none)", + "noProvider": "none", "failover": "Failover", "routeSettings": "Routing settings", "routeSettingsTitle": "Routing settings", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 405cd8751..d1660356d 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -4228,7 +4228,7 @@ }, "active": "有効", "activeTitle": "{{mode}}が有効", - "noProvider": "(未選択)", + "noProvider": "未選択", "failover": "フェイルオーバー", "routeSettings": "ルーティング設定", "routeSettingsTitle": "ルーティング設定", diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index cf2d57cd9..464107d03 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -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ı", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index ec0675048..97fe0a37a 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -4229,7 +4229,7 @@ }, "active": "生效中", "activeTitle": "{{mode}}生效中", - "noProvider": "(未選擇)", + "noProvider": "未選擇", "failover": "故障轉移", "routeSettings": "路由設定", "routeSettingsTitle": "路由設定", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 4f395b538..44a923d97 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -4228,7 +4228,7 @@ }, "active": "生效中", "activeTitle": "{{mode}}生效中", - "noProvider": "(未选择)", + "noProvider": "未选择", "failover": "故障转移", "routeSettings": "路由设置", "routeSettingsTitle": "路由设置", diff --git a/tests/components/SwitchModePanel.test.tsx b/tests/components/SwitchModePanel.test.tsx index 7df4a3009..f263622dc 100644 --- a/tests/components/SwitchModePanel.test.tsx +++ b/tests/components/SwitchModePanel.test.tsx @@ -86,10 +86,18 @@ function renderPanel( onSwitch = vi.fn(), ) { const queryClient = createTestQueryClient(); + const statusSlot = document.createElement("div"); const view = render( +
{ + node?.appendChild(statusSlot); + }} + /> { 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(); }); });