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)