diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 6862f3854d..6cb84b07cf 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -16,6 +16,7 @@ import { readPersistedState, } from "./hooks/usePersistedState"; import { useResizableSidebar } from "./hooks/useResizableSidebar"; +import { useAutoHideSidebar } from "./hooks/useAutoHideSidebar"; import { matchesKeybind, KEYBINDS } from "./utils/ui/keybinds"; import { handleLayoutSlotHotkeys } from "./utils/ui/layoutSlotHotkeys"; import { buildSortedWorkspacesByProject } from "./utils/ui/workspaceFiltering"; @@ -187,6 +188,10 @@ function AppInner() { } ); + const autoHideSidebar = useAutoHideSidebar(); + const [sidebarHovered, setSidebarHovered] = useState(false); + const effectiveSidebarCollapsed = autoHideSidebar ? !sidebarHovered : sidebarCollapsed; + const [isMultiProjectWorkspaceModalOpen, setMultiProjectWorkspaceModalOpen] = useState(false); const multiProjectWorkspacesEnabled = useExperimentValue(EXPERIMENT_IDS.MULTI_PROJECT_WORKSPACES); @@ -220,8 +225,8 @@ function AppInner() { // Sync sidebar collapse state to the root element for non-React consumers like // Storybook play helpers that need to know whether the sidebar is currently collapsed. useEffect(() => { - document.documentElement.dataset.leftSidebarCollapsed = String(sidebarCollapsed); - }, [sidebarCollapsed]); + document.documentElement.dataset.leftSidebarCollapsed = String(effectiveSidebarCollapsed); + }, [effectiveSidebarCollapsed]); const creationProjectPath = !selectedWorkspace && !currentWorkspaceId ? pendingNewWorkspaceProject : null; @@ -1070,8 +1075,10 @@ function AppInner() { <>
void; + /** When true, attach hover handlers so the parent can expand/collapse on mouse enter/leave. */ + autoHideEnabled?: boolean; + onHoverChange?: (hovered: boolean) => void; widthPx?: number; isResizing?: boolean; onStartResize?: (e: React.MouseEvent) => void; @@ -20,6 +23,8 @@ export function LeftSidebar(props: LeftSidebarProps) { const { collapsed, onToggleCollapsed, + autoHideEnabled, + onHoverChange, widthPx, isResizing, onStartResize, @@ -57,6 +62,8 @@ export function LeftSidebar(props: LeftSidebarProps) { {/* Sidebar */}
onHoverChange?.(true) : undefined} + onMouseLeave={autoHideEnabled ? () => onHoverChange?.(false) : undefined} className={cn( "h-full bg-sidebar border-r border-border flex flex-col shrink-0 overflow-hidden relative z-20", !isResizing && "transition-[width] duration-200", diff --git a/src/browser/features/Settings/Sections/GeneralSection.test.tsx b/src/browser/features/Settings/Sections/GeneralSection.test.tsx index e0fa9a38cb..3b8aafc93a 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.test.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.test.tsx @@ -18,6 +18,7 @@ interface MockConfig { coderWorkspaceArchiveBehavior: CoderWorkspaceArchiveBehavior; worktreeArchiveBehavior: WorktreeArchiveBehavior; chatTranscriptFullWidth: boolean; + autoHideSidebar: boolean; llmDebugLogs: boolean; } @@ -29,6 +30,7 @@ interface MockAPIClient { worktreeArchiveBehavior: WorktreeArchiveBehavior; }) => Promise; updateChatTranscriptFullWidth: (input: { enabled: boolean }) => Promise; + updateAutoHideSidebar: (input: { enabled: boolean }) => Promise; updateLlmDebugLogs: (input: { enabled: boolean }) => Promise; }; server: { @@ -171,6 +173,7 @@ interface RenderGeneralSectionOptions { coderWorkspaceArchiveBehavior?: CoderWorkspaceArchiveBehavior; worktreeArchiveBehavior?: WorktreeArchiveBehavior; chatTranscriptFullWidth?: boolean; + autoHideSidebar?: boolean; } interface MockAPISetup { @@ -187,6 +190,9 @@ interface MockAPISetup { updateChatTranscriptFullWidthMock: ReturnType< typeof mock<(input: { enabled: boolean }) => Promise> >; + updateAutoHideSidebarMock: ReturnType< + typeof mock<(input: { enabled: boolean }) => Promise> + >; } function createMockAPI(configOverrides: Partial = {}): MockAPISetup { @@ -194,6 +200,7 @@ function createMockAPI(configOverrides: Partial = {}): MockAPISetup coderWorkspaceArchiveBehavior: DEFAULT_CODER_ARCHIVE_BEHAVIOR, worktreeArchiveBehavior: DEFAULT_WORKTREE_ARCHIVE_BEHAVIOR, chatTranscriptFullWidth: false, + autoHideSidebar: false, llmDebugLogs: false, ...configOverrides, }; @@ -217,12 +224,19 @@ function createMockAPI(configOverrides: Partial = {}): MockAPISetup return Promise.resolve(); }); + const updateAutoHideSidebarMock = mock(({ enabled }: { enabled: boolean }) => { + config.autoHideSidebar = enabled; + + return Promise.resolve(); + }); + return { api: { config: { getConfig: getConfigMock, updateCoderPrefs: updateCoderPrefsMock, updateChatTranscriptFullWidth: updateChatTranscriptFullWidthMock, + updateAutoHideSidebar: updateAutoHideSidebarMock, updateLlmDebugLogs: mock(({ enabled }: { enabled: boolean }) => { config.llmDebugLogs = enabled; @@ -241,6 +255,7 @@ function createMockAPI(configOverrides: Partial = {}): MockAPISetup getConfigMock, updateCoderPrefsMock, updateChatTranscriptFullWidthMock, + updateAutoHideSidebarMock, }; } @@ -263,8 +278,14 @@ describe("GeneralSection", () => { }); function renderGeneralSection(options: RenderGeneralSectionOptions = {}) { - const { api, updateCoderPrefsMock, updateChatTranscriptFullWidthMock } = createMockAPI({ + const { + api, + updateCoderPrefsMock, + updateChatTranscriptFullWidthMock, + updateAutoHideSidebarMock, + } = createMockAPI({ chatTranscriptFullWidth: options.chatTranscriptFullWidth, + autoHideSidebar: options.autoHideSidebar, coderWorkspaceArchiveBehavior: options.coderWorkspaceArchiveBehavior, worktreeArchiveBehavior: options.worktreeArchiveBehavior, }); @@ -276,7 +297,12 @@ describe("GeneralSection", () => { ); - return { updateCoderPrefsMock, updateChatTranscriptFullWidthMock, view }; + return { + updateCoderPrefsMock, + updateChatTranscriptFullWidthMock, + updateAutoHideSidebarMock, + view, + }; } function getSelectTrigger(view: ReturnType, label: string): HTMLElement { @@ -353,6 +379,24 @@ describe("GeneralSection", () => { }); }); + test("loads and persists the auto-hide sidebar toggle", async () => { + const { updateAutoHideSidebarMock, view } = renderGeneralSection({ + autoHideSidebar: true, + }); + + const toggle = view.getByRole("switch", { name: "Toggle automatic sidebar hiding" }); + await waitFor(() => { + expect(toggle.getAttribute("aria-checked")).toBe("true"); + }); + + fireEvent.click(toggle); + + await waitFor(() => { + expect(toggle.getAttribute("aria-checked")).toBe("false"); + expect(updateAutoHideSidebarMock).toHaveBeenCalledWith({ enabled: false }); + }); + }); + test("renders the worktree archive behavior copy and loads the saved value", async () => { const { view } = renderGeneralSection({ coderWorkspaceArchiveBehavior: "delete", diff --git a/src/browser/features/Settings/Sections/GeneralSection.tsx b/src/browser/features/Settings/Sections/GeneralSection.tsx index b5a9d59feb..b9095cf22e 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.tsx @@ -21,6 +21,7 @@ import { BASH_COLLAPSED_SUMMARY_MODE_KEY, BASH_COLLAPSED_SUMMARY_MODES, CHAT_TRANSCRIPT_FULL_WIDTH_KEY, + AUTO_HIDE_SIDEBAR_KEY, DEFAULT_BASH_COLLAPSED_SUMMARY_MODE, normalizeBashCollapsedSummaryMode, type BashCollapsedSummaryMode, @@ -226,6 +227,7 @@ export function GeneralSection() { ); const [archiveSettingsLoaded, setArchiveSettingsLoaded] = useState(false); const [chatTranscriptFullWidth, setChatTranscriptFullWidth] = useState(false); + const [autoHideSidebar, setAutoHideSidebar] = useState(false); const [llmDebugLogs, setLlmDebugLogs] = useState(false); const archiveBehaviorLoadNonceRef = useRef(0); const archiveBehaviorRef = useRef(DEFAULT_CODER_ARCHIVE_BEHAVIOR); @@ -234,12 +236,14 @@ export function GeneralSection() { ); const chatTranscriptFullWidthLoadNonceRef = useRef(0); + const autoHideSidebarLoadNonceRef = useRef(0); const llmDebugLogsLoadNonceRef = useRef(0); // updateCoderPrefs writes config.json on the backend. Serialize (and coalesce) updates so rapid // selections can't race and persist a stale value via out-of-order writes. const archiveBehaviorUpdateChainRef = useRef>(Promise.resolve()); const chatTranscriptFullWidthUpdateChainRef = useRef>(Promise.resolve()); + const autoHideSidebarUpdateChainRef = useRef>(Promise.resolve()); const llmDebugLogsUpdateChainRef = useRef>(Promise.resolve()); const archiveBehaviorPendingUpdateRef = useRef( undefined @@ -256,6 +260,7 @@ export function GeneralSection() { setArchiveSettingsLoaded(false); const archiveBehaviorNonce = ++archiveBehaviorLoadNonceRef.current; const chatTranscriptFullWidthNonce = ++chatTranscriptFullWidthLoadNonceRef.current; + const autoHideSidebarNonce = ++autoHideSidebarLoadNonceRef.current; const llmDebugLogsNonce = ++llmDebugLogsLoadNonceRef.current; void api.config @@ -289,6 +294,15 @@ export function GeneralSection() { ); } + if (autoHideSidebarNonce === autoHideSidebarLoadNonceRef.current) { + const enabled = cfg.autoHideSidebar === true; + setAutoHideSidebar(enabled); + updatePersistedState( + AUTO_HIDE_SIDEBAR_KEY, + enabled ? true : undefined + ); + } + if (llmDebugLogsNonce === llmDebugLogsLoadNonceRef.current) { setLlmDebugLogs(cfg.llmDebugLogs === true); } @@ -398,6 +412,26 @@ export function GeneralSection() { }); }; + const handleAutoHideSidebarChange = (checked: boolean) => { + // Invalidate any in-flight config load so it does not overwrite the user's selection. + autoHideSidebarLoadNonceRef.current++; + setAutoHideSidebar(checked); + updatePersistedState(AUTO_HIDE_SIDEBAR_KEY, checked ? true : undefined); + + if (!api?.config?.updateAutoHideSidebar) { + return; + } + + autoHideSidebarUpdateChainRef.current = autoHideSidebarUpdateChainRef.current + .catch(() => { + // Best-effort only. + }) + .then(() => api.config.updateAutoHideSidebar({ enabled: checked })) + .catch(() => { + // Best-effort persistence. + }); + }; + const handleLlmDebugLogsChange = (checked: boolean) => { // Invalidate any in-flight debug-log load so it doesn't overwrite the user's selection. llmDebugLogsLoadNonceRef.current++; @@ -536,6 +570,18 @@ export function GeneralSection() {
+
+
+
Automatically hide sidebar
+
Collapse the left sidebar when not hovered.
+
+ +
+
Launch behavior
diff --git a/src/browser/hooks/useAutoHideSidebar.test.tsx b/src/browser/hooks/useAutoHideSidebar.test.tsx new file mode 100644 index 0000000000..db6c5ba43f --- /dev/null +++ b/src/browser/hooks/useAutoHideSidebar.test.tsx @@ -0,0 +1,297 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import type React from "react"; +import { installDom } from "../../../tests/ui/dom"; + +import { APIProvider, type APIClient } from "@/browser/contexts/API"; +import { createControllableAsyncIterable } from "@/browser/testUtils"; +import { updatePersistedState } from "@/browser/hooks/usePersistedState"; +import { AUTO_HIDE_SIDEBAR_KEY } from "@/common/constants/storage"; + +import { useAutoHideSidebar } from "./useAutoHideSidebar"; + +interface AutoHideSidebarConfig { + autoHideSidebar: boolean; +} + +function createConfigEventStream() { + const returnMock = mock(() => undefined); + const stream = createControllableAsyncIterable({ onReturn: returnMock }); + + return { + emit(value: unknown = Symbol("config-change")) { + stream.push(value); + }, + iterator: stream.iterable, + returnMock, + }; +} + +function createWrapper(client: APIClient): React.FC<{ children: React.ReactNode }> { + return function Wrapper(props) { + return {props.children}; + }; +} + +describe("useAutoHideSidebar", () => { + let cleanupDom: (() => void) | null = null; + + beforeEach(() => { + cleanupDom = installDom(); + }); + + afterEach(() => { + cleanup(); + updatePersistedState(AUTO_HIDE_SIDEBAR_KEY, undefined); + cleanupDom?.(); + cleanupDom = null; + }); + + test("uses the cached preference until backend config resolves", async () => { + updatePersistedState(AUTO_HIDE_SIDEBAR_KEY, true); + const stream = createConfigEventStream(); + const getConfigMock = mock(() => + Promise.resolve({ autoHideSidebar: true }) + ); + const onConfigChangedMock = mock(() => Promise.resolve(stream.iterator)); + const client = { + config: { + getConfig: getConfigMock, + onConfigChanged: onConfigChangedMock, + }, + } as unknown as APIClient; + + const { result, unmount } = renderHook(() => useAutoHideSidebar(), { + wrapper: createWrapper(client), + }); + + expect(result.current).toBe(true); + await waitFor(() => { + expect(getConfigMock).toHaveBeenCalledTimes(1); + expect(onConfigChangedMock).toHaveBeenCalledTimes(1); + }); + + unmount(); + + await waitFor(() => { + expect(stream.returnMock).toHaveBeenCalled(); + }); + }); + + test("ignores stale config fetches after a newer subscription refresh", async () => { + const firstFetch = Promise.withResolvers(); + const secondFetch = Promise.withResolvers(); + const stream = createConfigEventStream(); + const getConfigMock = mock(() => { + if (getConfigMock.mock.calls.length === 1) { + return firstFetch.promise; + } + + return secondFetch.promise; + }); + const client = { + config: { + getConfig: getConfigMock, + onConfigChanged: mock(() => Promise.resolve(stream.iterator)), + }, + } as unknown as APIClient; + + const { result } = renderHook(() => useAutoHideSidebar(), { + wrapper: createWrapper(client), + }); + + expect(result.current).toBe(false); + await waitFor(() => { + expect(getConfigMock).toHaveBeenCalledTimes(1); + }); + + act(() => { + stream.emit(); + }); + await waitFor(() => { + expect(getConfigMock).toHaveBeenCalledTimes(2); + }); + + await act(async () => { + secondFetch.resolve({ autoHideSidebar: true }); + await secondFetch.promise; + }); + await waitFor(() => { + expect(result.current).toBe(true); + }); + + await act(async () => { + firstFetch.resolve({ autoHideSidebar: false }); + await firstFetch.promise; + }); + + expect(result.current).toBe(true); + }); + + test("accepts a newer backend refresh after a backend-driven cache update", async () => { + const firstFetch = Promise.withResolvers(); + const secondFetch = Promise.withResolvers(); + const stream = createConfigEventStream(); + const getConfigMock = mock(() => { + if (getConfigMock.mock.calls.length === 1) { + return firstFetch.promise; + } + + return secondFetch.promise; + }); + const client = { + config: { + getConfig: getConfigMock, + onConfigChanged: mock(() => Promise.resolve(stream.iterator)), + }, + } as unknown as APIClient; + + const { result } = renderHook(() => useAutoHideSidebar(), { + wrapper: createWrapper(client), + }); + + await waitFor(() => { + expect(getConfigMock).toHaveBeenCalledTimes(1); + }); + + await act(async () => { + firstFetch.resolve({ autoHideSidebar: true }); + await firstFetch.promise; + stream.emit(); + }); + await waitFor(() => { + expect(getConfigMock).toHaveBeenCalledTimes(2); + }); + + await act(async () => { + secondFetch.resolve({ autoHideSidebar: false }); + await secondFetch.promise; + }); + + await waitFor(() => { + expect(result.current).toBe(false); + }); + }); + + test("keeps a local persisted update when an older backend fetch resolves", async () => { + const fetch = Promise.withResolvers(); + const client = { + config: { + getConfig: mock(() => fetch.promise), + onConfigChanged: mock(() => Promise.resolve(createConfigEventStream().iterator)), + }, + } as unknown as APIClient; + + const { result } = renderHook(() => useAutoHideSidebar(), { + wrapper: createWrapper(client), + }); + + expect(result.current).toBe(false); + + act(() => { + updatePersistedState(AUTO_HIDE_SIDEBAR_KEY, true); + }); + await waitFor(() => { + expect(result.current).toBe(true); + }); + + await act(async () => { + fetch.resolve({ autoHideSidebar: false }); + await fetch.promise; + }); + + expect(result.current).toBe(true); + expect(window.localStorage.getItem(AUTO_HIDE_SIDEBAR_KEY)).toBe(JSON.stringify(true)); + }); + + test("ignores invalid cached preference values", () => { + updatePersistedState(AUTO_HIDE_SIDEBAR_KEY, "false"); + const getConfig = Promise.withResolvers(); + const client = { + config: { + getConfig: mock(() => getConfig.promise), + onConfigChanged: mock(() => Promise.resolve(createConfigEventStream().iterator)), + }, + } as unknown as APIClient; + + const { result } = renderHook(() => useAutoHideSidebar(), { + wrapper: createWrapper(client), + }); + + expect(result.current).toBe(false); + }); + + test("responds to persisted preference updates while mounted", async () => { + const getConfig = Promise.withResolvers(); + const client = { + config: { + getConfig: mock(() => getConfig.promise), + onConfigChanged: mock(() => Promise.resolve(createConfigEventStream().iterator)), + }, + } as unknown as APIClient; + + const { result } = renderHook(() => useAutoHideSidebar(), { + wrapper: createWrapper(client), + }); + + expect(result.current).toBe(false); + + act(() => { + updatePersistedState(AUTO_HIDE_SIDEBAR_KEY, true); + }); + + await waitFor(() => { + expect(result.current).toBe(true); + }); + }); + + test("caches backend config for the next mount", async () => { + const firstFetch = Promise.withResolvers(); + const secondFetch = Promise.withResolvers(); + const getConfigMock = mock(() => { + if (getConfigMock.mock.calls.length === 1) { + return firstFetch.promise; + } + + return secondFetch.promise; + }); + const client = { + config: { + getConfig: getConfigMock, + onConfigChanged: mock(() => Promise.resolve(createConfigEventStream().iterator)), + }, + } as unknown as APIClient; + + const firstRender = renderHook(() => useAutoHideSidebar(), { + wrapper: createWrapper(client), + }); + + expect(firstRender.result.current).toBe(false); + await act(async () => { + firstFetch.resolve({ autoHideSidebar: true }); + await firstFetch.promise; + }); + await waitFor(() => { + expect(firstRender.result.current).toBe(true); + }); + + firstRender.unmount(); + + const secondRender = renderHook(() => useAutoHideSidebar(), { + wrapper: createWrapper(client), + }); + + expect(secondRender.result.current).toBe(true); + }); + + test("preserves the last known value while API config is unavailable", () => { + updatePersistedState(AUTO_HIDE_SIDEBAR_KEY, true); + const client = { config: {} } as unknown as APIClient; + + const { result } = renderHook(() => useAutoHideSidebar(), { + wrapper: createWrapper(client), + }); + + expect(result.current).toBe(true); + }); +}); diff --git a/src/browser/hooks/useAutoHideSidebar.ts b/src/browser/hooks/useAutoHideSidebar.ts new file mode 100644 index 0000000000..aaf6296be5 --- /dev/null +++ b/src/browser/hooks/useAutoHideSidebar.ts @@ -0,0 +1,100 @@ +import { useEffect, useRef } from "react"; + +import { useAPI } from "@/browser/contexts/API"; +import { updatePersistedState, usePersistedState } from "@/browser/hooks/usePersistedState"; +import { AUTO_HIDE_SIDEBAR_KEY } from "@/common/constants/storage"; + +/** Seeded from local cache to avoid a layout flash before the backend responds. */ +export function useAutoHideSidebar(): boolean { + const { api } = useAPI(); + const fetchVersionRef = useRef(0); + const [rawAutoHideSidebar] = usePersistedState(AUTO_HIDE_SIDEBAR_KEY, false, { + listener: true, + }); + const autoHideSidebar = rawAutoHideSidebar === true; + const autoHideSidebarRef = useRef(autoHideSidebar); + const persistedValueRef = useRef(autoHideSidebar); + const backendPersistedValueRef = useRef(undefined); + + useEffect(() => { + if (persistedValueRef.current !== autoHideSidebar) { + persistedValueRef.current = autoHideSidebar; + const backendPersistedValue = backendPersistedValueRef.current; + backendPersistedValueRef.current = undefined; + if (backendPersistedValue !== autoHideSidebar) { + // Settings writes update persisted state first, so old backend reads must not overwrite them. + fetchVersionRef.current++; + } + } + + autoHideSidebarRef.current = autoHideSidebar; + }, [autoHideSidebar]); + + useEffect(() => { + const getConfig = api?.config?.getConfig; + if (!getConfig) { + return; + } + + const abortController = new AbortController(); + const { signal } = abortController; + let iterator: AsyncIterator | null = null; + + const setSyncedValue = (enabled: boolean) => { + if (autoHideSidebarRef.current === enabled) { + return; + } + + autoHideSidebarRef.current = enabled; + backendPersistedValueRef.current = enabled; + updatePersistedState(AUTO_HIDE_SIDEBAR_KEY, enabled ? true : undefined); + }; + + const refresh = () => { + const fetchVersion = ++fetchVersionRef.current; + getConfig() + .then((config) => { + if (!signal.aborted && fetchVersion === fetchVersionRef.current) { + setSyncedValue(config.autoHideSidebar === true); + } + }) + .catch(() => { + // Keep the current preference on failure. + }); + }; + + refresh(); + + const onConfigChanged = api?.config?.onConfigChanged; + if (onConfigChanged) { + const runSubscription = async () => { + try { + const subscribedIterator = await onConfigChanged(undefined, { signal }); + if (signal.aborted) { + void subscribedIterator.return?.(); + return; + } + + iterator = subscribedIterator; + for await (const _ of subscribedIterator) { + if (signal.aborted) { + break; + } + refresh(); + } + } catch { + // Subscription errors are non-fatal. + } + }; + + void runSubscription(); + } + + return () => { + abortController.abort(); + void iterator?.return?.(); + }; + }, [api]); + + return autoHideSidebar; +} diff --git a/src/browser/stories/mocks/orpc.ts b/src/browser/stories/mocks/orpc.ts index 1d4b48bd51..d5d6a9c63a 100644 --- a/src/browser/stories/mocks/orpc.ts +++ b/src/browser/stories/mocks/orpc.ts @@ -134,6 +134,8 @@ export interface MockORPCClientOptions { worktreeArchiveBehavior?: WorktreeArchiveBehavior; /** Initial full-width transcript toggle for config.getConfig */ chatTranscriptFullWidth?: boolean; + /** Initial auto-hide sidebar toggle for config.getConfig */ + autoHideSidebar?: boolean; /** Initial runtime enablement for config.getConfig */ runtimeEnablement?: Record; /** Initial default runtime for config.getConfig (global) */ @@ -372,6 +374,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl coderWorkspaceArchiveBehavior: initialCoderWorkspaceArchiveBehavior = "stop", worktreeArchiveBehavior: initialWorktreeArchiveBehavior = "keep", chatTranscriptFullWidth: initialChatTranscriptFullWidth = false, + autoHideSidebar: initialAutoHideSidebar = false, runtimeEnablement: initialRuntimeEnablement, defaultRuntime: initialDefaultRuntime, onePasswordAccountName: initialOnePasswordAccountName = null, @@ -505,6 +508,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl let coderWorkspaceArchiveBehavior = initialCoderWorkspaceArchiveBehavior; let worktreeArchiveBehavior = initialWorktreeArchiveBehavior; let chatTranscriptFullWidth = initialChatTranscriptFullWidth; + let autoHideSidebar = initialAutoHideSidebar; let runtimeEnablement: Record = initialRuntimeEnablement ?? { local: true, worktree: true, @@ -715,6 +719,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl imageGeneration, goalDefaults, chatTranscriptFullWidth, + autoHideSidebar, muxGovernorEnrolled, llmDebugLogs: false, }), @@ -791,6 +796,11 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl notifyConfigChanged(); return Promise.resolve(undefined); }, + updateAutoHideSidebar: (input: { enabled: boolean }) => { + autoHideSidebar = input.enabled; + notifyConfigChanged(); + return Promise.resolve(undefined); + }, updateCoderPrefs: (input: { coderWorkspaceArchiveBehavior: CoderWorkspaceArchiveBehavior; worktreeArchiveBehavior: WorktreeArchiveBehavior; diff --git a/src/common/config/schemas/appConfigOnDisk.test.ts b/src/common/config/schemas/appConfigOnDisk.test.ts index e88deab005..482d95a30c 100644 --- a/src/common/config/schemas/appConfigOnDisk.test.ts +++ b/src/common/config/schemas/appConfigOnDisk.test.ts @@ -22,6 +22,11 @@ describe("AppConfigOnDiskSchema", () => { ); }); + it("validates the auto-hide sidebar flag", () => { + expect(AppConfigOnDiskSchema.safeParse({ autoHideSidebar: true }).success).toBe(true); + expect(AppConfigOnDiskSchema.safeParse({ autoHideSidebar: "true" }).success).toBe(false); + }); + it("validates taskSettings with limits", () => { const valid = { taskSettings: { diff --git a/src/common/config/schemas/appConfigOnDisk.ts b/src/common/config/schemas/appConfigOnDisk.ts index 3af551ee00..571e14923f 100644 --- a/src/common/config/schemas/appConfigOnDisk.ts +++ b/src/common/config/schemas/appConfigOnDisk.ts @@ -91,6 +91,7 @@ export const AppConfigOnDiskSchema = z layoutPresets: z.unknown().optional(), taskSettings: TaskSettingsSchema.optional(), chatTranscriptFullWidth: z.boolean().optional(), + autoHideSidebar: z.boolean().optional(), muxGatewayEnabled: z.boolean().optional(), llmDebugLogs: z.boolean().optional(), heartbeatDefaultPrompt: z.string().optional(), diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index ead2d92cb8..634e9e0de1 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -87,6 +87,11 @@ export type LaunchBehavior = "dashboard" | "new-chat" | "last-workspace"; */ export const CHAT_TRANSCRIPT_FULL_WIDTH_KEY = "chatTranscriptFullWidth"; +/** + * Synchronous mirror for the backend auto-hide sidebar preference. + */ +export const AUTO_HIDE_SIDEBAR_KEY = "autoHideSidebar"; + /** * Get the localStorage key for expanded projects in sidebar (global) * Format: "expandedProjects" diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index dbdfd4badd..2b6607fa1d 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -1997,6 +1997,7 @@ export const config = { muxGovernorUrl: z.string().nullable(), muxGovernorEnrolled: z.boolean(), chatTranscriptFullWidth: z.boolean(), + autoHideSidebar: z.boolean(), llmDebugLogs: z.boolean(), heartbeatDefaultPrompt: z.string().optional(), heartbeatDefaultIntervalMs: z.number().optional(), @@ -2083,6 +2084,7 @@ export const config = { output: z.void(), }, updateChatTranscriptFullWidth: booleanToggleRoute, + updateAutoHideSidebar: booleanToggleRoute, updateLlmDebugLogs: booleanToggleRoute, updateHeartbeatDefaultPrompt: { input: z diff --git a/src/common/types/project.ts b/src/common/types/project.ts index 9108df732e..d08dc1c05f 100644 --- a/src/common/types/project.ts +++ b/src/common/types/project.ts @@ -83,6 +83,8 @@ export interface ProjectsConfig { layoutPresets?: LayoutPresetsConfig; /** Let chat transcripts use the full chat pane width instead of the default readable column. */ chatTranscriptFullWidth?: boolean; + /** Auto-collapse the left sidebar; expand on hover. */ + autoHideSidebar?: boolean; /** * Mux Gateway routing preferences (shared via ~/.mux/config.json). * Mirrors browser localStorage so switching server ports doesn't reset the UI. diff --git a/src/node/config.test.ts b/src/node/config.test.ts index 4a0c64f250..22ad3de7b2 100644 --- a/src/node/config.test.ts +++ b/src/node/config.test.ts @@ -95,6 +95,47 @@ describe("Config", () => { }); }); + describe("auto-hide sidebar settings", () => { + it("persists the auto-hide sidebar flag", async () => { + await config.editConfig((cfg) => { + cfg.autoHideSidebar = true; + return cfg; + }); + + const restartedConfig = new Config(tempDir); + expect(restartedConfig.loadConfigOrDefault().autoHideSidebar).toBe(true); + + const raw = JSON.parse(fs.readFileSync(path.join(tempDir, "config.json"), "utf-8")) as { + autoHideSidebar?: unknown; + }; + expect(raw.autoHideSidebar).toBe(true); + }); + + it("omits the auto-hide sidebar flag when disabled", async () => { + await config.editConfig((cfg) => { + cfg.autoHideSidebar = false; + return cfg; + }); + + const raw = JSON.parse(fs.readFileSync(path.join(tempDir, "config.json"), "utf-8")) as { + autoHideSidebar?: unknown; + }; + expect(raw.autoHideSidebar).toBeUndefined(); + }); + + it("ignores invalid auto-hide sidebar values on load", () => { + fs.writeFileSync( + path.join(tempDir, "config.json"), + JSON.stringify({ + projects: [], + autoHideSidebar: "yes", + }) + ); + + expect(config.loadConfigOrDefault().autoHideSidebar).toBeUndefined(); + }); + }); + describe("api server settings", () => { it("should persist apiServerBindHost, apiServerPort, and apiServerServeWebUi", async () => { await config.editConfig((cfg) => { diff --git a/src/node/config.ts b/src/node/config.ts index f9a44c2ebf..ffeb987cc3 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -879,6 +879,7 @@ export class Config { layoutPresets, taskSettings, chatTranscriptFullWidth: parseOptionalBoolean(parsed.chatTranscriptFullWidth), + autoHideSidebar: parseOptionalBoolean(parsed.autoHideSidebar), muxGatewayEnabled, llmDebugLogs: parseOptionalBoolean(parsed.llmDebugLogs), heartbeatDefaultPrompt: parseOptionalNonEmptyString(parsed.heartbeatDefaultPrompt), @@ -960,6 +961,11 @@ export class Config { data.chatTranscriptFullWidth = true; } + const autoHideSidebar = parseOptionalBoolean(config.autoHideSidebar); + if (autoHideSidebar === true) { + data.autoHideSidebar = true; + } + const llmDebugLogs = parseOptionalBoolean(config.llmDebugLogs); if (llmDebugLogs !== undefined) { data.llmDebugLogs = llmDebugLogs; diff --git a/src/node/orpc/router.test.ts b/src/node/orpc/router.test.ts index ced3ecaf38..23b1d4fa2f 100644 --- a/src/node/orpc/router.test.ts +++ b/src/node/orpc/router.test.ts @@ -128,6 +128,22 @@ describe("router config.saveConfig", () => { expect(config.loadConfigOrDefault().chatTranscriptFullWidth).toBeUndefined(); }); + test("persists the auto-hide sidebar config flag", async () => { + const client = createRouterClient(router(), { context: createContext() }); + + expect((await client.config.getConfig()).autoHideSidebar).toBe(false); + + await client.config.updateAutoHideSidebar({ enabled: true }); + + expect((await client.config.getConfig()).autoHideSidebar).toBe(true); + expect(config.loadConfigOrDefault().autoHideSidebar).toBe(true); + + await client.config.updateAutoHideSidebar({ enabled: false }); + + expect((await client.config.getConfig()).autoHideSidebar).toBe(false); + expect(config.loadConfigOrDefault().autoHideSidebar).toBeUndefined(); + }); + test("preserves optional task settings when a save omits them", async () => { await config.editConfig((current) => ({ ...current, diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index c80da15a09..2493dd5133 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -699,6 +699,7 @@ export const router = (authToken?: string) => { muxGovernorUrl, muxGovernorEnrolled, chatTranscriptFullWidth: config.chatTranscriptFullWidth === true, + autoHideSidebar: config.autoHideSidebar === true, llmDebugLogs: config.llmDebugLogs === true, heartbeatDefaultPrompt: config.heartbeatDefaultPrompt ?? undefined, heartbeatDefaultIntervalMs: config.heartbeatDefaultIntervalMs ?? undefined, @@ -1123,6 +1124,19 @@ export const router = (authToken?: string) => { return config; }); }), + updateAutoHideSidebar: t + .input(schemas.config.updateAutoHideSidebar.input) + .output(schemas.config.updateAutoHideSidebar.output) + .handler(async ({ context, input }) => { + await context.config.editConfig((config) => { + if (input.enabled) { + config.autoHideSidebar = true; + } else { + delete config.autoHideSidebar; + } + return config; + }); + }), updateLlmDebugLogs: t .input(schemas.config.updateLlmDebugLogs.input) .output(schemas.config.updateLlmDebugLogs.output)