fix(ui): replace the new-layout banner with a one-time dialog

The banner arrived ~0.7s after launch and pushed the content area down.
Upgrading users now get a dialog over the page instead, listing what
moved and linking to this version's release notes.

Confirmation is stored as newLayoutNoticeConfirmed in settings.json,
following the existing *_confirmed flags. The welcome dialog sets it
too, so fresh installs never see it.
This commit is contained in:
Jason committed 2026-10-03 23:29:00 +08:00
1 parent 3dc46460fe
commit d4e47d7197
12 files changed
+263 -205

No files matched your search

+4
View File
@@ -418,6 +418,9 @@ pub struct AppSettings {
/// User has confirmed the first-run welcome notice
#[serde(default, skip_serializing_if = "Option::is_none")]
pub first_run_notice_confirmed: Option<bool>,
/// User has confirmed the one-time "new layout" dialog shown to upgrading users
#[serde(default, skip_serializing_if = "Option::is_none")]
pub new_layout_notice_confirmed: Option<bool>,
/// User has confirmed the common config first-run notice
#[serde(default, skip_serializing_if = "Option::is_none")]
pub common_config_confirmed: Option<bool>,
@@ -553,6 +556,7 @@ impl Default for AppSettings {
unify_codex_migrate_existing: None,
failover_confirmed: None,
first_run_notice_confirmed: None,
new_layout_notice_confirmed: None,
common_config_confirmed: None,
language: None,
visible_apps: None,
+2 -2
View File
@@ -55,7 +55,7 @@ import {
type View,
} from "@/lib/navigation";
import { Sidebar } from "@/components/shell/Sidebar";
import { NewLayoutNotice } from "@/components/shell/NewLayoutNotice";
import { NewLayoutDialog } from "@/components/shell/NewLayoutDialog";
import {
AppPageHeader,
WindowControlsContext,
@@ -1491,7 +1491,6 @@ function App() {
}}
/>
)}
<NewLayoutNotice />
<div
key={
currentView === "settings"
@@ -1581,6 +1580,7 @@ function App() {
<DeepLinkImportDialog />
<FirstRunNoticeDialog />
<NewLayoutDialog />
</WindowControlsContext.Provider>
);
}
+6 -1
View File
@@ -27,7 +27,12 @@ export function FirstRunNoticeDialog() {
if (!settings) return;
try {
const { webdavSync: _, ...rest } = settings;
await settingsApi.save({ ...rest, firstRunNoticeConfirmed: true });
// 新装用户看的就是新界面,「界面改版了」弹窗一并记成已看过
await settingsApi.save({
...rest,
firstRunNoticeConfirmed: true,
newLayoutNoticeConfirmed: true,
});
await queryClient.invalidateQueries({ queryKey: ["settings"] });
} catch (error) {
console.error("Failed to save firstRunNoticeConfirmed:", error);
+117
View File
@@ -0,0 +1,117 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { getVersion } from "@tauri-apps/api/app";
import { Sparkles } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { APP_IDS } from "@/config/appConfig";
import { providersApi, settingsApi } from "@/lib/api";
import { useSettingsQuery } from "@/lib/query";
const RELEASES_URL = "https://github.com/farion1231/cc-switch/releases";
/** 任一应用的数据库里已经有供应商(找到一家就停) */
async function hasAnyProvider(): Promise<boolean> {
for (const app of APP_IDS) {
try {
const providers = await providersApi.getAll(app);
if (Object.keys(providers).length > 0) return true;
} catch {
// 某个应用读失败不影响其他应用
}
}
return false;
}
/**
* 「界面改版了」一次性弹窗:只给升级上来的老用户看,确认后写 newLayoutNoticeConfirmed。
* - 必须已经确认过首次启动的欢迎弹窗。全新安装时后端会先导入当前配置、再预置官方
* 供应商,单看「有没有供应商」分不出新老用户;新用户点欢迎弹窗的「我知道了」时
* 会把这个字段一起写成 true,以后也不会再看到这里。
* - 数据库里已经有供应商(任一应用)。
* 弹窗盖在页面上,不占布局,晚一点出来也不会推动内容区。
*/
export function NewLayoutDialog() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { data: settings } = useSettingsQuery();
// 点了就先关,不等保存回来
const [closed, setClosed] = useState(false);
const eligible =
settings != null &&
settings.firstRunNoticeConfirmed === true &&
settings.newLayoutNoticeConfirmed !== true;
const { data: hasProviders } = useQuery({
queryKey: ["new-layout-notice", "has-providers"],
queryFn: hasAnyProvider,
enabled: eligible && !closed,
staleTime: Infinity,
});
const isOpen = eligible && !closed && hasProviders === true;
const handleAcknowledge = async () => {
setClosed(true);
if (!settings) return;
try {
const { webdavSync: _, ...rest } = settings;
await settingsApi.save({ ...rest, newLayoutNoticeConfirmed: true });
await queryClient.invalidateQueries({ queryKey: ["settings"] });
} catch (error) {
console.error("Failed to save newLayoutNoticeConfirmed:", error);
}
};
const handleViewReleaseNotes = async () => {
try {
const version = await getVersion();
await settingsApi.openExternal(
version ? `${RELEASES_URL}/tag/v${version}` : RELEASES_URL,
);
} catch (error) {
console.error("Failed to open release notes:", error);
}
await handleAcknowledge();
};
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) void handleAcknowledge();
}}
>
<DialogContent className="max-w-md" zIndex="top">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-fg-1" />
{t("newLayoutNotice.title")}
</DialogTitle>
</DialogHeader>
<div className="px-6 py-5">
<DialogDescription className="whitespace-pre-line leading-relaxed">
{t("newLayoutNotice.body")}
</DialogDescription>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleViewReleaseNotes}>
{t("newLayoutNotice.viewReleaseNotes")}
</Button>
<Button onClick={handleAcknowledge}>
{t("newLayoutNotice.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
-90
View File
@@ -1,90 +0,0 @@
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Notice } from "@/components/ui/notice";
import { APP_IDS } from "@/config/appConfig";
import { providersApi } from "@/lib/api";
import { useSettingsQuery } from "@/lib/query";
export const NEW_LAYOUT_NOTICE_KEY = "cc-switch-new-layout-notice-dismissed";
function readDismissed(): boolean {
try {
return window.localStorage.getItem(NEW_LAYOUT_NOTICE_KEY) === "1";
} catch {
return false;
}
}
function writeDismissed() {
try {
window.localStorage.setItem(NEW_LAYOUT_NOTICE_KEY, "1");
} catch {
// 存不下就只在这次会话里隐藏,下次启动可能再出现一次,不影响使用
}
}
/** 任一应用的数据库里已经有供应商(找到一家就停) */
async function hasAnyProvider(): Promise<boolean> {
for (const app of APP_IDS) {
try {
const providers = await providersApi.getAll(app);
if (Object.keys(providers).length > 0) return true;
} catch {
// 某个应用读失败不影响其他应用
}
}
return false;
}
/**
* 「界面改版了」一次性提示(方案 B6 coach.newLayout、B7 阶段 1):主内容区顶部的
* 中性通知条,关掉后永不再出现。只给老用户看:
* - 数据库里已经有供应商(任一应用);
* - 并且之前已经确认过首次启动的欢迎弹窗。全新安装时后端启动会先导入当前配置、
* 再预置官方供应商,单看「有没有供应商」分不出新老用户;新装用户这次启动会看到
* 欢迎弹窗(firstRunNoticeConfirmed 还不是 true),这时直接记成「已看过」,
* 以后也不再给他看「改版」。
*/
export function NewLayoutNotice() {
const { t } = useTranslation();
const [dismissed, setDismissed] = useState(readDismissed);
const { data: settings } = useSettingsQuery();
const [returningUser, setReturningUser] = useState<boolean | null>(null);
// 只在第一次拿到设置时判断一次:欢迎弹窗确认之后字段会变 true,不能因此再弹
useEffect(() => {
if (dismissed || returningUser !== null || !settings) return;
const returning = settings.firstRunNoticeConfirmed === true;
setReturningUser(returning);
if (!returning) {
writeDismissed();
setDismissed(true);
}
}, [dismissed, returningUser, settings]);
const { data: hasProviders } = useQuery({
queryKey: ["new-layout-notice", "has-providers"],
queryFn: hasAnyProvider,
enabled: !dismissed && returningUser === true,
staleTime: Infinity,
});
if (dismissed || returningUser !== true || hasProviders !== true) {
return null;
}
return (
<div className="shrink-0 px-6 pt-3">
<Notice
tone="neutral"
title={t("coach.newLayout")}
onDismiss={() => {
writeDismissed();
setDismissed(true);
}}
dismissLabel={t("common.close")}
/>
</div>
);
}
+5 -2
View File
@@ -4476,8 +4476,11 @@
"headerUpdatesClear": "Show all Skills",
"updateAllCount": "Update all ({{count}})"
},
"coach": {
"newLayout": "New layout: modes sit at the top of each app page; Usage and Accounts are in the sidebar."
"newLayoutNotice": {
"title": "CC Switch has a new look",
"body": "This version redesigns the interface, so a few things have moved:\n• Modes now sit at the top of each app page\n• Usage and Accounts are in the sidebar\nSee the release notes for everything that changed.",
"viewReleaseNotes": "View release notes",
"confirm": "Got it"
},
"authCenter": {
"helpTitle": "What is Accounts?",
+5 -2
View File
@@ -4476,8 +4476,11 @@
"headerUpdatesClear": "すべての Skill を表示",
"updateAllCount": "すべて更新({{count}} 件)"
},
"coach": {
"newLayout": "画面が新しくなりました:モードは各アプリのページ上部に、使用量とアカウントはサイドバーにあります。"
"newLayoutNotice": {
"title": "画面が新しくなりました",
"body": "このバージョンでは画面を一新し、いくつかの機能の場所が変わりました:\n• モードは各アプリのページ上部に移動しました\n• 使用量とアカウントはサイドバーにあります\n変更点の詳細はリリースノートをご覧ください。",
"viewReleaseNotes": "リリースノートを見る",
"confirm": "OK"
},
"authCenter": {
"helpTitle": "アカウントとは",
+5 -2
View File
@@ -4477,8 +4477,11 @@
"headerUpdatesClear": "顯示全部 Skill",
"updateAllCount": "全部更新({{count}} 個)"
},
"coach": {
"newLayout": "介面改版了:模式移到了每個應用頁的頂部,用量統計和授權中心在左側邊欄。"
"newLayoutNotice": {
"title": "介面改版了",
"body": "CC Switch 這一版重新設計了介面,常用功能的位置有變化:\n• 模式移到了每個應用頁的頂部\n• 用量統計和授權中心移到了左側邊欄\n想了解全部變化,可以看看這一版的更新說明。",
"viewReleaseNotes": "查看更新說明",
"confirm": "我知道了"
},
"authCenter": {
"helpTitle": "授權中心是什麼",
+5 -2
View File
@@ -4476,8 +4476,11 @@
"headerUpdatesClear": "显示全部 Skill",
"updateAllCount": "全部更新({{count}} 个)"
},
"coach": {
"newLayout": "界面改版了:模式移到了每个应用页的顶部,用量统计和授权中心在左侧边栏。"
"newLayoutNotice": {
"title": "界面改版了",
"body": "CC Switch 这一版重新设计了界面,常用功能的位置有变化:\n• 模式移到了每个应用页的顶部\n• 用量统计和授权中心移到了左侧边栏\n想了解全部变化,可以看看这一版的更新说明。",
"viewReleaseNotes": "查看更新说明",
"confirm": "我知道了"
},
"authCenter": {
"helpTitle": "授权中心是什么",
+2
View File
@@ -407,6 +407,8 @@ export interface Settings {
failoverConfirmed?: boolean;
// User has confirmed the first-run welcome notice
firstRunNoticeConfirmed?: boolean;
// User has confirmed the one-time "new layout" dialog shown to upgrading users
newLayoutNoticeConfirmed?: boolean;
// User has confirmed the auto-sync traffic warning
autoSyncConfirmed?: boolean;
// User has confirmed the common config first-run notice
+112
View File
@@ -0,0 +1,112 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
getAll: vi.fn(),
save: vi.fn(),
openExternal: vi.fn(),
settings: undefined as
| { firstRunNoticeConfirmed?: boolean; newLayoutNoticeConfirmed?: boolean }
| undefined,
}));
vi.mock("@/lib/api", () => ({
providersApi: { getAll: mocks.getAll },
settingsApi: { save: mocks.save, openExternal: mocks.openExternal },
}));
vi.mock("@/lib/query", () => ({
useSettingsQuery: () => ({ data: mocks.settings }),
}));
vi.mock("@tauri-apps/api/app", () => ({
getVersion: async () => "4.0.0",
}));
import { NewLayoutDialog } from "@/components/shell/NewLayoutDialog";
function renderDialog() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<NewLayoutDialog />
</QueryClientProvider>,
);
}
describe("NewLayoutDialog", () => {
beforeEach(() => {
mocks.settings = { firstRunNoticeConfirmed: true };
mocks.save.mockReset().mockResolvedValue(true);
mocks.openExternal.mockReset().mockResolvedValue(undefined);
mocks.getAll
.mockReset()
.mockImplementation(async (app: string) =>
app === "codex" ? { p1: { id: "p1" } } : {},
);
});
it("shows to returning users and records the confirmation", async () => {
renderDialog();
expect(
await screen.findByText("newLayoutNotice.title"),
).toBeInTheDocument();
fireEvent.click(
screen.getByRole("button", { name: "newLayoutNotice.confirm" }),
);
await waitFor(() =>
expect(
screen.queryByText("newLayoutNotice.title"),
).not.toBeInTheDocument(),
);
expect(mocks.save).toHaveBeenCalledWith(
expect.objectContaining({ newLayoutNoticeConfirmed: true }),
);
});
it("opens this version's release notes and closes", async () => {
renderDialog();
fireEvent.click(
await screen.findByRole("button", {
name: "newLayoutNotice.viewReleaseNotes",
}),
);
await waitFor(() =>
expect(mocks.openExternal).toHaveBeenCalledWith(
"https://github.com/farion1231/cc-switch/releases/tag/v4.0.0",
),
);
await waitFor(() =>
expect(mocks.save).toHaveBeenCalledWith(
expect.objectContaining({ newLayoutNoticeConfirmed: true }),
),
);
expect(screen.queryByText("newLayoutNotice.title")).not.toBeInTheDocument();
});
it("stays closed once confirmed", async () => {
mocks.settings = {
firstRunNoticeConfirmed: true,
newLayoutNoticeConfirmed: true,
};
renderDialog();
expect(mocks.getAll).not.toHaveBeenCalled();
expect(screen.queryByText("newLayoutNotice.title")).not.toBeInTheDocument();
});
it("stays closed when no app has providers", async () => {
mocks.getAll.mockResolvedValue({});
renderDialog();
await waitFor(() => expect(mocks.getAll).toHaveBeenCalledTimes(10));
expect(screen.queryByText("newLayoutNotice.title")).not.toBeInTheDocument();
});
it("never shows to a fresh install that is seeing the welcome dialog", async () => {
mocks.settings = { firstRunNoticeConfirmed: undefined };
renderDialog();
expect(mocks.getAll).not.toHaveBeenCalled();
expect(screen.queryByText("newLayoutNotice.title")).not.toBeInTheDocument();
});
});
-104
View File
@@ -1,104 +0,0 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
getAll: vi.fn(),
settings: undefined as { firstRunNoticeConfirmed?: boolean } | undefined,
}));
vi.mock("@/lib/api", () => ({
providersApi: { getAll: mocks.getAll },
}));
vi.mock("@/lib/query", () => ({
useSettingsQuery: () => ({ data: mocks.settings }),
}));
import {
NEW_LAYOUT_NOTICE_KEY,
NewLayoutNotice,
} from "@/components/shell/NewLayoutNotice";
function renderNotice() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<NewLayoutNotice />
</QueryClientProvider>,
);
}
describe("NewLayoutNotice", () => {
beforeEach(() => {
window.localStorage.clear();
mocks.settings = { firstRunNoticeConfirmed: true };
mocks.getAll
.mockReset()
.mockImplementation(async (app: string) =>
app === "codex" ? { p1: { id: "p1" } } : {},
);
});
it("shows once for returning users and stays dismissed", async () => {
const view = renderNotice();
expect(await screen.findByText("coach.newLayout")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "common.close" }));
expect(screen.queryByText("coach.newLayout")).not.toBeInTheDocument();
expect(window.localStorage.getItem(NEW_LAYOUT_NOTICE_KEY)).toBe("1");
view.unmount();
renderNotice();
await waitFor(() => expect(mocks.getAll).toHaveBeenCalled());
expect(screen.queryByText("coach.newLayout")).not.toBeInTheDocument();
});
it("stays hidden when no app has providers", async () => {
mocks.getAll.mockResolvedValue({});
renderNotice();
await waitFor(() => expect(mocks.getAll).toHaveBeenCalledTimes(10));
expect(screen.queryByText("coach.newLayout")).not.toBeInTheDocument();
});
it("never shows to a fresh install that is seeing the welcome dialog", async () => {
mocks.settings = { firstRunNoticeConfirmed: undefined };
const view = renderNotice();
await waitFor(() =>
expect(window.localStorage.getItem(NEW_LAYOUT_NOTICE_KEY)).toBe("1"),
);
expect(mocks.getAll).not.toHaveBeenCalled();
// 欢迎弹窗确认之后字段变成 true,也不再补弹「改版」
mocks.settings = { firstRunNoticeConfirmed: true };
view.rerender(
<QueryClientProvider client={new QueryClient()}>
<NewLayoutNotice />
</QueryClientProvider>,
);
expect(screen.queryByText("coach.newLayout")).not.toBeInTheDocument();
});
it("still works when localStorage throws", async () => {
const getItem = vi
.spyOn(Storage.prototype, "getItem")
.mockImplementation(() => {
throw new Error("blocked");
});
const setItem = vi
.spyOn(Storage.prototype, "setItem")
.mockImplementation(() => {
throw new Error("blocked");
});
try {
renderNotice();
expect(await screen.findByText("coach.newLayout")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "common.close" }));
expect(screen.queryByText("coach.newLayout")).not.toBeInTheDocument();
} finally {
getItem.mockRestore();
setItem.mockRestore();
}
});
});