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
13 changes: 10 additions & 3 deletions src/browser/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -1070,8 +1075,10 @@ function AppInner() {
<>
<div className="bg-surface-primary mobile-layout flex h-full overflow-hidden pt-[env(safe-area-inset-top)] pr-[env(safe-area-inset-right)] pb-[min(env(safe-area-inset-bottom,0px),40px)] pl-[env(safe-area-inset-left)]">
<LeftSidebar
collapsed={sidebarCollapsed}
collapsed={effectiveSidebarCollapsed}
onToggleCollapsed={handleToggleSidebar}
autoHideEnabled={autoHideSidebar}
onHoverChange={setSidebarHovered}
widthPx={leftSidebar.width}
isResizing={leftSidebar.isResizing}
onStartResize={leftSidebar.startResize}
Expand Down
7 changes: 7 additions & 0 deletions src/browser/components/LeftSidebar/LeftSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import { isDesktopMode } from "@/browser/hooks/useDesktopTitlebar";
interface LeftSidebarProps {
collapsed: boolean;
onToggleCollapsed: () => 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;
Expand All @@ -20,6 +23,8 @@ export function LeftSidebar(props: LeftSidebarProps) {
const {
collapsed,
onToggleCollapsed,
autoHideEnabled,
onHoverChange,
widthPx,
isResizing,
onStartResize,
Expand Down Expand Up @@ -57,6 +62,8 @@ export function LeftSidebar(props: LeftSidebarProps) {
{/* Sidebar */}
<div
data-testid="left-sidebar"
onMouseEnter={autoHideEnabled ? () => 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",
Expand Down
48 changes: 46 additions & 2 deletions src/browser/features/Settings/Sections/GeneralSection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ interface MockConfig {
coderWorkspaceArchiveBehavior: CoderWorkspaceArchiveBehavior;
worktreeArchiveBehavior: WorktreeArchiveBehavior;
chatTranscriptFullWidth: boolean;
autoHideSidebar: boolean;
llmDebugLogs: boolean;
}

Expand All @@ -29,6 +30,7 @@ interface MockAPIClient {
worktreeArchiveBehavior: WorktreeArchiveBehavior;
}) => Promise<void>;
updateChatTranscriptFullWidth: (input: { enabled: boolean }) => Promise<void>;
updateAutoHideSidebar: (input: { enabled: boolean }) => Promise<void>;
updateLlmDebugLogs: (input: { enabled: boolean }) => Promise<void>;
};
server: {
Expand Down Expand Up @@ -171,6 +173,7 @@ interface RenderGeneralSectionOptions {
coderWorkspaceArchiveBehavior?: CoderWorkspaceArchiveBehavior;
worktreeArchiveBehavior?: WorktreeArchiveBehavior;
chatTranscriptFullWidth?: boolean;
autoHideSidebar?: boolean;
}

interface MockAPISetup {
Expand All @@ -187,13 +190,17 @@ interface MockAPISetup {
updateChatTranscriptFullWidthMock: ReturnType<
typeof mock<(input: { enabled: boolean }) => Promise<void>>
>;
updateAutoHideSidebarMock: ReturnType<
typeof mock<(input: { enabled: boolean }) => Promise<void>>
>;
}

function createMockAPI(configOverrides: Partial<MockConfig> = {}): MockAPISetup {
const config: MockConfig = {
coderWorkspaceArchiveBehavior: DEFAULT_CODER_ARCHIVE_BEHAVIOR,
worktreeArchiveBehavior: DEFAULT_WORKTREE_ARCHIVE_BEHAVIOR,
chatTranscriptFullWidth: false,
autoHideSidebar: false,
llmDebugLogs: false,
...configOverrides,
};
Expand All @@ -217,12 +224,19 @@ function createMockAPI(configOverrides: Partial<MockConfig> = {}): 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;

Expand All @@ -241,6 +255,7 @@ function createMockAPI(configOverrides: Partial<MockConfig> = {}): MockAPISetup
getConfigMock,
updateCoderPrefsMock,
updateChatTranscriptFullWidthMock,
updateAutoHideSidebarMock,
};
}

Expand All @@ -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,
});
Expand All @@ -276,7 +297,12 @@ describe("GeneralSection", () => {
</ThemeProvider>
);

return { updateCoderPrefsMock, updateChatTranscriptFullWidthMock, view };
return {
updateCoderPrefsMock,
updateChatTranscriptFullWidthMock,
updateAutoHideSidebarMock,
view,
};
}

function getSelectTrigger(view: ReturnType<typeof render>, label: string): HTMLElement {
Expand Down Expand Up @@ -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",
Expand Down
46 changes: 46 additions & 0 deletions src/browser/features/Settings/Sections/GeneralSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<CoderWorkspaceArchiveBehavior>(DEFAULT_CODER_ARCHIVE_BEHAVIOR);
Expand All @@ -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<void>>(Promise.resolve());
const chatTranscriptFullWidthUpdateChainRef = useRef<Promise<void>>(Promise.resolve());
const autoHideSidebarUpdateChainRef = useRef<Promise<void>>(Promise.resolve());
const llmDebugLogsUpdateChainRef = useRef<Promise<void>>(Promise.resolve());
const archiveBehaviorPendingUpdateRef = useRef<CoderWorkspaceArchiveBehavior | undefined>(
undefined
Expand All @@ -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
Expand Down Expand Up @@ -289,6 +294,15 @@ export function GeneralSection() {
);
}

if (autoHideSidebarNonce === autoHideSidebarLoadNonceRef.current) {
const enabled = cfg.autoHideSidebar === true;
setAutoHideSidebar(enabled);
updatePersistedState<boolean | undefined>(
AUTO_HIDE_SIDEBAR_KEY,
enabled ? true : undefined
);
}

if (llmDebugLogsNonce === llmDebugLogsLoadNonceRef.current) {
setLlmDebugLogs(cfg.llmDebugLogs === true);
}
Expand Down Expand Up @@ -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<boolean | undefined>(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++;
Expand Down Expand Up @@ -536,6 +570,18 @@ export function GeneralSection() {
</Select>
</div>

<div className="flex items-center justify-between gap-4">
<div className="flex-1">
<div className="text-foreground text-sm">Automatically hide sidebar</div>
<div className="text-muted text-xs">Collapse the left sidebar when not hovered.</div>
</div>
<Switch
checked={autoHideSidebar}
onCheckedChange={handleAutoHideSidebarChange}
aria-label="Toggle automatic sidebar hiding"
/>
</div>

<div className="flex items-center justify-between gap-4">
<div className="flex-1">
<div className="text-foreground text-sm">Launch behavior</div>
Expand Down
Loading