From cded2c52ab046163deceb42c80bca1fe4f2ac60f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 23 Mar 2026 13:05:07 +0700 Subject: [PATCH] feat(app): add focus mode (Cmd+Shift+F) to show only the active pane Hides ScreenHeader, LeftSidebar, and ExplorerSidebar on desktop, replacing the split layout with the single focused pane. Respects macOS traffic light padding in the tab bar when active. Shortcut is restricted to workspace screens. Also nudges traffic light buttons up 3px globally. --- packages/app/src/app/_layout.tsx | 5 ++- .../app/src/components/split-container.tsx | 27 ++++++++++++++-- .../app/src/hooks/use-keyboard-shortcuts.ts | 8 +++++ packages/app/src/keyboard/actions.ts | 1 + .../app/src/keyboard/keyboard-shortcuts.ts | 26 +++++++++++++++ .../screens/workspace/workspace-screen.tsx | 20 ++++++++---- packages/app/src/stores/panel-store.ts | 32 +++++++++++++++++-- packages/desktop/src/main.ts | 2 +- 8 files changed, 107 insertions(+), 14 deletions(-) diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index a6fdb40c8..164b9de5e 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -265,6 +265,8 @@ function AppContainer({ const toggleAgentList = usePanelStore((state) => state.toggleAgentList); const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer); const toggleBothSidebars = usePanelStore((state) => state.toggleBothSidebars); + const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode); + const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0; @@ -276,6 +278,7 @@ function AppContainer({ selectedAgentId, toggleFileExplorer, toggleBothSidebars, + toggleFocusMode, }); const containerStyle = useMemo( @@ -286,7 +289,7 @@ function AppContainer({ const content = ( - {!isMobile && chromeEnabled && } + {!isMobile && chromeEnabled && !isFocusModeEnabled && } {children} {isMobile && chromeEnabled && } diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index bed3dc1b3..3089c9e01 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -31,6 +31,8 @@ import { Platform, View, Text } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { ResizeHandle } from "@/components/resize-handle"; import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus"; +import { usePanelStore } from "@/stores/panel-store"; +import { useTrafficLightPadding } from "@/utils/desktop-window"; import { computeTabDropPreview, type TabDropPreview, @@ -109,6 +111,7 @@ interface SplitContainerProps { onResizeSplit: (groupId: string, sizes: number[]) => void; onReorderTabsInPane: (paneId: string, tabIds: string[]) => void; renderPaneEmptyState?: () => ReactNode; + focusModeEnabled?: boolean; } interface WorkspaceTabDragData { @@ -262,6 +265,7 @@ export function SplitContainer({ onResizeSplit, onReorderTabsInPane, renderPaneEmptyState = () => null, + focusModeEnabled, }: SplitContainerProps) { const [activeDragTabId, setActiveDragTabId] = useState(null); const [dropPreview, setDropPreview] = useState(null); @@ -280,6 +284,17 @@ export function SplitContainer({ const panesById = useMemo(() => collectPanesById(layout.root), [layout.root]); + const effectiveRoot = useMemo(() => { + if (!focusModeEnabled) { + return layout.root; + } + const focusedPane = panesById.get(layout.focusedPaneId); + if (!focusedPane) { + return layout.root; + } + return { kind: "pane" as const, pane: focusedPane }; + }, [focusModeEnabled, layout.root, layout.focusedPaneId, panesById]); + const handleDragStart = useCallback((event: DragStartEvent) => { const data = event.active.data.current as WorkspaceTabDragData | undefined; if (data?.kind !== "workspace-tab") { @@ -491,7 +506,7 @@ export function SplitContainer({ onDragEnd={handleDragEnd} > (null); const stableOnFocusPane = useStableEvent(onFocusPane); + const isFocusModeEnabled = usePanelStore((s) => s.desktop.focusModeEnabled); + const trafficLightPadding = useTrafficLightPadding(); const paneState = useMemo( () => deriveWorkspacePaneState({ @@ -852,7 +869,13 @@ function SplitPaneView({ return ( - + 0 && { paddingLeft: trafficLightPadding.left }, + ]} + > void; toggleBothSidebars?: () => void; + toggleFocusMode?: () => void; }) { const pathname = usePathname(); const hosts = useHosts(); @@ -250,6 +252,11 @@ export function useKeyboardShortcuts({ } toggleFileExplorer(); return true; + case "view.toggle.focus": + if (toggleFocusMode) { + toggleFocusMode(); + } + return true; case "command-center.toggle": { const store = useKeyboardShortcutsStore.getState(); if (!store.commandCenterOpen) { @@ -404,5 +411,6 @@ export function useKeyboardShortcuts({ selectedAgentId, toggleAgentList, toggleFileExplorer, + toggleFocusMode, ]); } diff --git a/packages/app/src/keyboard/actions.ts b/packages/app/src/keyboard/actions.ts index d4746bc46..2653ac323 100644 --- a/packages/app/src/keyboard/actions.ts +++ b/packages/app/src/keyboard/actions.ts @@ -41,6 +41,7 @@ export type KeyboardActionId = | "workspace.terminal.new" | "worktree.new" | "worktree.archive" + | "view.toggle.focus" | "message-input.action"; export type KeyboardShortcutPayload = diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index 0291fb243..2540e8549 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -718,6 +718,32 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ }, }, + // --- Focus mode --- + { + id: "view-toggle-focus-cmd-shift-f-mac", + action: "view.toggle.focus", + combo: "Cmd+Shift+F", + when: { mac: true, hasSelectedAgent: true, commandCenter: false }, + help: { + id: "toggle-focus", + section: "panels", + label: "Toggle focus mode", + keys: ["mod", "shift", "F"], + }, + }, + { + id: "view-toggle-focus-ctrl-shift-f-non-mac", + action: "view.toggle.focus", + combo: "Ctrl+Shift+F", + when: { mac: false, hasSelectedAgent: true, commandCenter: false, terminal: false }, + help: { + id: "toggle-focus", + section: "panels", + label: "Toggle focus mode", + keys: ["mod", "shift", "F"], + }, + }, + // --- Message input --- { id: "message-input-voice-toggle-cmd-shift-d-mac", diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 9b547b23a..98a368c4a 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -550,6 +550,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) const mainBackgroundColor = isDarkMode ? theme.colors.surface1 : theme.colors.surface0; const toast = useToast(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled); const normalizedServerId = trimNonEmpty(decodeSegment(serverId)) ?? ""; const normalizedWorkspaceId = @@ -1949,6 +1950,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) ) : null} + {(!isFocusModeEnabled || isMobile) && ( @@ -2135,6 +2137,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) } /> + )} {isMobile ? ( - + {(!isFocusModeEnabled || isMobile) && ( + + )} ); diff --git a/packages/app/src/stores/panel-store.ts b/packages/app/src/stores/panel-store.ts index 369bd7522..9799026fb 100644 --- a/packages/app/src/stores/panel-store.ts +++ b/packages/app/src/stores/panel-store.ts @@ -33,6 +33,7 @@ type MobilePanelView = "agent" | "agent-list" | "file-explorer"; interface DesktopSidebarState { agentListOpen: boolean; fileExplorerOpen: boolean; + focusModeEnabled: boolean; } export type SortOption = "name" | "modified" | "size"; @@ -79,6 +80,7 @@ interface PanelState { toggleAgentList: () => void; toggleFileExplorer: () => void; toggleBothSidebars: () => void; + toggleFocusMode: () => void; // File explorer settings actions setExplorerTab: (tab: ExplorerTab) => void; @@ -134,6 +136,7 @@ export const usePanelStore = create()( desktop: { agentListOpen: DEFAULT_DESKTOP_OPEN, fileExplorerOpen: false, + focusModeEnabled: false, }, // File explorer defaults @@ -175,6 +178,7 @@ export const usePanelStore = create()( // On desktop, closing depends on which panel triggered it // This is called when closing via gesture/backdrop, so we close the currently active mobile panel desktop: { + ...state.desktop, agentListOpen: state.mobileView === "agent-list" ? false : state.desktop.agentListOpen, fileExplorerOpen: state.mobileView === "file-explorer" ? false : state.desktop.fileExplorerOpen, @@ -217,6 +221,11 @@ export const usePanelStore = create()( return nextState; }), + toggleFocusMode: () => + set((state) => ({ + desktop: { ...state.desktop, focusModeEnabled: !state.desktop.focusModeEnabled }, + })), + toggleBothSidebars: () => set((state) => { // If either sidebar is open, close both. Otherwise, open both. @@ -224,13 +233,13 @@ export const usePanelStore = create()( if (anyOpen) { return { mobileView: "agent" as MobilePanelView, - desktop: { agentListOpen: false, fileExplorerOpen: false }, + desktop: { ...state.desktop, agentListOpen: false, fileExplorerOpen: false }, }; } const resolvedTab = resolveExplorerTabFromActiveCheckout(state); return { mobileView: "agent" as MobilePanelView, - desktop: { agentListOpen: true, fileExplorerOpen: true }, + desktop: { ...state.desktop, agentListOpen: true, fileExplorerOpen: true }, ...(resolvedTab ? { explorerTab: resolvedTab } : {}), }; }), @@ -286,7 +295,7 @@ export const usePanelStore = create()( }), { name: "panel-state", - version: 6, + version: 8, storage: createJSONStorage(() => AsyncStorage), migrate: (persistedState, version) => { const state = persistedState as Partial & Record; @@ -341,6 +350,23 @@ export const usePanelStore = create()( state.explorerTabByCheckout = next; } + if (version < 8) { + const desktop = state.desktop as Record | undefined; + if (desktop) { + if ("zoomed" in desktop) { + desktop.focusModeEnabled = desktop.zoomed; + delete desktop.zoomed; + } + if ("focused" in desktop) { + desktop.focusModeEnabled = desktop.focused; + delete desktop.focused; + } + if (typeof desktop.focusModeEnabled !== "boolean") { + desktop.focusModeEnabled = false; + } + } + } + if (version < 6 || typeof state.sidebarWidth !== "number") { state.sidebarWidth = DEFAULT_SIDEBAR_WIDTH; } diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 23cc7eb3b..b27669934 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -90,7 +90,7 @@ async function createMainWindow(): Promise { show: false, ...(iconPath ? { icon: iconPath } : {}), titleBarStyle: isMac ? "hidden" : "default", - trafficLightPosition: isMac ? { x: 16, y: 17 } : undefined, + trafficLightPosition: isMac ? { x: 16, y: 14 } : undefined, webPreferences: { preload: getPreloadPath(), contextIsolation: true,