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([]),
),