Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 87 additions & 2 deletions src/components/appearance-provider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ import { act, fireEvent, render, screen } from "@testing-library/react"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"

import { AppearanceProvider } from "./appearance-provider"
import { useCustomStyle } from "@/hooks/use-appearance"
import { STORAGE_KEY_CUSTOM_THEME } from "@/lib/appearance-script"
import { useCustomStyle, useSidebarNavVisibility } from "@/hooks/use-appearance"
import {
STORAGE_KEY_CUSTOM_THEME,
STORAGE_KEY_SIDEBAR_NAV_VISIBILITY,
} from "@/lib/appearance-script"

function Probe() {
const { setCustomThemeToken } = useCustomStyle()
Expand Down Expand Up @@ -93,3 +96,85 @@ describe("debounced persistence", () => {
expect(storedPrimary()).toBe("#bbbbbb")
})
})

function NavVisibilityProbe() {
const { sidebarNavVisibility, setSidebarNavItemVisible } =
useSidebarNavVisibility()
return (
<>
<span data-testid="nav-visibility">
{JSON.stringify(sidebarNavVisibility)}
</span>
<button onClick={() => setSidebarNavItemVisible("forge", false)}>
hide-forge
</button>
</>
)
}

describe("sidebar nav visibility", () => {
const renderNavProbe = () =>
render(
<AppearanceProvider>
<NavVisibilityProbe />
</AppearanceProvider>
)

const visibility = () =>
JSON.parse(screen.getByTestId("nav-visibility").textContent ?? "")

it("defaults every row to visible and round-trips a hide through storage", () => {
const { unmount } = renderNavProbe()
expect(visibility()).toEqual({
automations: true,
tasks: true,
forge: true,
})

fireEvent.click(screen.getByText("hide-forge"))
expect(visibility()).toEqual({
automations: true,
tasks: true,
forge: false,
})
// Persisted immediately (a discrete toggle, so no debounce to wait out).
expect(
JSON.parse(localStorage.getItem(STORAGE_KEY_SIDEBAR_NAV_VISIBILITY) ?? "")
).toEqual({ automations: true, tasks: true, forge: false })

// A fresh mount reads the stored record back: the mount effect reconciles
// the SSR-safe all-visible initial state with the persisted choice.
unmount()
renderNavProbe()
expect(visibility()).toEqual({
automations: true,
tasks: true,
forge: false,
})
})

it("follows a change written by another window via the storage event", () => {
// Settings is its own window — without this sync a row hidden there would
// only disappear from the workspace after a reload.
renderNavProbe()
const next = JSON.stringify({
automations: false,
tasks: true,
forge: true,
})
act(() => {
localStorage.setItem(STORAGE_KEY_SIDEBAR_NAV_VISIBILITY, next)
window.dispatchEvent(
new StorageEvent("storage", {
key: STORAGE_KEY_SIDEBAR_NAV_VISIBILITY,
newValue: next,
})
)
})
expect(visibility()).toEqual({
automations: false,
tasks: true,
forge: true,
})
})
})
41 changes: 41 additions & 0 deletions src/components/appearance-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
STORAGE_KEY_THEME_COLOR,
STORAGE_KEY_ZOOM_LEVEL,
STORAGE_KEY_WELCOME_QUICK_ACTIONS,
STORAGE_KEY_SIDEBAR_NAV_VISIBILITY,
STORAGE_KEY_UI_FONT,
STORAGE_KEY_UI_FONT_CUSTOM,
STORAGE_KEY_UI_FONT_STACK,
Expand Down Expand Up @@ -66,6 +67,12 @@ import {
} from "@/lib/custom-style"
import { useShortcutSettings } from "@/hooks/use-shortcut-settings"
import { matchShortcutEvent } from "@/lib/keyboard-shortcuts"
import {
DEFAULT_SIDEBAR_NAV_VISIBILITY,
parseSidebarNavVisibility,
type HideableSidebarNavId,
type SidebarNavVisibility,
} from "@/lib/sidebar-nav-visibility"
import {
DEFAULT_WORKSPACE_BG_ENABLED,
DEFAULT_WORKSPACE_BG_MASK_OPACITY,
Expand Down Expand Up @@ -110,6 +117,10 @@ type AppearanceContextValue = {
/** 新会话欢迎页是否显示「模式选择区域」(QuickActions 快捷卡片),默认开启 */
showWelcomeQuickActions: boolean
setShowWelcomeQuickActions: (on: boolean) => void
/** Which of the sidebar's fixed nav rows (Automations / To-dos / Repository
* panel) are shown. Every row defaults to visible. */
sidebarNavVisibility: SidebarNavVisibility
setSidebarNavItemVisible: (id: HideableSidebarNavId, visible: boolean) => void
/** 界面字体(普通组件,驱动 --font-sans) */
uiFont: FontSelection
setUiFont: (id: string, custom?: string) => void
Expand Down Expand Up @@ -356,6 +367,20 @@ export function AppearanceProvider({
const [showWelcomeQuickActions, setShowWelcomeQuickActionsState] =
useState<boolean>(() => readBool(STORAGE_KEY_WELCOME_QUICK_ACTIONS, true))

// Sidebar fixed-nav visibility starts at the all-visible default and picks
// up the stored record in a mount effect below. Unlike the welcome quick
// actions (client-only), the nav rows are part of the server-rendered
// markup, so reading localStorage in the initializer would desync SSR/CSR
// markup for anyone who hid a row.
const [sidebarNavVisibility, setSidebarNavVisibilityState] =
useState<SidebarNavVisibility>(DEFAULT_SIDEBAR_NAV_VISIBILITY)

useEffect(() => {
setSidebarNavVisibilityState(
parseSidebarNavVisibility(readStored(STORAGE_KEY_SIDEBAR_NAV_VISIBILITY))
)
}, [])

// 字体偏好的初始值从 localStorage 读 id/custom(视觉已由 inline 脚本就位,
// 这里只是回填选中态,不会造成闪烁)。
const [uiFont, setUiFontState] = useState<FontSelection>(() =>
Expand Down Expand Up @@ -478,6 +503,15 @@ export function AppearanceProvider({
persist(STORAGE_KEY_WELCOME_QUICK_ACTIONS, on ? "1" : "0")
}, [])

const setSidebarNavItemVisible = useCallback(
(id: HideableSidebarNavId, visible: boolean) => {
const next = { ...sidebarNavVisibility, [id]: visible }
setSidebarNavVisibilityState(next)
persist(STORAGE_KEY_SIDEBAR_NAV_VISIBILITY, JSON.stringify(next))
},
[sidebarNavVisibility]
)

const setUiFont = useCallback((id: string, custom = "") => {
setUiFontState({ id, custom })
const stack = resolveFontStack(id, custom, "sans")
Expand Down Expand Up @@ -829,6 +863,11 @@ export function AppearanceProvider({
readBool(STORAGE_KEY_WELCOME_QUICK_ACTIONS, true)
)
}
// Settings is its own window: a nav row hidden there reaches the
// workspace window through this event. Removal (null) = defaults.
if (e.key === STORAGE_KEY_SIDEBAR_NAV_VISIBILITY) {
setSidebarNavVisibilityState(parseSidebarNavVisibility(e.newValue))
}
if (e.key && FONT_KEYS.has(e.key)) {
rehydrateFonts()
}
Expand Down Expand Up @@ -927,6 +966,8 @@ export function AppearanceProvider({
setZoomLevel,
showWelcomeQuickActions,
setShowWelcomeQuickActions,
sidebarNavVisibility,
setSidebarNavItemVisible,
uiFont,
setUiFont,
editorFont,
Expand Down
48 changes: 48 additions & 0 deletions src/components/layout/sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ const spies = vi.hoisted(() => ({
}))
const mockState = vi.hoisted(() => ({
activeFolder: { id: 7, path: "/x" } as { id: number; path: string } | null,
// Settings → Appearance visibility of the fixed nav rows (default: all on).
navVisibility: { automations: true, tasks: true, forge: true },
}))

// The conversation list is irrelevant here — stub it so the test exercises only
Expand Down Expand Up @@ -82,6 +84,10 @@ vi.mock("@/hooks/use-shortcut-settings", () => ({
vi.mock("@/hooks/use-mobile", () => ({ useIsMobile: () => false }))
vi.mock("@/hooks/use-appearance", () => ({
useZoomLevel: () => ({ zoomLevel: 100, setZoomLevel: () => {} }),
useSidebarNavVisibility: () => ({
sidebarNavVisibility: mockState.navVisibility,
setSidebarNavItemVisible: vi.fn(),
}),
}))

function renderSidebar() {
Expand Down Expand Up @@ -147,6 +153,48 @@ describe("Sidebar — fixed nav region", () => {
})
})

describe("Sidebar — fixed nav visibility (Settings → Appearance)", () => {
beforeEach(() => {
mockState.navVisibility = { automations: true, tasks: true, forge: true }
mockState.activeFolder = { id: 7, path: "/x" }
})

it("shows every route row by default", () => {
const { getByText } = renderSidebar()
expect(getByText("Automations")).toBeTruthy()
expect(getByText("To-dos")).toBeTruthy()
expect(getByText("Repository panel")).toBeTruthy()
})

it("unmounts a hidden row entirely, leaving its neighbours alone", () => {
mockState.navVisibility = { automations: false, tasks: true, forge: true }
const { getByText, queryByText } = renderSidebar()
// Fully absent from the DOM — not CSS-hidden, so nothing focusable or
// announceable is left behind.
expect(queryByText("Automations")).toBeNull()
expect(getByText("To-dos")).toBeTruthy()
expect(getByText("Repository panel")).toBeTruthy()
})

it("hides the Repository panel row together with its Beta badge", () => {
mockState.navVisibility = { automations: true, tasks: true, forge: false }
const { queryByText } = renderSidebar()
expect(queryByText("Repository panel")).toBeNull()
expect(queryByText("Beta")).toBeNull()
})

it("keeps New chat even with every hideable row hidden", () => {
mockState.navVisibility = { automations: false, tasks: false, forge: false }
const { getByText, queryByText } = renderSidebar()
// New chat is deliberately not hideable — the primary entry point into the
// workspace survives any combination of the toggles.
expect(getByText("New chat")).toBeTruthy()
expect(queryByText("Automations")).toBeNull()
expect(queryByText("To-dos")).toBeNull()
expect(queryByText("Repository panel")).toBeNull()
})
})

describe("Sidebar — Show worktree folders toggle", () => {
beforeEach(() => {
localStorage.clear()
Expand Down
101 changes: 56 additions & 45 deletions src/components/layout/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import {
import { useIsMobile } from "@/hooks/use-mobile"
import { useIsMac } from "@/hooks/use-is-mac"
import { usePlatform } from "@/hooks/use-platform"
import { useZoomLevel } from "@/hooks/use-appearance"
import { useSidebarNavVisibility, useZoomLevel } from "@/hooks/use-appearance"
import { useShortcutSettings } from "@/hooks/use-shortcut-settings"
import { formatShortcutLabel } from "@/lib/keyboard-shortcuts"
import { isDesktop } from "@/lib/platform"
Expand Down Expand Up @@ -138,6 +138,11 @@ export function Sidebar() {
const isMac = useIsMac()
const { isMac: platformIsMac } = usePlatform()
const { zoomLevel } = useZoomLevel()
// Settings → Appearance controls which route rows render below. New chat is
// exempt on purpose: it is the primary entry point and never hides. Hidden
// routes stay reachable from the status bar's quick-actions menu, which
// exists precisely to be the always-on path to every workbench route.
const { sidebarNavVisibility } = useSidebarNavVisibility()
const { shortcuts } = useShortcutSettings()
const isMobile = useIsMobile()
const listRef = useRef<SidebarConversationListHandle>(null)
Expand Down Expand Up @@ -447,50 +452,56 @@ export function Sidebar() {
{/* Both route rows close the mobile Sheet on the way out, like tapping a
conversation card (handled by the list wrapper below) — otherwise the
page they just opened stays hidden behind the sidebar. */}
<SidebarNavButton
icon={Zap}
label={t("automations")}
active={routeId === "automations"}
onClick={() => {
if (isMobile) toggle()
setRoute("automations")
}}
trailing={
unseenFailures > 0 ? (
<span className="ml-auto inline-flex h-[0.9375rem] min-w-[0.9375rem] shrink-0 items-center justify-center rounded-full bg-destructive/15 px-1 font-mono text-[0.625rem] font-medium leading-none text-destructive">
{unseenFailures}
</span>
) : null
}
/>
<SidebarNavButton
icon={ListTodo}
label={t("tasks")}
active={routeId === "tasks"}
onClick={() => {
if (isMobile) toggle()
setRoute("tasks")
}}
trailing={
attentionCount > 0 ? (
// Attention (not failure): tasks waiting on the user — primary
// tint like the shortcut chips, not destructive.
<span className="ml-auto inline-flex h-[0.9375rem] min-w-[0.9375rem] shrink-0 items-center justify-center rounded-full bg-primary/10 px-1 font-mono text-[0.625rem] font-medium leading-none text-primary">
{attentionCount}
</span>
) : null
}
/>
<SidebarNavButton
icon={LayoutTemplate}
label={t("forge")}
active={routeId === "forge"}
onClick={() => {
if (isMobile) toggle()
setRoute("forge")
}}
trailing={<ForgeBetaBadge className="ml-auto" />}
/>
{sidebarNavVisibility.automations && (
<SidebarNavButton
icon={Zap}
label={t("automations")}
active={routeId === "automations"}
onClick={() => {
if (isMobile) toggle()
setRoute("automations")
}}
trailing={
unseenFailures > 0 ? (
<span className="ml-auto inline-flex h-[0.9375rem] min-w-[0.9375rem] shrink-0 items-center justify-center rounded-full bg-destructive/15 px-1 font-mono text-[0.625rem] font-medium leading-none text-destructive">
{unseenFailures}
</span>
) : null
}
/>
)}
{sidebarNavVisibility.tasks && (
<SidebarNavButton
icon={ListTodo}
label={t("tasks")}
active={routeId === "tasks"}
onClick={() => {
if (isMobile) toggle()
setRoute("tasks")
}}
trailing={
attentionCount > 0 ? (
// Attention (not failure): tasks waiting on the user — primary
// tint like the shortcut chips, not destructive.
<span className="ml-auto inline-flex h-[0.9375rem] min-w-[0.9375rem] shrink-0 items-center justify-center rounded-full bg-primary/10 px-1 font-mono text-[0.625rem] font-medium leading-none text-primary">
{attentionCount}
</span>
) : null
}
/>
)}
{sidebarNavVisibility.forge && (
<SidebarNavButton
icon={LayoutTemplate}
label={t("forge")}
active={routeId === "forge"}
onClick={() => {
if (isMobile) toggle()
setRoute("forge")
}}
trailing={<ForgeBetaBadge className="ml-auto" />}
/>
)}
</div>

{/* On mobile, clicking a conversation card auto-closes the Sheet */}
Expand Down
Loading
Loading