mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-26 01:34:51 +08:00
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:
1 parent
3d23ddb3b5
commit
c19c117009
13 files changed
+395
-28
No files matched your search
@@ -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,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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "使用中のプロバイダーは削除できません。先にほかを有効にしてください。",
|
||||
|
||||
@@ -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": "正在用的這家不能移除,先啟用別家。",
|
||||
|
||||
@@ -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": "正在用的这家不能移除,先启用别家。",
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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([]),
|
||||
),
|
||||
|
||||
Reference in new issue
Block a user