feat(ui): set the aggregation default from the card's more menu

After the "start aggregation with this as default" button went away, the
aggregation preview showed the default-after-switch but had no control to
change it; the default could only be picked in the confirm dialog. Once
in aggregation mode it was an icon-only star next to remove.

Move "set as default" to the top of each card's more menu on the
aggregation tab, in both states, and leave add / remove as the only row
actions:

- In aggregation mode it takes effect at once, also for providers not
  added yet (the backend adds the new default to the list).
- In direct mode it only records the choice through set_proxy_route; the
  card moves to the default section and the confirm dialog preselects it.
- While routing it is listed but disabled with the reason: the default
  and the route target are one pointer, so changing it would switch the
  live route from a preview.

On Codex, when the default is an official account and the target is a
third-party provider, the item says the official subscription's models
won't be part of the aggregation. That was previously a silent side
effect of changing the default.

Mode-specific menu items come from the presentation builder
(CardPresentation.menuItems), like the row buttons.
This commit is contained in:
Jason committed 2026-10-03 23:29:00 +08:00
1 parent 3d23ddb3b5
commit c19c117009
13 files changed
+395 -28

No files matched your search

+2 -1
View File
@@ -898,7 +898,8 @@
<span class="spec"><button class="btn icon" data-tip="加入队列" aria-label="加入队列"><svg class="i"><use href="#list-plus"/></svg></button><span class="cap">加入 / 移出队列</span></span>
<span class="spec"><button class="btn icon" data-tip="添加" aria-label="添加"><svg class="i"><use href="#plus"/></svg></button><span class="cap">添加 / 移除</span></span>
</div>
<p class="note">设为默认 = 星标、启用 / 停用 = 电源;没对上的 key 回退为圆点图标,新加按钮时在 BUTTON_ICON 里补一条。</p>
<p class="note">设为默认 = 星标(OpenClaw 选默认模型)、启用 / 停用 = 电源;没对上的 key 回退为圆点图标,新加按钮时在 BUTTON_ICON 里补一条。</p>
<p class="note">聚合页的「设为默认」不占主操作位:行上只留名单的添加 / 移除,「设为默认」在 ⋯ 菜单最前面(<code>CardPresentation.menuItems</code>),点了之后的后果用警示色写在名字下面,不能点时列出来并写原因。</p>
<p class="note">供应商图标框固定白底深色图标,深色主题里也不反色——单色品牌图标继承浅色文字会看不见。</p>
</section>
@@ -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 (
<div className="flex shrink-0 items-center gap-2">
@@ -169,6 +170,28 @@ export function ProviderCardActions({
</DropdownMenuTrigger>
</HoverTip>
<DropdownMenuContent align="end" className="min-w-[180px]">
{menuItems.map((item) => (
<DropdownMenuItem
key={item.key}
disabled={Boolean(item.disabledReason)}
onSelect={item.onSelect}
className="flex-col items-start gap-0.5"
>
{item.label}
{item.disabledReason ? (
<span className="max-w-64 text-caption text-fg-3">
{item.disabledReason}
</span>
) : (
item.detail && (
<span className="max-w-64 text-caption text-warning-text">
{item.detail}
</span>
)
)}
</DropdownMenuItem>
))}
{menuItems.length > 0 && <DropdownMenuSeparator />}
{onDuplicate && (
<DropdownMenuItem onSelect={onDuplicate}>
{t("provider.duplicate")}
@@ -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),
},
};
+38 -14
View File
@@ -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",
+3
View File
@@ -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.",
+3
View File
@@ -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": "使用中のプロバイダーは削除できません。先にほかを有効にしてください。",
+3
View File
@@ -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": "正在用的這家不能移除,先啟用別家。",
+3
View File
@@ -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": "正在用的这家不能移除,先启用别家。",
+6
View File
@@ -58,6 +58,12 @@ export const proxyApi = {
});
},
// 指定路由目标(聚合模式下是默认那家):直连时只记下来、下次进入路由 / 聚合模式时用它,
// 已经在路由 / 聚合模式时当场生效
async setProxyRoute(appType: string, providerId: string): Promise<void> {
return invoke("set_proxy_route", { appType, providerId });
},
// 应用页模式行:生效的模式、路由目标(直连时是上次路由的那家)、直连那家
async getAppMode(appType: string): Promise<AppModeView> {
return invoke("get_app_mode", { appType });
@@ -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(
<ProviderCardActions
providerName="Kimi"
presentation={presentation}
onEdit={vi.fn()}
onDelete={vi.fn()}
onDuplicate={vi.fn()}
/>,
);
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"]);
});
});
+124 -5
View File
@@ -26,7 +26,7 @@ vi.mock("sonner", () => ({
},
}));
// 只关心模式层:卡片画成按钮,按 presentation 的 key 找
// 只关心模式层:卡片画成按钮,按 presentation 的 key 找;「更多」菜单里的项加 menu- 前缀
vi.mock("@/components/providers/ProviderCard", () => ({
ProviderCard: ({ provider, presentation }: any) => (
<div data-testid={`card-${provider.id}`}>
@@ -40,6 +40,16 @@ vi.mock("@/components/providers/ProviderCard", () => ({
{button.label}
</button>
))}
{(presentation.menuItems ?? []).map((item: any) => (
<button
key={item.key}
data-testid={`menu-${item.key}-${provider.id}`}
disabled={Boolean(item.disabledReason)}
onClick={item.onSelect}
>
{item.label}
</button>
))}
</div>
),
}));
@@ -76,7 +86,11 @@ function mockMode(
);
}
function renderPanel(app: ProxyAppId, providers: Record<string, Provider>) {
function renderPanel(
app: ProxyAppId,
providers: Record<string, Provider>,
onSwitch = vi.fn(),
) {
const queryClient = createTestQueryClient();
return render(
<QueryClientProvider client={queryClient}>
@@ -85,7 +99,7 @@ function renderPanel(app: ProxyAppId, providers: Record<string, Provider>) {
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<string, Provider>) {
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");
+78 -5
View File
@@ -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);
});
});
+1
View File
@@ -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([]),
),