diff --git a/docs/design-system.html b/docs/design-system.html index 8ab3c7ff0..cf6add4b5 100644 --- a/docs/design-system.html +++ b/docs/design-system.html @@ -898,7 +898,8 @@ 加入 / 移出队列 添加 / 移除 -

设为默认 = 星标、启用 / 停用 = 电源;没对上的 key 回退为圆点图标,新加按钮时在 BUTTON_ICON 里补一条。

+

设为默认 = 星标(OpenClaw 选默认模型)、启用 / 停用 = 电源;没对上的 key 回退为圆点图标,新加按钮时在 BUTTON_ICON 里补一条。

+

聚合页的「设为默认」不占主操作位:行上只留名单的添加 / 移除,「设为默认」在 ⋯ 菜单最前面(CardPresentation.menuItems),点了之后的后果用警示色写在名字下面,不能点时列出来并写原因。

供应商图标框固定白底深色图标,深色主题里也不反色——单色品牌图标继承浅色文字会看不见。

diff --git a/src/components/providers/ProviderCardActions.tsx b/src/components/providers/ProviderCardActions.tsx index 944d33f75..ad6e109ec 100644 --- a/src/components/providers/ProviderCardActions.tsx +++ b/src/components/providers/ProviderCardActions.tsx @@ -71,7 +71,8 @@ interface ProviderCardActionsProps { /** * 卡片右侧(v7):主操作位(状态文字或按钮)→ 上移 / 下移(故障转移队列)→ 编辑 → ⋯。 - * 次要操作都在 ⋯ 里:复制、检测连通、配置用量查询、打开终端、删除。 + * 次要操作都在 ⋯ 里:跟当前模式有关的(聚合页的「设为默认」)在最前,然后是复制、检测连通、 + * 配置用量查询、打开终端、删除。 * 按钮一律纯图标 + HoverTip;有禁用原因时改由 DisabledReason 的说明卡报原因。 */ export function ProviderCardActions({ @@ -86,7 +87,7 @@ export function ProviderCardActions({ onOpenTerminal, }: ProviderCardActionsProps) { const { t } = useTranslation(); - const { status, buttons, move } = presentation; + const { status, buttons, move, menuItems = [] } = presentation; return (
@@ -169,6 +170,28 @@ export function ProviderCardActions({ + {menuItems.map((item) => ( + + {item.label} + {item.disabledReason ? ( + + {item.disabledReason} + + ) : ( + item.detail && ( + + {item.detail} + + ) + )} + + ))} + {menuItems.length > 0 && } {onDuplicate && ( {t("provider.duplicate")} diff --git a/src/components/providers/mode/SwitchModePanel.tsx b/src/components/providers/mode/SwitchModePanel.tsx index c8e0c4a2f..3d0015a8a 100644 --- a/src/components/providers/mode/SwitchModePanel.tsx +++ b/src/components/providers/mode/SwitchModePanel.tsx @@ -205,6 +205,23 @@ export function SwitchModePanel({ } }; + const rememberDefault = async (provider: Provider) => { + try { + await proxyApi.setProxyRoute(app, provider.id); + await queryClient.invalidateQueries({ queryKey: ["providers", app] }); + toast.success( + t("mode.toast.defaultRemembered", { provider: provider.name }), + { closeButton: true }, + ); + } catch (error) { + toast.error( + t("mode.toast.failed", { + detail: extractErrorMessage(error) || t("common.unknown"), + }), + ); + } + }; + const switchMode = { active, view, @@ -248,7 +265,11 @@ export function SwitchModePanel({ enabled: false, }); }, - stackSetDefault: (provider: Provider) => void onSwitch(provider), + // 聚合生效时换默认当场生效(同切换供应商);没生效时只记下选择,切换时的确认框预选它 + stackSetDefault: (provider: Provider) => + active === "stack" + ? void onSwitch(provider) + : void rememberDefault(provider), }, }; diff --git a/src/components/providers/presentation.ts b/src/components/providers/presentation.ts index 22ee9a7ca..4821dfe58 100644 --- a/src/components/providers/presentation.ts +++ b/src/components/providers/presentation.ts @@ -38,6 +38,17 @@ export interface CardMenuOption { onSelect: () => void; } +/** 「更多」菜单里跟当前模式有关的一项。 */ +export interface CardMenuItem { + key: string; + label: string; + /** 点了之后的后果,写在名字下面 */ + detail?: string; + /** 不能点的原因:照常列出来,原因写在名字下面 */ + disabledReason?: string; + onSelect: () => void; +} + export interface CardButton { key: string; label: string; @@ -54,6 +65,8 @@ export interface CardPresentation { /** 主操作位换成状态文字(使用中 / 路由中 / 当前默认 / 已添加…) */ status?: { label: string; dot: CardTone | "muted" }; buttons: CardButton[]; + /** 「更多」菜单最前面、跟当前模式有关的操作(聚合页的「设为默认」) */ + menuItems?: CardMenuItem[]; chips: CardChip[]; /** 整卡淡一些(不能用于当前模式的官方订阅、Hermes 托管) */ dim?: boolean; @@ -426,24 +439,33 @@ function buildSwitchSectionsByView(input: SwitchModeInput): ProviderSection[] { // view === "stack" const on = active === "stack"; - // 进了聚合后「设为默认」当场生效;没进聚合时默认那家在确认框里选,行上只留名单的 - // 添加 / 移除(只改名单,不切模式) - const defaultButtons = (p: Provider): CardButton[] => - on - ? [ - { - key: "setDefault", - label: t("providerCard.action.setDefault"), - onClick: () => actions.stackSetDefault(p), - }, - ] - : []; const defaultId = (() => { const candidate = on ? routeId : (routeId ?? directId); const p = providers.find((x) => x.id === candidate); return p && !blockedFromRouting(app, p) ? p.id : null; })(); const defaultProvider = providers.find((p) => p.id === defaultId); + // 行上的主操作只有名单的添加 / 移除;「设为默认」在「更多」菜单里:进了聚合当场生效, + // 在直连时只记下选择(切换时的确认框里还能改)。正在路由时不能点:默认和路由目标是同一个 + // 指针,改了就是当场换路由。官方账号只能做默认、不能做成员(Codex):默认从它换成别家, + // 官方订阅的模型就不在聚合里了,后果写在菜单项上 + const defaultIsOfficial = + defaultProvider !== undefined && official(defaultProvider); + const setDefaultItems = (p: Provider): CardMenuItem[] => [ + { + key: "setDefault", + label: t("providerCard.action.setDefault"), + detail: + defaultIsOfficial && !official(p) + ? t("providerCard.reason.defaultDropsOfficial") + : undefined, + disabledReason: + active === "route" + ? t("providerCard.reason.defaultWhileRouting") + : undefined, + onSelect: () => actions.stackSetDefault(p), + }, + ]; const memberIds = providers .filter((p) => p.id !== defaultId && stackMembers.has(p.id)) .map((p) => p.id); @@ -498,8 +520,8 @@ function buildSwitchSectionsByView(input: SwitchModeInput): ProviderSection[] { provider: p, presentation: { chips: [modelsChip(p)], + menuItems: setDefaultItems(p), buttons: [ - ...defaultButtons(p), { key: "remove", label: t("providerCard.action.remove"), @@ -525,8 +547,8 @@ function buildSwitchSectionsByView(input: SwitchModeInput): ProviderSection[] { provider: p, presentation: { chips: [chip.official()], + menuItems: setDefaultItems(p), buttons: [ - ...defaultButtons(p), { key: "add", label: t("providerCard.action.add"), @@ -541,6 +563,8 @@ function buildSwitchSectionsByView(input: SwitchModeInput): ProviderSection[] { provider: p, presentation: { chips: providerNeedsRouting(app, p) ? [chip.needsRoute(p)] : [], + // 还没添加的也能直接设为默认:后端会把默认那家一起加进名单 + menuItems: setDefaultItems(p), buttons: [ { key: "add", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index a19033d6f..ba0ee99a8 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -3843,6 +3843,7 @@ "enteredRoute": "{{app}} is now routing → {{provider}}.", "enteredStack": "{{app}} switched to Aggregation. Restart {{app}} to see the aggregated models in /model.", "exited": "Back to direct: {{app}} now connects to {{provider}} directly.", + "defaultRemembered": "{{provider}} will be the default provider once you switch to Aggregation.", "restartHint": " Restart {{app}} to apply.", "undo": "Undo", "failed": "Couldn't switch: {{detail}}" @@ -3921,6 +3922,8 @@ "noRoute": "Official subscriptions don't go through routing; available again in direct mode.", "noStack": "Official subscriptions can't be used in Aggregation.", "officialStack": "Official accounts can only be the default; they can't join the aggregation.", + "defaultDropsOfficial": "The official subscription's models won't be part of the aggregation.", + "defaultWhileRouting": "While routing, the default is the provider you route to. You can pick another one when switching to Aggregation.", "needsRoute": "Needs routing: {{reason}}; can't connect directly.", "defaultCannotRemove": "The default can't be removed. Make another provider the default first.", "currentCannotRemove": "The provider in use can't be removed. Enable another one first.", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index b5ae3f93f..23e00b41b 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -3843,6 +3843,7 @@ "enteredRoute": "{{app}} のルーティングを開始しました → {{provider}}。", "enteredStack": "{{app}} を集約に切り替えました。{{app}} を再起動すると /model に集約したモデルが表示されます。", "exited": "直接接続に戻しました:{{app}} は {{provider}} に直接接続しています。", + "defaultRemembered": "集約に切り替えると、{{provider}} が既定のプロバイダーになります。", "restartHint": "{{app}} の再起動後に反映されます。", "undo": "元に戻す", "failed": "切り替えに失敗しました:{{detail}}" @@ -3921,6 +3922,8 @@ "noRoute": "公式サブスクリプションはルーティングを通りません。直接接続に戻すと使えます。", "noStack": "公式サブスクリプションは集約では使えません。", "officialStack": "公式アカウントは既定にだけでき、集約には追加できません。", + "defaultDropsOfficial": "公式サブスクリプションのモデルは集約に入りません。", + "defaultWhileRouting": "ルーティング中は、転送先のプロバイダーがそのまま既定になります。集約に切り替えるときに別のプロバイダーを選べます。", "needsRoute": "ルーティングが必要:{{reason}}。直接接続はできません。", "defaultCannotRemove": "既定のプロバイダーは削除できません。先にほかを既定にしてください。", "currentCannotRemove": "使用中のプロバイダーは削除できません。先にほかを有効にしてください。", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 82c2d99a3..02d7a3fe3 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -3844,6 +3844,7 @@ "enteredRoute": "{{app}} 已開始路由 → {{provider}}。", "enteredStack": "{{app}} 已切換到聚合模式。重新啟動 {{app}} 後,聚合的模型會出現在 /model 裡。", "exited": "已回到直連:{{app}} 現在直接連線 {{provider}}。", + "defaultRemembered": "切換到聚合模式後,預設供應商是 {{provider}}。", "restartHint": "重新啟動 {{app}} 後生效。", "undo": "復原", "failed": "切換失敗:{{detail}}" @@ -3922,6 +3923,8 @@ "noRoute": "官方訂閱不經過路由,回到直連後可用。", "noStack": "官方訂閱不能用於聚合模式。", "officialStack": "官方帳號只能當預設,不能加入聚合。", + "defaultDropsOfficial": "官方訂閱的模型不會出現在聚合裡。", + "defaultWhileRouting": "正在路由時,預設就是路由到的那家;切換到聚合模式時可以另選。", "needsRoute": "需要路由:{{reason}},不能直連。", "defaultCannotRemove": "預設那家不能移除,先把別家設為預設。", "currentCannotRemove": "正在用的這家不能移除,先啟用別家。", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 93e4c7880..a3d39fcf1 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -3843,6 +3843,7 @@ "enteredRoute": "{{app}} 已开始路由 → {{provider}}。", "enteredStack": "{{app}} 已切换到聚合模式。重启 {{app}} 后,聚合的模型出现在 /model 里。", "exited": "已回到直连:{{app}} 现在直接连接 {{provider}}。", + "defaultRemembered": "切换到聚合模式后,默认供应商是 {{provider}}。", "restartHint": "重启 {{app}} 后生效。", "undo": "撤销", "failed": "切换失败:{{detail}}" @@ -3921,6 +3922,8 @@ "noRoute": "官方订阅不经过路由,回到直连后可用。", "noStack": "官方订阅不能用于聚合模式。", "officialStack": "官方账号只能做默认,不能加入聚合。", + "defaultDropsOfficial": "官方订阅的模型不会出现在聚合里。", + "defaultWhileRouting": "正在路由时,默认就是路由到的那家;切换到聚合模式时可以另选。", "needsRoute": "需要路由:{{reason}},不能直连。", "defaultCannotRemove": "默认那家不能移除,先把别家设为默认。", "currentCannotRemove": "正在用的这家不能移除,先启用别家。", diff --git a/src/lib/api/proxy.ts b/src/lib/api/proxy.ts index 03c07d4ec..d9608cd1e 100644 --- a/src/lib/api/proxy.ts +++ b/src/lib/api/proxy.ts @@ -58,6 +58,12 @@ export const proxyApi = { }); }, + // 指定路由目标(聚合模式下是默认那家):直连时只记下来、下次进入路由 / 聚合模式时用它, + // 已经在路由 / 聚合模式时当场生效 + async setProxyRoute(appType: string, providerId: string): Promise { + return invoke("set_proxy_route", { appType, providerId }); + }, + // 应用页模式行:生效的模式、路由目标(直连时是上次路由的那家)、直连那家 async getAppMode(appType: string): Promise { return invoke("get_app_mode", { appType }); diff --git a/tests/components/ProviderCardActions.test.tsx b/tests/components/ProviderCardActions.test.tsx new file mode 100644 index 000000000..d04ba5f36 --- /dev/null +++ b/tests/components/ProviderCardActions.test.tsx @@ -0,0 +1,87 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { ProviderCardActions } from "@/components/providers/ProviderCardActions"; +import type { CardPresentation } from "@/components/providers/presentation"; + +async function openMenu(presentation: CardPresentation) { + const user = userEvent.setup(); + render( + , + ); + await user.click( + screen.getByRole("button", { name: "providerCard.action.more" }), + ); + return { user, menu: await screen.findByRole("menu") }; +} + +describe("ProviderCardActions — mode items in the more menu", () => { + it("lists them first, with the consequence under the name", async () => { + const onSelect = vi.fn(); + const { user, menu } = await openMenu({ + buttons: [], + chips: [], + menuItems: [ + { + key: "setDefault", + label: "providerCard.action.setDefault", + detail: "providerCard.reason.defaultDropsOfficial", + onSelect, + }, + ], + }); + + const items = within(menu).getAllByRole("menuitem"); + expect(items[0]).toHaveTextContent("providerCard.action.setDefault"); + expect(items[0]).toHaveTextContent( + "providerCard.reason.defaultDropsOfficial", + ); + expect(items[1]).toHaveTextContent("provider.duplicate"); + + await user.click(items[0]); + expect(onSelect).toHaveBeenCalledTimes(1); + }); + + it("keeps a disabled one listed with its reason", async () => { + const onSelect = vi.fn(); + const { user, menu } = await openMenu({ + buttons: [], + chips: [], + menuItems: [ + { + key: "setDefault", + label: "providerCard.action.setDefault", + detail: "providerCard.reason.defaultDropsOfficial", + disabledReason: "providerCard.reason.defaultWhileRouting", + onSelect, + }, + ], + }); + + const item = within(menu).getAllByRole("menuitem")[0]; + expect(item).toHaveAttribute("aria-disabled", "true"); + // 不能点时只写原因,不再写后果 + expect(item).toHaveTextContent("providerCard.reason.defaultWhileRouting"); + expect(item).not.toHaveTextContent( + "providerCard.reason.defaultDropsOfficial", + ); + await user.click(item); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it("shows only the usual items when the mode has none", async () => { + const { menu } = await openMenu({ buttons: [], chips: [] }); + + expect( + within(menu) + .getAllByRole("menuitem") + .map((item) => item.textContent), + ).toEqual(["provider.duplicate", "common.delete"]); + }); +}); diff --git a/tests/components/SwitchModePanel.test.tsx b/tests/components/SwitchModePanel.test.tsx index 7deaa0c94..d9eb404ae 100644 --- a/tests/components/SwitchModePanel.test.tsx +++ b/tests/components/SwitchModePanel.test.tsx @@ -26,7 +26,7 @@ vi.mock("sonner", () => ({ }, })); -// 只关心模式层:卡片画成按钮,按 presentation 的 key 找 +// 只关心模式层:卡片画成按钮,按 presentation 的 key 找;「更多」菜单里的项加 menu- 前缀 vi.mock("@/components/providers/ProviderCard", () => ({ ProviderCard: ({ provider, presentation }: any) => (
@@ -40,6 +40,16 @@ vi.mock("@/components/providers/ProviderCard", () => ({ {button.label} ))} + {(presentation.menuItems ?? []).map((item: any) => ( + + ))}
), })); @@ -76,7 +86,11 @@ function mockMode( ); } -function renderPanel(app: ProxyAppId, providers: Record) { +function renderPanel( + app: ProxyAppId, + providers: Record, + onSwitch = vi.fn(), +) { const queryClient = createTestQueryClient(); return render( @@ -85,7 +99,7 @@ function renderPanel(app: ProxyAppId, providers: Record) { providers={providers} currentProviderId={Object.keys(providers)[0] ?? ""} isLoading={false} - onSwitch={vi.fn()} + onSwitch={onSwitch} onOpenRoutingSettings={vi.fn()} onEdit={vi.fn()} onDelete={vi.fn()} @@ -98,6 +112,7 @@ function renderPanel(app: ProxyAppId, providers: Record) { beforeEach(() => { vi.mocked(toast.info).mockClear(); + vi.mocked(toast.success).mockClear(); // jsdom 没有 scrollIntoView,确认框里的下拉打开时会调 Element.prototype.scrollIntoView = vi.fn(); }); @@ -263,11 +278,11 @@ describe("SwitchModePanel — mode layer", () => { await user.click( await screen.findByRole("button", { name: /mode\.names\.stack/ }), ); - // 行上只有名单的添加:没有「设为默认」,也没有会切模式的按钮 + // 行上只有名单的添加,「设为默认」在「更多」菜单里;没有会切模式的按钮 const row = await screen.findByTestId("card-kimi"); expect( Array.from(row.querySelectorAll("button")).map((b) => b.dataset.testid), - ).toEqual(["add-kimi"]); + ).toEqual(["add-kimi", "menu-setDefault-kimi"]); expect(screen.getByTestId("add-kimi")).not.toBeDisabled(); await user.click( @@ -293,6 +308,110 @@ describe("SwitchModePanel — mode layer", () => { ); }); + it("records the Stack default from the card menu while direct, without entering the mode", async () => { + const user = userEvent.setup(); + let route: string | null = null; + const routeCalls: unknown[] = []; + server.use( + http.post(`${TAURI_ENDPOINT}/get_app_mode`, () => + HttpResponse.json({ + mode: "direct", + attached: false, + routeProviderId: route, + directProviderId: "a", + }), + ), + http.post(`${TAURI_ENDPOINT}/get_proxy_stack`, () => + HttpResponse.json({ active: false, members: [] }), + ), + http.post(`${TAURI_ENDPOINT}/set_proxy_route`, async ({ request }) => { + const body = (await request.json()) as { providerId: string }; + routeCalls.push(body); + route = body.providerId; + return HttpResponse.json(null); + }), + ); + const takeover = captureTakeover(); + const onSwitch = vi.fn(); + renderPanel("claude", { a: provider("a"), kimi: provider("kimi") }, onSwitch); + + await user.click( + await screen.findByRole("button", { name: /mode\.names\.stack/ }), + ); + await user.click(await screen.findByTestId("menu-setDefault-kimi")); + + // 只记下选择:不进聚合、不走切换供应商 + await waitFor(() => + expect(routeCalls).toEqual([{ appType: "claude", providerId: "kimi" }]), + ); + expect(takeover).toEqual([]); + expect(onSwitch).not.toHaveBeenCalled(); + await waitFor(() => + expect(toast.success).toHaveBeenCalledWith( + "mode.toast.defaultRemembered", + expect.anything(), + ), + ); + // 记下的那家成了「切换后的默认」:自己不再有「设为默认」,原来那家有了 + await screen.findByTestId("menu-setDefault-a"); + expect(screen.queryByTestId("menu-setDefault-kimi")).not.toBeInTheDocument(); + + // 确认框预选它 + await user.click( + screen.getByRole("button", { name: "mode.activate.stack" }), + ); + expect( + await screen.findByRole("combobox", { name: "mode.dialog.stackDefault" }), + ).toHaveTextContent("kimi"); + }); + + it("changes the Stack default right away once Stack mode is on", async () => { + mockMode("stack", "route"); + const routeCalls: unknown[] = []; + server.use( + http.post(`${TAURI_ENDPOINT}/get_proxy_stack`, () => + HttpResponse.json({ + active: true, + members: [ + { providerId: "route", modelIds: ["m-route"], route: true }, + { providerId: "kimi", modelIds: ["m-kimi"], route: false }, + ], + }), + ), + http.post(`${TAURI_ENDPOINT}/set_proxy_route`, async ({ request }) => { + routeCalls.push(await request.json()); + return HttpResponse.json(null); + }), + ); + const onSwitch = vi.fn(); + const kimi = provider("kimi"); + const other = provider("other"); + renderPanel("claude", { route: provider("route"), kimi, other }, onSwitch); + + // 已添加的、还没添加的都能直接设为默认,走的是切换供应商(当场生效) + fireEvent.click(await screen.findByTestId("menu-setDefault-kimi")); + fireEvent.click(screen.getByTestId("menu-setDefault-other")); + expect(onSwitch.mock.calls.map(([p]) => p)).toEqual([kimi, other]); + expect(screen.queryByTestId("menu-setDefault-route")).not.toBeInTheDocument(); + expect(routeCalls).toEqual([]); + }); + + it("cannot change the Stack default from the preview while routing", async () => { + mockMode("route", "a"); + server.use( + http.post(`${TAURI_ENDPOINT}/get_proxy_stack`, () => + HttpResponse.json({ active: false, members: [] }), + ), + ); + renderPanel("claude", { a: provider("a"), kimi: provider("kimi") }); + + // 等模式读到了再换页签:生效的模式一变,查看的那格会跳回生效的那格 + await screen.findByText("→ a"); + fireEvent.click(screen.getByRole("button", { name: /mode\.names\.stack/ })); + // 默认和路由目标是同一个指针:路由中改它就是当场换路由,只能在切换时的确认框里选 + expect(await screen.findByTestId("menu-setDefault-kimi")).toBeDisabled(); + }); + it("warns that a Codex official account drops out of the Stack unless it is the default", async () => { const user = userEvent.setup(); mockMode("direct", null, "kimi"); diff --git a/tests/components/providerPresentation.test.ts b/tests/components/providerPresentation.test.ts index 5829fbb85..b658abab7 100644 --- a/tests/components/providerPresentation.test.ts +++ b/tests/components/providerPresentation.test.ts @@ -77,6 +77,15 @@ const button = (sections: ProviderSection[], id: string, key: string) => { return found; }; +/** 「更多」菜单里的「设为默认」 */ +const setDefaultItem = (sections: ProviderSection[], id: string) => { + const found = item(sections, id).menuItems?.find( + (m) => m.key === "setDefault", + ); + if (!found) throw new Error(`no setDefault menu item on ${id}`); + return found; +}; + describe("buildSwitchSections — direct", () => { it("marks the direct provider in use and asks before switching to one that needs routing", () => { const { sections, input } = build({ active: "direct", view: "direct" }); @@ -290,6 +299,7 @@ describe("buildSwitchSections — route", () => { for (const id of ["relay", "backup", "converted"]) { expect(item(sections, id).buttons).toEqual([]); expect(item(sections, id).chips).toEqual([]); + expect(item(sections, id).menuItems).toBeUndefined(); } // 不能路由的官方订阅没有按钮可挂原因,原因在「官方」徽标上 expect(item(sections, "official")).toMatchObject({ @@ -327,17 +337,32 @@ describe("buildSwitchSections — stack", () => { key: "models", tone: "warning", }); + // 行上只有名单的移除 / 添加;「设为默认」在「更多」菜单里 + expect(item(sections, "backup").buttons.map((b) => b.key)).toEqual([ + "remove", + ]); button(sections, "backup", "remove").onClick(); expect(input.actions.stackRemove).toHaveBeenCalledWith(backup); - button(sections, "backup", "setDefault").onClick(); + expect(setDefaultItem(sections, "backup")).toMatchObject({ + label: "providerCard.action.setDefault", + detail: undefined, + disabledReason: undefined, + }); + setDefaultItem(sections, "backup").onSelect(); expect(input.actions.stackSetDefault).toHaveBeenCalledWith(backup); button(sections, "converted", "add").onClick(); expect(input.actions.stackAdd).toHaveBeenCalledWith(converted); - // Claude 官方订阅不能进聚合 + // 还没添加的也能直接设为默认 + setDefaultItem(sections, "converted").onSelect(); + expect(input.actions.stackSetDefault).toHaveBeenCalledWith(converted); + // 默认那家自己没有这一项 + expect(item(sections, "relay").menuItems).toBeUndefined(); + // Claude 官方订阅不能进聚合,也做不了默认 expect(button(sections, "official", "blocked").disabledReason).toBe( "providerCard.reason.noStack", ); + expect(item(sections, "official").menuItems).toBeUndefined(); }); it("previews the stack outside Stack mode: rows only edit the list", () => { @@ -354,11 +379,10 @@ describe("buildSwitchSections — stack", () => { tone: undefined, status: { label: "providerCard.status.defaultAfterSwitch" }, }); - // 默认那家在确认框里选:行上没有「设为默认」,也没有会切模式的按钮 + // 行上没有会切模式的按钮,只有名单的添加 / 移除 expect(item(sections, "backup").buttons.map((b) => b.key)).toEqual([ "remove", ]); - // 名单的添加 / 移除照旧能点 expect(item(sections, "converted").buttons.map((b) => b.key)).toEqual([ "add", ]); @@ -367,6 +391,27 @@ describe("buildSwitchSections — stack", () => { button(sections, "backup", "remove").onClick(); expect(input.actions.stackRemove).toHaveBeenCalledWith(backup); expect(input.actions.stackSetDefault).not.toHaveBeenCalled(); + // 「设为默认」在「更多」菜单里:直连时能点(只记下选择) + expect(setDefaultItem(sections, "backup").disabledReason).toBeUndefined(); + setDefaultItem(sections, "converted").onSelect(); + expect(input.actions.stackSetDefault).toHaveBeenCalledWith(converted); + }); + + it("does not let the Stack default change from the preview while routing", () => { + // 默认和路由目标是同一个指针:路由中改它就是当场换路由 + const { sections } = build({ + active: "route", + view: "stack", + routeId: "relay", + stackMembers: new Map([["backup", 1]]), + }); + + expect(item(sections, "relay").section).toBe("default"); + for (const id of ["backup", "converted"]) { + expect(setDefaultItem(sections, id).disabledReason).toBe( + "providerCard.reason.defaultWhileRouting", + ); + } }); it("shows a Codex official account outside Stack mode with only the reason it cannot be added", () => { @@ -388,6 +433,34 @@ describe("buildSwitchSections — stack", () => { expect(button(sections, "account", "add").disabledReason).toBe( "providerCard.reason.officialStack", ); + // 它能做默认;默认本来就不是官方账号时,换默认没有额外后果 + expect(setDefaultItem(sections, "account").detail).toBeUndefined(); + }); + + it("says the official subscription leaves the Stack when the default moves off a Codex official account", () => { + const account = provider("account", { + category: "official", + settingsConfig: { auth: {}, config: "" }, + }); + for (const active of ["direct", "stack"] as AppMode[]) { + const { sections } = build({ + app: "codex", + active, + view: "stack", + providers: [account, relay, backup], + directId: "relay", + routeId: "account", + stackMembers: new Map([["relay", 1]]), + }); + + expect(item(sections, "account").section).toBe("default"); + // 已添加的、还没添加的第三方都一样:官方账号不做默认就不在聚合里了 + for (const id of ["relay", "backup"]) { + expect(setDefaultItem(sections, id).detail).toBe( + "providerCard.reason.defaultDropsOfficial", + ); + } + } }); it("never lets ChatGPT accounts be added in Codex Stack mode, even legacy cards without a category", () => { @@ -416,7 +489,7 @@ describe("buildSwitchSections — stack", () => { expect(button(sections, "managed", "add").disabledReason).toBe( "providerCard.reason.officialStack", ); - button(sections, "managed", "setDefault").onClick(); + setDefaultItem(sections, "managed").onSelect(); expect(input.actions.stackSetDefault).toHaveBeenCalledWith(managed); }); }); diff --git a/tests/msw/handlers.ts b/tests/msw/handlers.ts index 97dcf2800..87620e118 100644 --- a/tests/msw/handlers.ts +++ b/tests/msw/handlers.ts @@ -422,6 +422,7 @@ export const handlers = [ directProviderId: getCurrentProviderId(appType) || null, }); }), + http.post(`${TAURI_ENDPOINT}/set_proxy_route`, () => success(null)), http.post(`${TAURI_ENDPOINT}/take_startup_attach_failures`, () => success([]), ),