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,