feat(ui): move to a sidebar shell with global pages and grouped settings

Replace the header toolbar and icon-only app switcher with the v7 shell:

- Sidebar (200px, collapses to a 72px icon rail under 960px, Cmd/Ctrl+\
  toggles and remembers the choice): the app list with mode tags and
  "needs attention" dots, then Usage (today's cost), Accounts (re-login
  dot), MCP, Skills, Prompts and Sessions pinned to the bottom, and an
  Apps / Settings footer. Settings swaps the sidebar for its own
  directory.
- Every screen gets a 52px page header; Linux in-app window controls
  sit at its far right. macOS traffic lights move down to centre in the
  44px sidebar top bar.
- Usage, Accounts, MCP, Skills, Prompts (with an app picker) and
  Sessions become global pages instead of settings tabs or per-app
  toolbar buttons. OpenClaw and Hermes switch to their own pages with a
  segmented control; OpenClaw's env, tools and agents merge into one
  Config page.
- New Apps page: per-app install / upgrade with path and install
  source, batch upgrade, conflict details, manual commands, and the
  "show in sidebar" switch that used to live in settings. Tool
  management moves out of About into a shared hook; a read-only
  list_tool_installations command feeds the page so it does not share
  calls with the upgrade preflight.
- Settings regroup into General, App config, Local routing, Network,
  Data and About, using row/card layout. Path settings save per
  section; switches still save immediately. Skills storage and sync
  move to a sheet on the Skills page.
- New opt-in "check app updates on startup" setting (off by default):
  when on, a background check puts a neutral dot on Apps.
- Switch adopts the v7 track and action colour; env-conflict banner
  joins the page flow instead of overlaying the header.

The old route / Stack / failover toggles stay in the provider page
header until the mode tabs land.
This commit is contained in:
Jason committed 2026-10-03 23:28:58 +08:00
1 parent 947a8a8b06
commit 40aa2864a2
53 files changed
+5574 -4295

No files matched your search

+21 -20
View File
@@ -1,6 +1,12 @@
import { Suspense, type ComponentType } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import {
render,
screen,
waitFor,
fireEvent,
within,
} from "@testing-library/react";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { http, HttpResponse } from "msw";
import { providersApi } from "@/lib/api/providers";
@@ -132,15 +138,8 @@ vi.mock("@/components/ConfirmDialog", () => ({
) : null,
}));
vi.mock("@/components/AppSwitcher", () => ({
AppSwitcher: ({ activeApp, onSwitch }: any) => (
<div data-testid="app-switcher">
<span>{activeApp}</span>
<button onClick={() => onSwitch("claude")}>switch-claude</button>
<button onClick={() => onSwitch("codex")}>switch-codex</button>
<button onClick={() => onSwitch("openclaw")}>switch-openclaw</button>
</div>
),
vi.mock("@/contexts/UpdateContext", () => ({
useUpdate: () => ({ hasUpdate: false, updateInfo: null }),
}));
vi.mock("@/components/skills/UnifiedSkillsPanel", async () => {
@@ -169,12 +168,6 @@ vi.mock("@/components/skills/UnifiedSkillsPanel", async () => {
return { default: MockUnifiedSkillsPanel };
});
vi.mock("@/components/UpdateBadge", () => ({
UpdateBadge: ({ onClick }: any) => (
<button onClick={onClick}>update-badge</button>
),
}));
vi.mock("@/components/mcp/McpPanel", () => ({
default: ({ open, onOpenChange }: any) =>
open ? (
@@ -186,6 +179,14 @@ vi.mock("@/components/mcp/McpPanel", () => ({
),
}));
/** 侧栏里的应用行(v7 侧栏取代了原来页头的应用切换器) */
const sidebarApp = (name: string) =>
// 首次启动提示是模态对话框,会把侧栏标成 aria-hidden,所以带上 hidden
within(document.querySelector("nav") as HTMLElement).getByRole("button", {
name,
hidden: true,
});
const renderApp = (AppComponent: ComponentType) => {
const client = new QueryClient();
return render(
@@ -218,7 +219,7 @@ describe("App integration with MSW", () => {
),
);
fireEvent.click(screen.getByText("switch-codex"));
fireEvent.click(sidebarApp("Codex"));
await waitFor(() =>
expect(screen.getByTestId("provider-list").textContent).toContain(
"codex-1",
@@ -291,7 +292,7 @@ describe("App integration with MSW", () => {
providerScrollContainer!.scrollTop = 640;
providerScrollContainer!.scrollLeft = 24;
fireEvent.click(screen.getByText("switch-codex"));
fireEvent.click(sidebarApp("Codex"));
await waitFor(() =>
expect(screen.getByTestId("provider-list").textContent).toContain(
@@ -369,7 +370,7 @@ describe("App integration with MSW", () => {
const { default: App } = await import("@/App");
renderApp(App);
fireEvent.click(screen.getByText("switch-openclaw"));
fireEvent.click(sidebarApp("OpenClaw"));
await waitFor(() =>
expect(screen.getByTestId("provider-list").textContent).toContain(
@@ -629,7 +630,7 @@ describe("App integration with MSW", () => {
const { default: App } = await import("@/App");
renderApp(App);
fireEvent.click(screen.getByText("switch-openclaw"));
fireEvent.click(sidebarApp("OpenClaw"));
await waitFor(() =>
expect(screen.getByTestId("provider-list").textContent).toContain(