From b360cfcd68df3c7719fde402ab1e5c8697b8883a Mon Sep 17 00:00:00 2001 From: Aleksey Bykhun Date: Wed, 5 Nov 2025 18:04:55 -0800 Subject: [PATCH 01/17] add arc-style keyboard shortcuts system with workspace and tab navigation --- CLAUDE.md | 44 ++++ .../src/renderer/lib/keyboard-shortcuts.ts | 97 +++++++++ apps/desktop/src/renderer/lib/shortcuts.ts | 199 ++++++++++++++++++ .../src/renderer/screens/main/MainScreen.tsx | 120 +++++++++++ .../main/components/MainContent/Terminal.tsx | 19 +- 5 files changed, 469 insertions(+), 10 deletions(-) create mode 100644 apps/desktop/src/renderer/lib/keyboard-shortcuts.ts create mode 100644 apps/desktop/src/renderer/lib/shortcuts.ts diff --git a/CLAUDE.md b/CLAUDE.md index de51ce75c6d..48bd43ff001 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -178,3 +178,47 @@ Each instance needs: - **Separate user data directory** - Pass via `--user-data-dir` flag The desktop app loads environment variables from the monorepo root `.env` file. + +### Keyboard Shortcuts System + +The desktop app uses a centralized keyboard shortcuts system inspired by Arc Browser. + +**File Structure:** +- `src/renderer/lib/keyboard-shortcuts.ts` - Core shortcuts infrastructure (types, matchers, handlers) +- `src/renderer/lib/shortcuts.ts` - Arc-style shortcut definitions (workspace, tab, terminal) + +**Implemented Shortcuts:** + +**Workspace Management:** +- `Cmd+Option+Left/Right` - Switch between workspaces +- `Cmd+S` - Toggle sidebar visibility +- `Cmd+D` - Create split view (horizontal) [TODO] +- `Cmd+Shift+D` - Create split view (vertical) [TODO] + +**Tab Management:** +- `Cmd+Option+Up/Down` - Switch between tabs +- `Cmd+T` - Create new tab +- `Cmd+W` - Close tab [TODO] +- `Cmd+Shift+T` - Reopen closed tab [TODO] +- `Cmd+1-9` - Jump to tab by position + +**Terminal:** +- `Cmd+K` - Clear terminal (scrollback + screen) + +**Adding New Shortcuts:** + +1. Define handlers in the component (e.g., `MainScreen.tsx`) +2. Create shortcut group using helper functions from `shortcuts.ts` +3. Use `createShortcutHandler` to convert to event handler +4. Attach to event listener or terminal custom key handler + +**Example:** +```typescript +const shortcuts = createWorkspaceShortcuts({ + switchToPrevWorkspace: () => { /* handler logic */ }, + // ... other handlers +}); + +const handleKeyDown = createShortcutHandler(shortcuts.shortcuts); +window.addEventListener("keydown", handleKeyDown); +``` diff --git a/apps/desktop/src/renderer/lib/keyboard-shortcuts.ts b/apps/desktop/src/renderer/lib/keyboard-shortcuts.ts new file mode 100644 index 00000000000..d0a3de242dc --- /dev/null +++ b/apps/desktop/src/renderer/lib/keyboard-shortcuts.ts @@ -0,0 +1,97 @@ +/** + * Keyboard shortcuts module + * Central place for all keyboard shortcut definitions and handling + */ + +export type ModifierKey = "meta" | "ctrl" | "alt" | "shift"; + +export interface KeyboardShortcut { + key: string; + modifiers?: ModifierKey[]; + description: string; + handler: (event: KeyboardEvent) => boolean | void; +} + +export interface KeyboardShortcutGroup { + name: string; + shortcuts: KeyboardShortcut[]; +} + +/** + * Check if event matches the shortcut definition + */ +export function matchesShortcut( + event: KeyboardEvent, + shortcut: KeyboardShortcut, +): boolean { + // Check key match (case insensitive) + if (event.key.toLowerCase() !== shortcut.key.toLowerCase()) { + return false; + } + + // Check modifiers + const modifiers = shortcut.modifiers || []; + const hasCtrl = modifiers.includes("ctrl"); + const hasMeta = modifiers.includes("meta"); + const hasAlt = modifiers.includes("alt"); + const hasShift = modifiers.includes("shift"); + + return ( + event.ctrlKey === hasCtrl && + event.metaKey === hasMeta && + event.altKey === hasAlt && + event.shiftKey === hasShift + ); +} + +/** + * Create a keyboard event handler that processes multiple shortcuts + */ +export function createShortcutHandler(shortcuts: KeyboardShortcut[]) { + return (event: KeyboardEvent): boolean => { + for (const shortcut of shortcuts) { + if (matchesShortcut(event, shortcut)) { + const result = shortcut.handler(event); + // If handler returns false, prevent default and stop propagation + if (result === false) { + event.preventDefault(); + return false; + } + } + } + // Allow event to propagate normally + return true; + }; +} + +/** + * Format shortcut for display (e.g., "Cmd+K" or "Ctrl+Shift+P") + */ +export function formatShortcut(shortcut: KeyboardShortcut): string { + const modifiers = shortcut.modifiers || []; + const parts: string[] = []; + + // Use platform-specific display names + const isMac = navigator.platform.toLowerCase().includes("mac"); + + for (const mod of modifiers) { + switch (mod) { + case "meta": + parts.push(isMac ? "Cmd" : "Win"); + break; + case "ctrl": + parts.push("Ctrl"); + break; + case "alt": + parts.push(isMac ? "Opt" : "Alt"); + break; + case "shift": + parts.push("Shift"); + break; + } + } + + parts.push(shortcut.key.toUpperCase()); + + return parts.join("+"); +} diff --git a/apps/desktop/src/renderer/lib/shortcuts.ts b/apps/desktop/src/renderer/lib/shortcuts.ts new file mode 100644 index 00000000000..9dcd73eb6d3 --- /dev/null +++ b/apps/desktop/src/renderer/lib/shortcuts.ts @@ -0,0 +1,199 @@ +/** + * Arc-style keyboard shortcuts for Superset + */ + +import type { + KeyboardShortcut, + KeyboardShortcutGroup, +} from "./keyboard-shortcuts"; + +export interface ShortcutHandlers { + // Workspace management + switchToPrevWorkspace: () => void; + switchToNextWorkspace: () => void; + toggleSidebar: () => void; + createSplitView: () => void; + createVerticalSplit: () => void; + + // Tab management + switchToPrevTab: () => void; + switchToNextTab: () => void; + newTab: () => void; + closeTab: () => void; + reopenClosedTab: () => void; + jumpToTab: (index: number) => void; + + // Terminal specific + clearTerminal: () => void; +} + +export function createWorkspaceShortcuts( + handlers: Pick< + ShortcutHandlers, + | "switchToPrevWorkspace" + | "switchToNextWorkspace" + | "toggleSidebar" + | "createSplitView" + | "createVerticalSplit" + >, +): KeyboardShortcutGroup { + return { + name: "Workspace Management", + shortcuts: [ + { + key: "ArrowLeft", + modifiers: ["meta", "alt"], + description: "Switch to previous workspace", + handler: (event) => { + event.preventDefault(); + handlers.switchToPrevWorkspace(); + return false; + }, + }, + { + key: "ArrowRight", + modifiers: ["meta", "alt"], + description: "Switch to next workspace", + handler: (event) => { + event.preventDefault(); + handlers.switchToNextWorkspace(); + return false; + }, + }, + { + key: "s", + modifiers: ["meta"], + description: "Toggle sidebar visibility", + handler: (event) => { + event.preventDefault(); + handlers.toggleSidebar(); + return false; + }, + }, + { + key: "d", + modifiers: ["meta"], + description: "Create split view (horizontal)", + handler: (event) => { + event.preventDefault(); + handlers.createSplitView(); + return false; + }, + }, + { + key: "d", + modifiers: ["meta", "shift"], + description: "Create split view (vertical)", + handler: (event) => { + event.preventDefault(); + handlers.createVerticalSplit(); + return false; + }, + }, + ], + }; +} + +export function createTabShortcuts( + handlers: Pick< + ShortcutHandlers, + | "switchToPrevTab" + | "switchToNextTab" + | "newTab" + | "closeTab" + | "reopenClosedTab" + | "jumpToTab" + >, +): KeyboardShortcutGroup { + const shortcuts: KeyboardShortcut[] = [ + { + key: "ArrowUp", + modifiers: ["meta", "alt"], + description: "Switch to previous tab", + handler: (event) => { + event.preventDefault(); + handlers.switchToPrevTab(); + return false; + }, + }, + { + key: "ArrowDown", + modifiers: ["meta", "alt"], + description: "Switch to next tab", + handler: (event) => { + event.preventDefault(); + handlers.switchToNextTab(); + return false; + }, + }, + { + key: "t", + modifiers: ["meta"], + description: "Create new tab", + handler: (event) => { + event.preventDefault(); + handlers.newTab(); + return false; + }, + }, + { + key: "w", + modifiers: ["meta"], + description: "Close current tab", + handler: (event) => { + event.preventDefault(); + handlers.closeTab(); + return false; + }, + }, + { + key: "t", + modifiers: ["meta", "shift"], + description: "Reopen closed tab", + handler: (event) => { + event.preventDefault(); + handlers.reopenClosedTab(); + return false; + }, + }, + ]; + + // Add Cmd+1-9 shortcuts for jumping to tabs + for (let i = 1; i <= 9; i++) { + shortcuts.push({ + key: i.toString(), + modifiers: ["meta"], + description: `Jump to tab ${i}`, + handler: (event) => { + event.preventDefault(); + handlers.jumpToTab(i); + return false; + }, + }); + } + + return { + name: "Tab Management", + shortcuts, + }; +} + +export function createTerminalShortcuts( + handlers: Pick, +): KeyboardShortcutGroup { + return { + name: "Terminal", + shortcuts: [ + { + key: "k", + modifiers: ["meta"], + description: "Clear terminal (scrollback + screen)", + handler: (event) => { + event.preventDefault(); + handlers.clearTerminal(); + return false; + }, + }, + ], + }; +} diff --git a/apps/desktop/src/renderer/screens/main/MainScreen.tsx b/apps/desktop/src/renderer/screens/main/MainScreen.tsx index 022b116d93a..c0ef81388f4 100644 --- a/apps/desktop/src/renderer/screens/main/MainScreen.tsx +++ b/apps/desktop/src/renderer/screens/main/MainScreen.tsx @@ -26,6 +26,11 @@ import TabGroup from "./components/MainContent/TabGroup"; import { PlaceholderState } from "./components/PlaceholderState"; import { Sidebar } from "./components/Sidebar"; import { TopBar } from "./components/TopBar"; +import { createShortcutHandler } from "../../lib/keyboard-shortcuts"; +import { + createWorkspaceShortcuts, + createTabShortcuts, +} from "../../lib/shortcuts"; // Droppable wrapper for main content area function DroppableMainContent({ @@ -887,6 +892,121 @@ export function MainScreen() { ? findTabById(selectedWorktree.tabs, activeId) : null; + // Set up keyboard shortcuts + useEffect(() => { + const workspaceShortcuts = createWorkspaceShortcuts({ + switchToPrevWorkspace: () => { + if (!workspaces || !currentWorkspace) return; + const currentIndex = workspaces.findIndex( + (ws) => ws.id === currentWorkspace.id, + ); + if (currentIndex > 0) { + handleWorkspaceSelect(workspaces[currentIndex - 1].id); + } + }, + switchToNextWorkspace: () => { + if (!workspaces || !currentWorkspace) return; + const currentIndex = workspaces.findIndex( + (ws) => ws.id === currentWorkspace.id, + ); + if (currentIndex < workspaces.length - 1) { + handleWorkspaceSelect(workspaces[currentIndex + 1].id); + } + }, + toggleSidebar: () => { + setIsSidebarOpen((prev) => !prev); + }, + createSplitView: () => { + // TODO: implement horizontal split + console.log("Create split view (horizontal)"); + }, + createVerticalSplit: () => { + // TODO: implement vertical split + console.log("Create split view (vertical)"); + }, + }); + + const tabShortcuts = createTabShortcuts({ + switchToPrevTab: () => { + if (!selectedWorktree || !selectedTabId) return; + const tabs = selectedWorktree.tabs; + const currentIndex = tabs.findIndex((t) => t.id === selectedTabId); + if (currentIndex > 0) { + handleTabSelect(selectedWorktree.id, tabs[currentIndex - 1].id); + } + }, + switchToNextTab: () => { + if (!selectedWorktree || !selectedTabId) return; + const tabs = selectedWorktree.tabs; + const currentIndex = tabs.findIndex((t) => t.id === selectedTabId); + if (currentIndex < tabs.length - 1) { + handleTabSelect(selectedWorktree.id, tabs[currentIndex + 1].id); + } + }, + newTab: async () => { + if (!currentWorkspace || !selectedWorktreeId) return; + const tab = await createTab( + currentWorkspace.id, + selectedWorktreeId, + "Terminal", + "terminal", + ); + if (tab) { + // Refresh workspace + const refreshedWorkspace = await window.ipcRenderer.invoke( + "workspace-get", + currentWorkspace.id, + ); + if (refreshedWorkspace) { + setCurrentWorkspace(refreshedWorkspace); + handleTabSelect(selectedWorktreeId, tab.id); + } + } + }, + closeTab: async () => { + if (!currentWorkspace || !selectedWorktreeId || !selectedTabId) return; + // TODO: implement tab close with proper cleanup + console.log("Close tab:", selectedTabId); + }, + reopenClosedTab: () => { + // TODO: implement reopen closed tab + console.log("Reopen closed tab"); + }, + jumpToTab: (index: number) => { + if (!selectedWorktree) return; + const tabs = selectedWorktree.tabs; + if (index > 0 && index <= tabs.length) { + handleTabSelect(selectedWorktree.id, tabs[index - 1].id); + } + }, + }); + + const handleKeyDown = (event: KeyboardEvent) => { + // Try workspace shortcuts first + const workspaceHandler = createShortcutHandler( + workspaceShortcuts.shortcuts, + ); + if (!workspaceHandler(event)) { + return; + } + + // Then try tab shortcuts + const tabHandler = createShortcutHandler(tabShortcuts.shortcuts); + tabHandler(event); + }; + + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [ + workspaces, + currentWorkspace, + selectedWorktree, + selectedWorktreeId, + selectedTabId, + ]); + return ( { - // Cmd+K (Mac) or Ctrl+K (Win/Linux): Clear terminal like iTerm2 - // This clears both the scrollback buffer and sends clear to the shell - if (event.metaKey && event.key === "k") { - event.preventDefault(); + // Set up keyboard shortcuts + const terminalShortcuts = createTerminalShortcuts({ + clearTerminal: () => { // Clear the xterm buffer (removes scrollback) term.clear(); // Also send clear command to shell to reset shell state @@ -164,12 +163,12 @@ export default function TerminalComponent({ data: "\x0c", // Form feed (Ctrl+L) - clears screen in most shells }); } - return false; // Prevent default terminal handling - } - // Allow all other keys to be processed normally - return true; + }, }); + const handleShortcut = createShortcutHandler(terminalShortcuts.shortcuts); + term.attachCustomKeyEventHandler(handleShortcut); + // Load addons // 1. WebLinks - Makes URLs clickable and open in default browser const webLinksAddon = new WebLinksAddon((event, uri) => { From 4aca4abe769c725827837b6910316da65b683569 Mon Sep 17 00:00:00 2001 From: Aleksey Bykhun Date: Wed, 5 Nov 2025 18:07:39 -0800 Subject: [PATCH 02/17] implement remaining shortcuts: split views and close tab --- CLAUDE.md | 8 +- .../src/renderer/screens/main/MainScreen.tsx | 270 +++++++++++++++++- 2 files changed, 266 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 48bd43ff001..1e62a732427 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -192,14 +192,14 @@ The desktop app uses a centralized keyboard shortcuts system inspired by Arc Bro **Workspace Management:** - `Cmd+Option+Left/Right` - Switch between workspaces - `Cmd+S` - Toggle sidebar visibility -- `Cmd+D` - Create split view (horizontal) [TODO] -- `Cmd+Shift+D` - Create split view (vertical) [TODO] +- `Cmd+D` - Create split view (horizontal) +- `Cmd+Shift+D` - Create split view (vertical) **Tab Management:** - `Cmd+Option+Up/Down` - Switch between tabs - `Cmd+T` - Create new tab -- `Cmd+W` - Close tab [TODO] -- `Cmd+Shift+T` - Reopen closed tab [TODO] +- `Cmd+W` - Close tab +- `Cmd+Shift+T` - Reopen closed tab [TODO - requires history tracking] - `Cmd+1-9` - Jump to tab by position **Terminal:** diff --git a/apps/desktop/src/renderer/screens/main/MainScreen.tsx b/apps/desktop/src/renderer/screens/main/MainScreen.tsx index c0ef81388f4..9691c8fc26f 100644 --- a/apps/desktop/src/renderer/screens/main/MainScreen.tsx +++ b/apps/desktop/src/renderer/screens/main/MainScreen.tsx @@ -916,13 +916,225 @@ export function MainScreen() { toggleSidebar: () => { setIsSidebarOpen((prev) => !prev); }, - createSplitView: () => { - // TODO: implement horizontal split - console.log("Create split view (horizontal)"); + createSplitView: async () => { + // Create horizontal split + if (!currentWorkspace || !selectedWorktreeId || !selectedTab) return; + + // If already in a group, add to that group + if (selectedTab.type === "group") { + const newTab = await createTab( + currentWorkspace.id, + selectedWorktreeId, + "Terminal", + "terminal", + ); + if (!newTab) return; + + // Move into the group + await window.ipcRenderer.invoke("tab-move", { + workspaceId: currentWorkspace.id, + worktreeId: selectedWorktreeId, + tabId: newTab.id, + sourceParentTabId: undefined, + targetParentTabId: selectedTab.id, + targetIndex: selectedTab.tabs?.length || 0, + }); + + // Update mosaic tree (horizontal split) + const updatedMosaicTree = addTabToMosaicTree( + selectedTab.mosaicTree, + newTab.id, + ); + + await window.ipcRenderer.invoke("tab-update-mosaic-tree", { + workspaceId: currentWorkspace.id, + worktreeId: selectedWorktreeId, + tabId: selectedTab.id, + mosaicTree: updatedMosaicTree, + }); + } else { + // Create new group with horizontal split + const newTab = await createTab( + currentWorkspace.id, + selectedWorktreeId, + "Terminal", + "terminal", + ); + if (!newTab) return; + + const groupTab = await createTab( + currentWorkspace.id, + selectedWorktreeId, + "Tab Group", + "group", + ); + if (!groupTab) return; + + // Move both tabs into group + await window.ipcRenderer.invoke("tab-move", { + workspaceId: currentWorkspace.id, + worktreeId: selectedWorktreeId, + tabId: selectedTab.id, + sourceParentTabId: undefined, + targetParentTabId: groupTab.id, + targetIndex: 0, + }); + + await window.ipcRenderer.invoke("tab-move", { + workspaceId: currentWorkspace.id, + worktreeId: selectedWorktreeId, + tabId: newTab.id, + sourceParentTabId: undefined, + targetParentTabId: groupTab.id, + targetIndex: 1, + }); + + // Create horizontal mosaic tree + const mosaicTree: MosaicNode = { + direction: "row", + first: selectedTab.id, + second: newTab.id, + splitPercentage: 50, + }; + + await window.ipcRenderer.invoke("tab-update-mosaic-tree", { + workspaceId: currentWorkspace.id, + worktreeId: selectedWorktreeId, + tabId: groupTab.id, + mosaicTree, + }); + + setSelectedTabId(groupTab.id); + await window.ipcRenderer.invoke("workspace-set-active-selection", { + workspaceId: currentWorkspace.id, + worktreeId: selectedWorktreeId, + tabId: groupTab.id, + }); + } + + // Refresh workspace + const refreshedWorkspace = await window.ipcRenderer.invoke( + "workspace-get", + currentWorkspace.id, + ); + if (refreshedWorkspace) { + setCurrentWorkspace(refreshedWorkspace); + } }, - createVerticalSplit: () => { - // TODO: implement vertical split - console.log("Create split view (vertical)"); + createVerticalSplit: async () => { + // Create vertical split + if (!currentWorkspace || !selectedWorktreeId || !selectedTab) return; + + // If already in a group, add to that group with column direction + if (selectedTab.type === "group") { + const newTab = await createTab( + currentWorkspace.id, + selectedWorktreeId, + "Terminal", + "terminal", + ); + if (!newTab) return; + + // Move into the group + await window.ipcRenderer.invoke("tab-move", { + workspaceId: currentWorkspace.id, + worktreeId: selectedWorktreeId, + tabId: newTab.id, + sourceParentTabId: undefined, + targetParentTabId: selectedTab.id, + targetIndex: selectedTab.tabs?.length || 0, + }); + + // Update mosaic tree with column direction for vertical split + const updatedMosaicTree: MosaicNode = + typeof selectedTab.mosaicTree === "string" + ? { + direction: "column", + first: selectedTab.mosaicTree, + second: newTab.id, + splitPercentage: 50, + } + : { + direction: "column", + first: selectedTab.mosaicTree, + second: newTab.id, + splitPercentage: 50, + }; + + await window.ipcRenderer.invoke("tab-update-mosaic-tree", { + workspaceId: currentWorkspace.id, + worktreeId: selectedWorktreeId, + tabId: selectedTab.id, + mosaicTree: updatedMosaicTree, + }); + } else { + // Create new group with vertical split + const newTab = await createTab( + currentWorkspace.id, + selectedWorktreeId, + "Terminal", + "terminal", + ); + if (!newTab) return; + + const groupTab = await createTab( + currentWorkspace.id, + selectedWorktreeId, + "Tab Group", + "group", + ); + if (!groupTab) return; + + // Move both tabs into group + await window.ipcRenderer.invoke("tab-move", { + workspaceId: currentWorkspace.id, + worktreeId: selectedWorktreeId, + tabId: selectedTab.id, + sourceParentTabId: undefined, + targetParentTabId: groupTab.id, + targetIndex: 0, + }); + + await window.ipcRenderer.invoke("tab-move", { + workspaceId: currentWorkspace.id, + worktreeId: selectedWorktreeId, + tabId: newTab.id, + sourceParentTabId: undefined, + targetParentTabId: groupTab.id, + targetIndex: 1, + }); + + // Create vertical mosaic tree + const mosaicTree: MosaicNode = { + direction: "column", + first: selectedTab.id, + second: newTab.id, + splitPercentage: 50, + }; + + await window.ipcRenderer.invoke("tab-update-mosaic-tree", { + workspaceId: currentWorkspace.id, + worktreeId: selectedWorktreeId, + tabId: groupTab.id, + mosaicTree, + }); + + setSelectedTabId(groupTab.id); + await window.ipcRenderer.invoke("workspace-set-active-selection", { + workspaceId: currentWorkspace.id, + worktreeId: selectedWorktreeId, + tabId: groupTab.id, + }); + } + + // Refresh workspace + const refreshedWorkspace = await window.ipcRenderer.invoke( + "workspace-get", + currentWorkspace.id, + ); + if (refreshedWorkspace) { + setCurrentWorkspace(refreshedWorkspace); + } }, }); @@ -965,8 +1177,50 @@ export function MainScreen() { }, closeTab: async () => { if (!currentWorkspace || !selectedWorktreeId || !selectedTabId) return; - // TODO: implement tab close with proper cleanup - console.log("Close tab:", selectedTabId); + + const tabs = selectedWorktree?.tabs || []; + const currentIndex = tabs.findIndex((t) => t.id === selectedTabId); + + // Delete the tab + const result = await window.ipcRenderer.invoke("tab-delete", { + workspaceId: currentWorkspace.id, + worktreeId: selectedWorktreeId, + tabId: selectedTabId, + }); + + if (!result.success) { + console.error("Failed to close tab:", result.error); + return; + } + + // Refresh workspace + const refreshedWorkspace = await window.ipcRenderer.invoke( + "workspace-get", + currentWorkspace.id, + ); + + if (refreshedWorkspace) { + setCurrentWorkspace(refreshedWorkspace); + + // Select adjacent tab + const updatedWorktree = refreshedWorkspace.worktrees.find( + (wt) => wt.id === selectedWorktreeId, + ); + if (updatedWorktree && updatedWorktree.tabs.length > 0) { + // Try to select the tab at the same index, or the last tab + const newIndex = Math.min( + currentIndex, + updatedWorktree.tabs.length - 1, + ); + handleTabSelect( + selectedWorktreeId, + updatedWorktree.tabs[newIndex].id, + ); + } else { + // No tabs left + setSelectedTabId(null); + } + } }, reopenClosedTab: () => { // TODO: implement reopen closed tab From 562e8a7ff1d4154d12d9fbea373b883ba936688c Mon Sep 17 00:00:00 2001 From: Aleksey Bykhun Date: Wed, 5 Nov 2025 18:16:06 -0800 Subject: [PATCH 03/17] fix keyboard shortcuts: auto-focus terminals and cmd+w reliability - auto-focus terminal when creating/switching tabs so users can type immediately - fix cmd+w not updating sidebar by clearing selection and forcing re-render - fix cmd+w not working when terminal focused by using event capture phase --- .../src/renderer/screens/main/MainScreen.tsx | 14 ++++++++------ .../main/components/MainContent/Terminal.tsx | 10 ++++++++++ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/MainScreen.tsx b/apps/desktop/src/renderer/screens/main/MainScreen.tsx index 9691c8fc26f..52f3a1db4af 100644 --- a/apps/desktop/src/renderer/screens/main/MainScreen.tsx +++ b/apps/desktop/src/renderer/screens/main/MainScreen.tsx @@ -1193,6 +1193,9 @@ export function MainScreen() { return; } + // Clear selected tab immediately to trigger UI update + setSelectedTabId(null); + // Refresh workspace const refreshedWorkspace = await window.ipcRenderer.invoke( "workspace-get", @@ -1200,7 +1203,8 @@ export function MainScreen() { ); if (refreshedWorkspace) { - setCurrentWorkspace(refreshedWorkspace); + // Force a new object reference to ensure React re-renders + setCurrentWorkspace({ ...refreshedWorkspace }); // Select adjacent tab const updatedWorktree = refreshedWorkspace.worktrees.find( @@ -1216,9 +1220,6 @@ export function MainScreen() { selectedWorktreeId, updatedWorktree.tabs[newIndex].id, ); - } else { - // No tabs left - setSelectedTabId(null); } } }, @@ -1249,9 +1250,10 @@ export function MainScreen() { tabHandler(event); }; - window.addEventListener("keydown", handleKeyDown); + // Use capture phase to intercept events before they reach terminal + window.addEventListener("keydown", handleKeyDown, true); return () => { - window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("keydown", handleKeyDown, true); }; }, [ workspaces, diff --git a/apps/desktop/src/renderer/screens/main/components/MainContent/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/MainContent/Terminal.tsx index 48ddced1b3a..6092e2c9c00 100644 --- a/apps/desktop/src/renderer/screens/main/components/MainContent/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/MainContent/Terminal.tsx @@ -95,6 +95,16 @@ export default function TerminalComponent({ onFocusRef.current = onFocus; }, [onFocus]); + // Auto-focus terminal when terminalId changes (new tab or switched tab) + useEffect(() => { + if (terminal && terminalId) { + // Small delay to ensure terminal is fully mounted + setTimeout(() => { + terminal.focus(); + }, 50); + } + }, [terminal, terminalId]); + useEffect(() => { if (terminal) { terminal.options.theme = From 497cc3802dc4734d2e8d458d00073da490dc4ceb Mon Sep 17 00:00:00 2001 From: Aleksey Bykhun Date: Wed, 5 Nov 2025 18:18:22 -0800 Subject: [PATCH 04/17] cmd+t new tab --- .../src/renderer/screens/main/MainScreen.tsx | 53 +++++++++++++------ 1 file changed, 38 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/MainScreen.tsx b/apps/desktop/src/renderer/screens/main/MainScreen.tsx index 52f3a1db4af..ca13b64e6d7 100644 --- a/apps/desktop/src/renderer/screens/main/MainScreen.tsx +++ b/apps/desktop/src/renderer/screens/main/MainScreen.tsx @@ -1157,22 +1157,45 @@ export function MainScreen() { }, newTab: async () => { if (!currentWorkspace || !selectedWorktreeId) return; - const tab = await createTab( - currentWorkspace.id, - selectedWorktreeId, - "Terminal", - "terminal", - ); - if (tab) { - // Refresh workspace - const refreshedWorkspace = await window.ipcRenderer.invoke( - "workspace-get", - currentWorkspace.id, - ); - if (refreshedWorkspace) { - setCurrentWorkspace(refreshedWorkspace); - handleTabSelect(selectedWorktreeId, tab.id); + + try { + const result = await window.ipcRenderer.invoke("tab-create", { + workspaceId: currentWorkspace.id, + worktreeId: selectedWorktreeId, + name: "New Terminal", + type: "terminal", + }); + + if (result.success && result.tab) { + const newTabId = result.tab.id; + // Set active selection immediately + await window.ipcRenderer.invoke("workspace-set-active-selection", { + workspaceId: currentWorkspace.id, + worktreeId: selectedWorktreeId, + tabId: newTabId, + }); + + // Update local state + setSelectedWorktreeId(selectedWorktreeId); + setSelectedTabId(newTabId); + + // Refresh workspace to get updated data + const refreshedWorkspace = await window.ipcRenderer.invoke( + "workspace-get", + currentWorkspace.id, + ); + if (refreshedWorkspace) { + setCurrentWorkspace({ + ...refreshedWorkspace, + activeWorktreeId: selectedWorktreeId, + activeTabId: newTabId, + }); + } + } else { + console.error("Failed to create tab:", result.error); } + } catch (error) { + console.error("Error creating new tab:", error); } }, closeTab: async () => { From 295f0fdb6fb7ec66bf65a7eae657e318a524948b Mon Sep 17 00:00:00 2001 From: Aleksey Bykhun Date: Wed, 5 Nov 2025 18:19:19 -0800 Subject: [PATCH 05/17] fix tab navigation to work within tab groups - cmd+option+up/down now switches between terminals inside a tab group when focused on grouped terminal - detects parentGroupTab and navigates group's children instead of top-level tabs - makes keyboard navigation context-aware --- .../src/renderer/screens/main/MainScreen.tsx | 38 +++++++++++++++---- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/MainScreen.tsx b/apps/desktop/src/renderer/screens/main/MainScreen.tsx index ca13b64e6d7..058bfb5f512 100644 --- a/apps/desktop/src/renderer/screens/main/MainScreen.tsx +++ b/apps/desktop/src/renderer/screens/main/MainScreen.tsx @@ -1141,18 +1141,40 @@ export function MainScreen() { const tabShortcuts = createTabShortcuts({ switchToPrevTab: () => { if (!selectedWorktree || !selectedTabId) return; - const tabs = selectedWorktree.tabs; - const currentIndex = tabs.findIndex((t) => t.id === selectedTabId); - if (currentIndex > 0) { - handleTabSelect(selectedWorktree.id, tabs[currentIndex - 1].id); + + // If we're inside a group tab, navigate between group's children + if (parentGroupTab && parentGroupTab.tabs) { + const tabs = parentGroupTab.tabs; + const currentIndex = tabs.findIndex((t) => t.id === selectedTabId); + if (currentIndex > 0) { + handleTabSelect(selectedWorktree.id, tabs[currentIndex - 1].id); + } + } else { + // Navigate between top-level tabs + const tabs = selectedWorktree.tabs; + const currentIndex = tabs.findIndex((t) => t.id === selectedTabId); + if (currentIndex > 0) { + handleTabSelect(selectedWorktree.id, tabs[currentIndex - 1].id); + } } }, switchToNextTab: () => { if (!selectedWorktree || !selectedTabId) return; - const tabs = selectedWorktree.tabs; - const currentIndex = tabs.findIndex((t) => t.id === selectedTabId); - if (currentIndex < tabs.length - 1) { - handleTabSelect(selectedWorktree.id, tabs[currentIndex + 1].id); + + // If we're inside a group tab, navigate between group's children + if (parentGroupTab && parentGroupTab.tabs) { + const tabs = parentGroupTab.tabs; + const currentIndex = tabs.findIndex((t) => t.id === selectedTabId); + if (currentIndex < tabs.length - 1) { + handleTabSelect(selectedWorktree.id, tabs[currentIndex + 1].id); + } + } else { + // Navigate between top-level tabs + const tabs = selectedWorktree.tabs; + const currentIndex = tabs.findIndex((t) => t.id === selectedTabId); + if (currentIndex < tabs.length - 1) { + handleTabSelect(selectedWorktree.id, tabs[currentIndex + 1].id); + } } }, newTab: async () => { From 0848154a464d050f9ad75b4c9fe816ec86345f0b Mon Sep 17 00:00:00 2001 From: Aleksey Bykhun Date: Wed, 5 Nov 2025 18:27:09 -0800 Subject: [PATCH 06/17] improve keyboard shortcuts: cmd+t, cmd+w, and cmd+1-9 - fix cmd+t to properly open new terminal by refreshing workspace before selection - fix cmd+w sidebar update by reordering workspace refresh and tab selection - fix cmd+1-9 to number flattened tabs including terminals inside groups --- .../src/renderer/screens/main/MainScreen.tsx | 63 +++++++++++-------- 1 file changed, 36 insertions(+), 27 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/MainScreen.tsx b/apps/desktop/src/renderer/screens/main/MainScreen.tsx index 058bfb5f512..bf1a50c3f60 100644 --- a/apps/desktop/src/renderer/screens/main/MainScreen.tsx +++ b/apps/desktop/src/renderer/screens/main/MainScreen.tsx @@ -1190,28 +1190,19 @@ export function MainScreen() { if (result.success && result.tab) { const newTabId = result.tab.id; - // Set active selection immediately - await window.ipcRenderer.invoke("workspace-set-active-selection", { - workspaceId: currentWorkspace.id, - worktreeId: selectedWorktreeId, - tabId: newTabId, - }); - - // Update local state - setSelectedWorktreeId(selectedWorktreeId); - setSelectedTabId(newTabId); - // Refresh workspace to get updated data + // Refresh workspace first to ensure we have the new tab const refreshedWorkspace = await window.ipcRenderer.invoke( "workspace-get", currentWorkspace.id, ); + if (refreshedWorkspace) { - setCurrentWorkspace({ - ...refreshedWorkspace, - activeWorktreeId: selectedWorktreeId, - activeTabId: newTabId, - }); + // Update workspace state + setCurrentWorkspace(refreshedWorkspace); + + // Then select the new tab (this will trigger focus) + handleTabSelect(selectedWorktreeId, newTabId); } } else { console.error("Failed to create tab:", result.error); @@ -1225,12 +1216,13 @@ export function MainScreen() { const tabs = selectedWorktree?.tabs || []; const currentIndex = tabs.findIndex((t) => t.id === selectedTabId); + const tabToClose = selectedTabId; // Delete the tab const result = await window.ipcRenderer.invoke("tab-delete", { workspaceId: currentWorkspace.id, worktreeId: selectedWorktreeId, - tabId: selectedTabId, + tabId: tabToClose, }); if (!result.success) { @@ -1238,23 +1230,21 @@ export function MainScreen() { return; } - // Clear selected tab immediately to trigger UI update - setSelectedTabId(null); - - // Refresh workspace + // Refresh workspace to get updated tab list const refreshedWorkspace = await window.ipcRenderer.invoke( "workspace-get", currentWorkspace.id, ); if (refreshedWorkspace) { - // Force a new object reference to ensure React re-renders - setCurrentWorkspace({ ...refreshedWorkspace }); + // Update workspace state first + setCurrentWorkspace(refreshedWorkspace); - // Select adjacent tab + // Find the worktree and select adjacent tab const updatedWorktree = refreshedWorkspace.worktrees.find( (wt) => wt.id === selectedWorktreeId, ); + if (updatedWorktree && updatedWorktree.tabs.length > 0) { // Try to select the tab at the same index, or the last tab const newIndex = Math.min( @@ -1265,6 +1255,9 @@ export function MainScreen() { selectedWorktreeId, updatedWorktree.tabs[newIndex].id, ); + } else { + // No tabs left, clear selection + setSelectedTabId(null); } } }, @@ -1274,9 +1267,25 @@ export function MainScreen() { }, jumpToTab: (index: number) => { if (!selectedWorktree) return; - const tabs = selectedWorktree.tabs; - if (index > 0 && index <= tabs.length) { - handleTabSelect(selectedWorktree.id, tabs[index - 1].id); + + // Flatten tabs: expand group children to number all actual terminals + const flattenTabs = (tabs: Tab[]): Tab[] => { + const result: Tab[] = []; + for (const tab of tabs) { + if (tab.type === "group" && tab.tabs && tab.tabs.length > 0) { + // Add all children of the group + result.push(...tab.tabs); + } else { + // Add non-group tabs + result.push(tab); + } + } + return result; + }; + + const flatTabs = flattenTabs(selectedWorktree.tabs); + if (index > 0 && index <= flatTabs.length) { + handleTabSelect(selectedWorktree.id, flatTabs[index - 1].id); } }, }); From 28c0e857f1866724365b6cd6645ef6815b827238 Mon Sep 17 00:00:00 2001 From: Aleksey Bykhun Date: Wed, 5 Nov 2025 18:29:46 -0800 Subject: [PATCH 07/17] fix terminal focus when selecting tabs inside tab groups - pass selectedTabId down to determine which terminal should be focused - only auto-focus terminal when isSelected is true - prevents all terminals in group from trying to focus simultaneously --- .../main/components/MainContent/TabContent.tsx | 11 ++++++++++- .../screens/main/components/MainContent/Terminal.tsx | 8 +++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/MainContent/TabContent.tsx b/apps/desktop/src/renderer/screens/main/components/MainContent/TabContent.tsx index 5eff08fa7b3..1833ff510e9 100644 --- a/apps/desktop/src/renderer/screens/main/components/MainContent/TabContent.tsx +++ b/apps/desktop/src/renderer/screens/main/components/MainContent/TabContent.tsx @@ -62,6 +62,7 @@ export default function TabContent({ workspaceId={workspaceId} worktreeId={worktreeId} groupTabId={groupTabId} + selectedTabId={selectedTabId} onFocus={handleFocus} /> ); @@ -132,6 +133,7 @@ interface TerminalTabContentProps { workspaceId?: string; worktreeId?: string; groupTabId: string; // ID of the parent group tab + selectedTabId?: string; // Currently selected tab ID onFocus: () => void; } @@ -141,10 +143,12 @@ function TerminalTabContent({ workspaceId, worktreeId, groupTabId, + selectedTabId, onFocus, }: TerminalTabContentProps) { const terminalId = tab.id; const terminalCreatedRef = useRef(false); + const isSelected = selectedTabId === tab.id; // Terminal creation and lifecycle useEffect(() => { @@ -227,7 +231,12 @@ function TerminalTabContent({ return (
- +
); } diff --git a/apps/desktop/src/renderer/screens/main/components/MainContent/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/MainContent/Terminal.tsx index 6092e2c9c00..08043a63a5e 100644 --- a/apps/desktop/src/renderer/screens/main/components/MainContent/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/MainContent/Terminal.tsx @@ -21,6 +21,7 @@ interface TerminalProps { terminalId?: string | null; hidden?: boolean; className?: string; + isSelected?: boolean; onFocus?: () => void; } @@ -81,6 +82,7 @@ export default function TerminalComponent({ terminalId, hidden = false, className = "", + isSelected = true, onFocus, }: TerminalProps) { const terminalRef = useRef(null); @@ -95,15 +97,15 @@ export default function TerminalComponent({ onFocusRef.current = onFocus; }, [onFocus]); - // Auto-focus terminal when terminalId changes (new tab or switched tab) + // Auto-focus terminal when selected (new tab or switched tab) useEffect(() => { - if (terminal && terminalId) { + if (terminal && terminalId && isSelected) { // Small delay to ensure terminal is fully mounted setTimeout(() => { terminal.focus(); }, 50); } - }, [terminal, terminalId]); + }, [terminal, terminalId, isSelected]); useEffect(() => { if (terminal) { From 8e53d26696b5bad7c8ebc9c9a1d4b89970d955c7 Mon Sep 17 00:00:00 2001 From: Aleksey Bykhun Date: Wed, 5 Nov 2025 18:34:10 -0800 Subject: [PATCH 08/17] fix cmd+t to show new terminal immediately without refresh - match sidebar button behavior by selecting tab before workspace refresh - prevents placeholder state from showing after creating new terminal - handleTabSelect sets local state first, then workspace refresh updates data --- apps/desktop/src/renderer/screens/main/MainScreen.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/MainScreen.tsx b/apps/desktop/src/renderer/screens/main/MainScreen.tsx index bf1a50c3f60..8197ee519a5 100644 --- a/apps/desktop/src/renderer/screens/main/MainScreen.tsx +++ b/apps/desktop/src/renderer/screens/main/MainScreen.tsx @@ -1191,18 +1191,17 @@ export function MainScreen() { if (result.success && result.tab) { const newTabId = result.tab.id; - // Refresh workspace first to ensure we have the new tab + // Select the new tab first (matches sidebar button behavior) + handleTabSelect(selectedWorktreeId, newTabId); + + // Then refresh workspace to get updated data const refreshedWorkspace = await window.ipcRenderer.invoke( "workspace-get", currentWorkspace.id, ); if (refreshedWorkspace) { - // Update workspace state setCurrentWorkspace(refreshedWorkspace); - - // Then select the new tab (this will trigger focus) - handleTabSelect(selectedWorktreeId, newTabId); } } else { console.error("Failed to create tab:", result.error); From 8b30a34f1e863ffa62549bdd0ebd21258611c4db Mon Sep 17 00:00:00 2001 From: Aleksey Bykhun Date: Wed, 5 Nov 2025 18:36:41 -0800 Subject: [PATCH 09/17] fix cmd+t sidebar not updating by refreshing workspaces list - sidebar renders from workspaces array via WorkspaceCarousel - need to call loadAllWorkspaces() to update sidebar after creating tab - matches sidebar button behavior that calls onWorktreeCreated --- apps/desktop/src/renderer/screens/main/MainScreen.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/desktop/src/renderer/screens/main/MainScreen.tsx b/apps/desktop/src/renderer/screens/main/MainScreen.tsx index 8197ee519a5..3be6ddbb75d 100644 --- a/apps/desktop/src/renderer/screens/main/MainScreen.tsx +++ b/apps/desktop/src/renderer/screens/main/MainScreen.tsx @@ -1202,6 +1202,8 @@ export function MainScreen() { if (refreshedWorkspace) { setCurrentWorkspace(refreshedWorkspace); + // Also refresh workspaces list for sidebar + await loadAllWorkspaces(); } } else { console.error("Failed to create tab:", result.error); From 343cdc34ff9f254474bbfdf97f060e579a2beb5a Mon Sep 17 00:00:00 2001 From: Aleksey Bykhun Date: Wed, 5 Nov 2025 18:39:33 -0800 Subject: [PATCH 10/17] fix cmd+w sidebar not updating by refreshing workspaces list - sidebar needs workspaces array refreshed to show deleted tab - call loadAllWorkspaces() after deleting tab to update sidebar --- apps/desktop/src/renderer/screens/main/MainScreen.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/desktop/src/renderer/screens/main/MainScreen.tsx b/apps/desktop/src/renderer/screens/main/MainScreen.tsx index 3be6ddbb75d..49ee9f7d974 100644 --- a/apps/desktop/src/renderer/screens/main/MainScreen.tsx +++ b/apps/desktop/src/renderer/screens/main/MainScreen.tsx @@ -1240,6 +1240,8 @@ export function MainScreen() { if (refreshedWorkspace) { // Update workspace state first setCurrentWorkspace(refreshedWorkspace); + // Also refresh workspaces list for sidebar + await loadAllWorkspaces(); // Find the worktree and select adjacent tab const updatedWorktree = refreshedWorkspace.worktrees.find( From f266c3f49b8ba4df389c36253471dd4e38d43e24 Mon Sep 17 00:00:00 2001 From: Aleksey Bykhun Date: Wed, 5 Nov 2025 18:45:02 -0800 Subject: [PATCH 11/17] improve split view shortcuts: auto-focus, sidebar updates, and proper cleanup - cmd+d/cmd+shift+d now auto-focus newly created terminal and update sidebar - cmd+w inside tab group properly collapses split view by updating mosaic tree - cmd+w intelligently selects adjacent tab within group or top-level - add removeTabFromMosaicTree helper to handle split view cleanup --- .../src/renderer/screens/main/MainScreen.tsx | 124 +++++++++++++++--- 1 file changed, 105 insertions(+), 19 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/MainScreen.tsx b/apps/desktop/src/renderer/screens/main/MainScreen.tsx index 49ee9f7d974..99214c30699 100644 --- a/apps/desktop/src/renderer/screens/main/MainScreen.tsx +++ b/apps/desktop/src/renderer/screens/main/MainScreen.tsx @@ -397,6 +397,41 @@ export function MainScreen() { return null; }; + // Helper: Remove tab ID from mosaic tree + const removeTabFromMosaicTree = ( + tree: MosaicNode, + tabId: string, + ): MosaicNode | null => { + if (typeof tree === "string") { + // If this is the tab to remove, return null + return tree === tabId ? null : tree; + } + + // Recursively remove from branches + const newFirst = removeTabFromMosaicTree(tree.first, tabId); + const newSecond = removeTabFromMosaicTree(tree.second, tabId); + + // If both branches are gone, return null + if (!newFirst && !newSecond) { + return null; + } + + // If one branch is gone, return the other + if (!newFirst) { + return newSecond; + } + if (!newSecond) { + return newFirst; + } + + // Both branches exist, keep the structure + return { + ...tree, + first: newFirst, + second: newSecond, + }; + }; + // Helper: Add tab ID to mosaic tree const addTabToMosaicTree = ( tree: MosaicNode | null | undefined, @@ -1004,11 +1039,12 @@ export function MainScreen() { mosaicTree, }); - setSelectedTabId(groupTab.id); + // Select the newly created terminal (not the group) + setSelectedTabId(newTab.id); await window.ipcRenderer.invoke("workspace-set-active-selection", { workspaceId: currentWorkspace.id, worktreeId: selectedWorktreeId, - tabId: groupTab.id, + tabId: newTab.id, }); } @@ -1019,6 +1055,8 @@ export function MainScreen() { ); if (refreshedWorkspace) { setCurrentWorkspace(refreshedWorkspace); + // Also refresh workspaces list for sidebar + await loadAllWorkspaces(); } }, createVerticalSplit: async () => { @@ -1119,11 +1157,12 @@ export function MainScreen() { mosaicTree, }); - setSelectedTabId(groupTab.id); + // Select the newly created terminal (not the group) + setSelectedTabId(newTab.id); await window.ipcRenderer.invoke("workspace-set-active-selection", { workspaceId: currentWorkspace.id, worktreeId: selectedWorktreeId, - tabId: groupTab.id, + tabId: newTab.id, }); } @@ -1134,6 +1173,8 @@ export function MainScreen() { ); if (refreshedWorkspace) { setCurrentWorkspace(refreshedWorkspace); + // Also refresh workspaces list for sidebar + await loadAllWorkspaces(); } }, }); @@ -1215,10 +1256,29 @@ export function MainScreen() { closeTab: async () => { if (!currentWorkspace || !selectedWorktreeId || !selectedTabId) return; - const tabs = selectedWorktree?.tabs || []; + // Check if we're inside a group tab + const isInGroup = !!parentGroupTab; + const tabs = isInGroup + ? parentGroupTab?.tabs || [] + : selectedWorktree?.tabs || []; const currentIndex = tabs.findIndex((t) => t.id === selectedTabId); const tabToClose = selectedTabId; + // If in a group, update mosaic tree first to remove the tab + if (isInGroup && parentGroupTab && parentGroupTab.mosaicTree) { + const updatedMosaicTree = removeTabFromMosaicTree( + parentGroupTab.mosaicTree as MosaicNode, + tabToClose, + ); + + await window.ipcRenderer.invoke("tab-update-mosaic-tree", { + workspaceId: currentWorkspace.id, + worktreeId: selectedWorktreeId, + tabId: parentGroupTab.id, + mosaicTree: updatedMosaicTree, + }); + } + // Delete the tab const result = await window.ipcRenderer.invoke("tab-delete", { workspaceId: currentWorkspace.id, @@ -1243,24 +1303,50 @@ export function MainScreen() { // Also refresh workspaces list for sidebar await loadAllWorkspaces(); - // Find the worktree and select adjacent tab + // Find the worktree and updated parent group if applicable const updatedWorktree = refreshedWorkspace.worktrees.find( (wt) => wt.id === selectedWorktreeId, ); - if (updatedWorktree && updatedWorktree.tabs.length > 0) { - // Try to select the tab at the same index, or the last tab - const newIndex = Math.min( - currentIndex, - updatedWorktree.tabs.length - 1, - ); - handleTabSelect( - selectedWorktreeId, - updatedWorktree.tabs[newIndex].id, - ); - } else { - // No tabs left, clear selection - setSelectedTabId(null); + if (updatedWorktree) { + // If we were in a group, find the updated group and select adjacent tab within it + if (isInGroup && parentGroupTab) { + const updatedGroupTab = findTabById( + updatedWorktree.tabs, + parentGroupTab.id, + ); + if ( + updatedGroupTab && + updatedGroupTab.tabs && + updatedGroupTab.tabs.length > 0 + ) { + // Select adjacent tab within the group + const newIndex = Math.min( + currentIndex, + updatedGroupTab.tabs.length - 1, + ); + handleTabSelect( + selectedWorktreeId, + updatedGroupTab.tabs[newIndex].id, + ); + } else { + // Group is now empty, clear selection or select the group itself + setSelectedTabId(null); + } + } else if (updatedWorktree.tabs.length > 0) { + // Top-level tab - select adjacent top-level tab + const newIndex = Math.min( + currentIndex, + updatedWorktree.tabs.length - 1, + ); + handleTabSelect( + selectedWorktreeId, + updatedWorktree.tabs[newIndex].id, + ); + } else { + // No tabs left, clear selection + setSelectedTabId(null); + } } } }, From ed46120254e5a6d72dfcd27647f81b7be30d6f9b Mon Sep 17 00:00:00 2001 From: Aleksey Bykhun Date: Wed, 5 Nov 2025 18:48:06 -0800 Subject: [PATCH 12/17] implement auto-close terminal tab on exit - terminal manager now emits terminal-exited event when pty process exits - renderer listens for exit events and auto-closes the tab - selects adjacent tab (respecting group context) if exited tab was focused - updates mosaic tree for split views and refreshes sidebar - silently removes background tabs without switching focus --- apps/desktop/src/main/lib/terminal.ts | 13 ++ .../src/renderer/screens/main/MainScreen.tsx | 122 ++++++++++++++++++ 2 files changed, 135 insertions(+) diff --git a/apps/desktop/src/main/lib/terminal.ts b/apps/desktop/src/main/lib/terminal.ts index baa30b6ceaa..4c7d4ad4532 100644 --- a/apps/desktop/src/main/lib/terminal.ts +++ b/apps/desktop/src/main/lib/terminal.ts @@ -74,6 +74,19 @@ class TerminalManager { this.addTerminalMessage(id, data); }); + // Handle terminal exit + ptyProcess.onExit(({ exitCode }) => { + console.log(`Terminal ${id} exited with code ${exitCode}`); + // Notify renderer that terminal has exited + this.mainWindow?.webContents.send("terminal-exited", { + id, + exitCode, + }); + // Clean up + this.processes.delete(id); + this.outputHistory.delete(id); + }); + this.processes.set(id, ptyProcess); return id; } catch (error) { diff --git a/apps/desktop/src/renderer/screens/main/MainScreen.tsx b/apps/desktop/src/renderer/screens/main/MainScreen.tsx index 99214c30699..e201f99850b 100644 --- a/apps/desktop/src/renderer/screens/main/MainScreen.tsx +++ b/apps/desktop/src/renderer/screens/main/MainScreen.tsx @@ -385,6 +385,128 @@ export function MainScreen() { }; }, []); + // Listen for terminal exit events and auto-close the tab + useEffect(() => { + const handleTerminalExit = async (data: { + id: string; + exitCode: number; + }) => { + console.log( + `[MainScreen] Terminal ${data.id} exited with code ${data.exitCode}`, + ); + + if (!currentWorkspace || !selectedWorktreeId) return; + + // Find which tab contains this terminal + const worktree = currentWorkspace.worktrees.find( + (wt) => wt.id === selectedWorktreeId, + ); + if (!worktree) return; + + // Check if the exited terminal is the currently selected tab + const isCurrentTab = selectedTabId === data.id; + + // Find the tab to determine its context (top-level or in group) + const tabResult = findTabRecursive(worktree.tabs, data.id); + if (!tabResult) return; + + const parentGroup = tabResult.parent; + const isInGroup = !!parentGroup; + + // Get the tabs array (either from group or top-level) + const tabs = isInGroup ? parentGroup?.tabs || [] : worktree.tabs; + const currentIndex = tabs.findIndex((t) => t.id === data.id); + + // Update mosaic tree if in a group + if (isInGroup && parentGroup && parentGroup.mosaicTree) { + const updatedMosaicTree = removeTabFromMosaicTree( + parentGroup.mosaicTree as MosaicNode, + data.id, + ); + + await window.ipcRenderer.invoke("tab-update-mosaic-tree", { + workspaceId: currentWorkspace.id, + worktreeId: selectedWorktreeId, + tabId: parentGroup.id, + mosaicTree: updatedMosaicTree, + }); + } + + // Delete the tab + const result = await window.ipcRenderer.invoke("tab-delete", { + workspaceId: currentWorkspace.id, + worktreeId: selectedWorktreeId, + tabId: data.id, + }); + + if (!result.success) { + console.error("Failed to close exited terminal tab:", result.error); + return; + } + + // Refresh workspace + const refreshedWorkspace = await window.ipcRenderer.invoke( + "workspace-get", + currentWorkspace.id, + ); + + if (refreshedWorkspace) { + setCurrentWorkspace(refreshedWorkspace); + await loadAllWorkspaces(); + + // Only select adjacent tab if the exited terminal was the current one + if (isCurrentTab) { + const updatedWorktree = refreshedWorkspace.worktrees.find( + (wt) => wt.id === selectedWorktreeId, + ); + + if (updatedWorktree) { + if (isInGroup && parentGroup) { + // Select adjacent tab within the group + const updatedGroupTab = findTabById( + updatedWorktree.tabs, + parentGroup.id, + ); + if ( + updatedGroupTab && + updatedGroupTab.tabs && + updatedGroupTab.tabs.length > 0 + ) { + const newIndex = Math.min( + currentIndex, + updatedGroupTab.tabs.length - 1, + ); + handleTabSelect( + selectedWorktreeId, + updatedGroupTab.tabs[newIndex].id, + ); + } else { + setSelectedTabId(null); + } + } else if (updatedWorktree.tabs.length > 0) { + // Select adjacent top-level tab + const newIndex = Math.min( + currentIndex, + updatedWorktree.tabs.length - 1, + ); + handleTabSelect( + selectedWorktreeId, + updatedWorktree.tabs[newIndex].id, + ); + } else { + setSelectedTabId(null); + } + } + } + } + }; + + window.ipcRenderer.on("terminal-exited", handleTerminalExit); + return () => { + window.ipcRenderer.off("terminal-exited", handleTerminalExit); + }; + }, [currentWorkspace, selectedWorktreeId, selectedTabId]); + // Helper: recursively find a tab by ID const findTabById = (tabs: Tab[], tabId: string): Tab | null => { for (const tab of tabs) { From 7c1f57dce97aa431b09474585ee62930513b2fca Mon Sep 17 00:00:00 2001 From: Aleksey Bykhun Date: Wed, 5 Nov 2025 18:55:00 -0800 Subject: [PATCH 13/17] fix terminal display glitches: scroll position and padding-aware sizing --- .../main/components/MainContent/Terminal.tsx | 78 +++++++++++++------ 1 file changed, 53 insertions(+), 25 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/MainContent/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/MainContent/Terminal.tsx index 08043a63a5e..069d82f9943 100644 --- a/apps/desktop/src/renderer/screens/main/components/MainContent/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/MainContent/Terminal.tsx @@ -205,7 +205,7 @@ export default function TerminalComponent({ const fitAddon = new FitAddon(); term.loadAddon(fitAddon); - // Custom fit function that accounts for container dimensions properly + // Custom fit function that accounts for container dimensions and padding const customFit = () => { if (isDisposed) return; @@ -219,11 +219,35 @@ export default function TerminalComponent({ return; // Skip if container has no dimensions yet } - // Use proposeDimensions to calculate optimal size without applying it - // Then manually resize to ensure PTY gets the correct dimensions - const dimensions = fitAddon.proposeDimensions(); - if (dimensions) { - term.resize(dimensions.cols, dimensions.rows); + // Account for the 8px padding on .xterm-screen (8px on all sides) + // This ensures dimensions are calculated correctly and no partial lines show + const PADDING_HORIZONTAL = 16; // 8px left + 8px right + const PADDING_VERTICAL = 16; // 8px top + 8px bottom + + // Get the core renderer dimensions (character size) + const core = (term as any)._core; + if (!core) { + // Fallback to default fit if core not available + const dimensions = fitAddon.proposeDimensions(); + if (dimensions) { + term.resize(dimensions.cols, dimensions.rows); + } + return; + } + + const cellWidth = core._renderService?.dimensions?.actualCellWidth || 9; + const cellHeight = + core._renderService?.dimensions?.actualCellHeight || 17; + + // Calculate rows and cols with padding subtracted + const availableWidth = width - PADDING_HORIZONTAL; + const availableHeight = height - PADDING_VERTICAL; + + const cols = Math.floor(availableWidth / cellWidth); + const rows = Math.floor(availableHeight / cellHeight); + + if (cols > 0 && rows > 0) { + term.resize(cols, rows); } } catch (e) { console.warn("Custom fit failed:", e); @@ -244,10 +268,6 @@ export default function TerminalComponent({ term.write(data); }; - // Perform initial fit to size terminal correctly on first render - // This ensures the terminal has correct dimensions when it first appears - customFit(); - // Listen for container resize to auto-fit terminal // Use ResizeObserver to detect when the container size changes // Debounce resize to prevent excessive fit calls that cause terminal corruption @@ -295,27 +315,35 @@ export default function TerminalComponent({ JSON.stringify(history.slice(-50)), ); // Write history directly - PTY data already has proper formatting - term.write(history); - - // Delay initial fit AFTER writing history to prevent resize events - // from triggering the shell to redraw the prompt - setTimeout(() => { - if (!isDisposed) { - customFit(); - // Mark initial setup as complete after first fit - isInitialSetup = false; - } - }, 100); + term.write(history, () => { + // After history is written, scroll to bottom to show latest content + term.scrollToBottom(); + }); } + + // Perform initial fit AFTER history is loaded and written + // This prevents the terminal from shifting when reconnecting + setTimeout(() => { + if (!isDisposed) { + customFit(); + // Ensure we're scrolled to bottom after fit as well + term.scrollToBottom(); + // Mark initial setup as complete after first fit + isInitialSetup = false; + } + }, 100); }) .catch((error: Error) => { console.error("Failed to get terminal history:", error); - // Mark initial setup as complete even on error - isInitialSetup = false; + // Still perform initial fit even on error + if (!isDisposed) { + customFit(); + isInitialSetup = false; + } }); } else { - // Mark initial setup as complete for new terminals - // Initial fit was already performed above + // New terminal - perform initial fit immediately + customFit(); setTimeout(() => { if (!isDisposed) { isInitialSetup = false; From 2e2149b9d6bc713d4955217cf6cf0250b754d1c3 Mon Sep 17 00:00:00 2001 From: Aleksey Bykhun Date: Wed, 5 Nov 2025 18:55:10 -0800 Subject: [PATCH 14/17] Revert "fix terminal display glitches: scroll position and padding-aware sizing" This reverts commit 7c1f57dce97aa431b09474585ee62930513b2fca. --- .../main/components/MainContent/Terminal.tsx | 78 ++++++------------- 1 file changed, 25 insertions(+), 53 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/MainContent/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/MainContent/Terminal.tsx index 069d82f9943..08043a63a5e 100644 --- a/apps/desktop/src/renderer/screens/main/components/MainContent/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/MainContent/Terminal.tsx @@ -205,7 +205,7 @@ export default function TerminalComponent({ const fitAddon = new FitAddon(); term.loadAddon(fitAddon); - // Custom fit function that accounts for container dimensions and padding + // Custom fit function that accounts for container dimensions properly const customFit = () => { if (isDisposed) return; @@ -219,35 +219,11 @@ export default function TerminalComponent({ return; // Skip if container has no dimensions yet } - // Account for the 8px padding on .xterm-screen (8px on all sides) - // This ensures dimensions are calculated correctly and no partial lines show - const PADDING_HORIZONTAL = 16; // 8px left + 8px right - const PADDING_VERTICAL = 16; // 8px top + 8px bottom - - // Get the core renderer dimensions (character size) - const core = (term as any)._core; - if (!core) { - // Fallback to default fit if core not available - const dimensions = fitAddon.proposeDimensions(); - if (dimensions) { - term.resize(dimensions.cols, dimensions.rows); - } - return; - } - - const cellWidth = core._renderService?.dimensions?.actualCellWidth || 9; - const cellHeight = - core._renderService?.dimensions?.actualCellHeight || 17; - - // Calculate rows and cols with padding subtracted - const availableWidth = width - PADDING_HORIZONTAL; - const availableHeight = height - PADDING_VERTICAL; - - const cols = Math.floor(availableWidth / cellWidth); - const rows = Math.floor(availableHeight / cellHeight); - - if (cols > 0 && rows > 0) { - term.resize(cols, rows); + // Use proposeDimensions to calculate optimal size without applying it + // Then manually resize to ensure PTY gets the correct dimensions + const dimensions = fitAddon.proposeDimensions(); + if (dimensions) { + term.resize(dimensions.cols, dimensions.rows); } } catch (e) { console.warn("Custom fit failed:", e); @@ -268,6 +244,10 @@ export default function TerminalComponent({ term.write(data); }; + // Perform initial fit to size terminal correctly on first render + // This ensures the terminal has correct dimensions when it first appears + customFit(); + // Listen for container resize to auto-fit terminal // Use ResizeObserver to detect when the container size changes // Debounce resize to prevent excessive fit calls that cause terminal corruption @@ -315,35 +295,27 @@ export default function TerminalComponent({ JSON.stringify(history.slice(-50)), ); // Write history directly - PTY data already has proper formatting - term.write(history, () => { - // After history is written, scroll to bottom to show latest content - term.scrollToBottom(); - }); + term.write(history); + + // Delay initial fit AFTER writing history to prevent resize events + // from triggering the shell to redraw the prompt + setTimeout(() => { + if (!isDisposed) { + customFit(); + // Mark initial setup as complete after first fit + isInitialSetup = false; + } + }, 100); } - - // Perform initial fit AFTER history is loaded and written - // This prevents the terminal from shifting when reconnecting - setTimeout(() => { - if (!isDisposed) { - customFit(); - // Ensure we're scrolled to bottom after fit as well - term.scrollToBottom(); - // Mark initial setup as complete after first fit - isInitialSetup = false; - } - }, 100); }) .catch((error: Error) => { console.error("Failed to get terminal history:", error); - // Still perform initial fit even on error - if (!isDisposed) { - customFit(); - isInitialSetup = false; - } + // Mark initial setup as complete even on error + isInitialSetup = false; }); } else { - // New terminal - perform initial fit immediately - customFit(); + // Mark initial setup as complete for new terminals + // Initial fit was already performed above setTimeout(() => { if (!isDisposed) { isInitialSetup = false; From fdd3d3cdbe986bd2c4512c612e35a0f721f8e498 Mon Sep 17 00:00:00 2001 From: Aleksey Bykhun Date: Wed, 5 Nov 2025 19:08:38 -0800 Subject: [PATCH 15/17] improve sidebar controls and fix split view refresh issues - cmd+s now fully collapses/expands sidebar using ResizablePanel API - add hover overlay on left edge to show sidebar when hidden - cmd+d/shift+d inside tab groups now add to existing group instead of nesting - fix split view not refreshing by clearing selection before workspace update - add key props to TabGroup to force re-mount when mosaic tree changes - ensure state synchronization with setTimeout for next tick updates --- .../src/renderer/screens/main/MainScreen.tsx | 169 +++++++++++++----- 1 file changed, 124 insertions(+), 45 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/MainScreen.tsx b/apps/desktop/src/renderer/screens/main/MainScreen.tsx index e201f99850b..be8cacef3aa 100644 --- a/apps/desktop/src/renderer/screens/main/MainScreen.tsx +++ b/apps/desktop/src/renderer/screens/main/MainScreen.tsx @@ -17,7 +17,8 @@ import { ResizablePanel, ResizablePanelGroup, } from "@superset/ui/resizable"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import type { ImperativePanelHandle } from "react-resizable-panels"; import type { MosaicNode, Tab, TabType, Workspace } from "shared/types"; import { AppFrame } from "./components/AppFrame"; import { Background } from "./components/Background"; @@ -68,6 +69,8 @@ function DroppableMainContent({ export function MainScreen() { const [isSidebarOpen, setIsSidebarOpen] = useState(true); + const [showSidebarOverlay, setShowSidebarOverlay] = useState(false); + const sidebarPanelRef = useRef(null); const [workspaces, setWorkspaces] = useState(null); const [currentWorkspace, setCurrentWorkspace] = useState( null, @@ -444,6 +447,13 @@ export function MainScreen() { return; } + // Temporarily clear selection to force unmount if it's the current tab + const savedTabId = selectedTabId; + const savedWorktreeId = selectedWorktreeId; + if (isCurrentTab) { + setSelectedTabId(null); + } + // Refresh workspace const refreshedWorkspace = await window.ipcRenderer.invoke( "workspace-get", @@ -454,10 +464,13 @@ export function MainScreen() { setCurrentWorkspace(refreshedWorkspace); await loadAllWorkspaces(); + // Wait for next tick to ensure state updates + await new Promise((resolve) => setTimeout(resolve, 0)); + // Only select adjacent tab if the exited terminal was the current one if (isCurrentTab) { const updatedWorktree = refreshedWorkspace.worktrees.find( - (wt) => wt.id === selectedWorktreeId, + (wt) => wt.id === savedWorktreeId, ); if (updatedWorktree) { @@ -477,7 +490,7 @@ export function MainScreen() { updatedGroupTab.tabs.length - 1, ); handleTabSelect( - selectedWorktreeId, + savedWorktreeId, updatedGroupTab.tabs[newIndex].id, ); } else { @@ -490,7 +503,7 @@ export function MainScreen() { updatedWorktree.tabs.length - 1, ); handleTabSelect( - selectedWorktreeId, + savedWorktreeId, updatedWorktree.tabs[newIndex].id, ); } else { @@ -1071,14 +1084,23 @@ export function MainScreen() { } }, toggleSidebar: () => { - setIsSidebarOpen((prev) => !prev); + const panel = sidebarPanelRef.current; + if (!panel) return; + + if (panel.isCollapsed()) { + panel.expand(); + setIsSidebarOpen(true); + } else { + panel.collapse(); + setIsSidebarOpen(false); + } }, createSplitView: async () => { // Create horizontal split if (!currentWorkspace || !selectedWorktreeId || !selectedTab) return; - // If already in a group, add to that group - if (selectedTab.type === "group") { + // If we're inside a group (parentGroupTab exists), add to that group + if (parentGroupTab) { const newTab = await createTab( currentWorkspace.id, selectedWorktreeId, @@ -1087,28 +1109,36 @@ export function MainScreen() { ); if (!newTab) return; - // Move into the group + // Move into the parent group await window.ipcRenderer.invoke("tab-move", { workspaceId: currentWorkspace.id, worktreeId: selectedWorktreeId, tabId: newTab.id, sourceParentTabId: undefined, - targetParentTabId: selectedTab.id, - targetIndex: selectedTab.tabs?.length || 0, + targetParentTabId: parentGroupTab.id, + targetIndex: parentGroupTab.tabs?.length || 0, }); - // Update mosaic tree (horizontal split) + // Update mosaic tree (horizontal split) - add to existing group's mosaic const updatedMosaicTree = addTabToMosaicTree( - selectedTab.mosaicTree, + parentGroupTab.mosaicTree, newTab.id, ); await window.ipcRenderer.invoke("tab-update-mosaic-tree", { workspaceId: currentWorkspace.id, worktreeId: selectedWorktreeId, - tabId: selectedTab.id, + tabId: parentGroupTab.id, mosaicTree: updatedMosaicTree, }); + + // Select the newly created terminal + setSelectedTabId(newTab.id); + await window.ipcRenderer.invoke("workspace-set-active-selection", { + workspaceId: currentWorkspace.id, + worktreeId: selectedWorktreeId, + tabId: newTab.id, + }); } else { // Create new group with horizontal split const newTab = await createTab( @@ -1185,8 +1215,8 @@ export function MainScreen() { // Create vertical split if (!currentWorkspace || !selectedWorktreeId || !selectedTab) return; - // If already in a group, add to that group with column direction - if (selectedTab.type === "group") { + // If we're inside a group (parentGroupTab exists), add to that group + if (parentGroupTab) { const newTab = await createTab( currentWorkspace.id, selectedWorktreeId, @@ -1195,28 +1225,28 @@ export function MainScreen() { ); if (!newTab) return; - // Move into the group + // Move into the parent group await window.ipcRenderer.invoke("tab-move", { workspaceId: currentWorkspace.id, worktreeId: selectedWorktreeId, tabId: newTab.id, sourceParentTabId: undefined, - targetParentTabId: selectedTab.id, - targetIndex: selectedTab.tabs?.length || 0, + targetParentTabId: parentGroupTab.id, + targetIndex: parentGroupTab.tabs?.length || 0, }); // Update mosaic tree with column direction for vertical split const updatedMosaicTree: MosaicNode = - typeof selectedTab.mosaicTree === "string" + typeof parentGroupTab.mosaicTree === "string" ? { direction: "column", - first: selectedTab.mosaicTree, + first: parentGroupTab.mosaicTree, second: newTab.id, splitPercentage: 50, } : { direction: "column", - first: selectedTab.mosaicTree, + first: parentGroupTab.mosaicTree, second: newTab.id, splitPercentage: 50, }; @@ -1224,9 +1254,17 @@ export function MainScreen() { await window.ipcRenderer.invoke("tab-update-mosaic-tree", { workspaceId: currentWorkspace.id, worktreeId: selectedWorktreeId, - tabId: selectedTab.id, + tabId: parentGroupTab.id, mosaicTree: updatedMosaicTree, }); + + // Select the newly created terminal + setSelectedTabId(newTab.id); + await window.ipcRenderer.invoke("workspace-set-active-selection", { + workspaceId: currentWorkspace.id, + worktreeId: selectedWorktreeId, + tabId: newTab.id, + }); } else { // Create new group with vertical split const newTab = await createTab( @@ -1386,7 +1424,19 @@ export function MainScreen() { const currentIndex = tabs.findIndex((t) => t.id === selectedTabId); const tabToClose = selectedTabId; - // If in a group, update mosaic tree first to remove the tab + // Delete the tab first + const result = await window.ipcRenderer.invoke("tab-delete", { + workspaceId: currentWorkspace.id, + worktreeId: selectedWorktreeId, + tabId: tabToClose, + }); + + if (!result.success) { + console.error("Failed to close tab:", result.error); + return; + } + + // Then update mosaic tree if in a group (after deletion) if (isInGroup && parentGroupTab && parentGroupTab.mosaicTree) { const updatedMosaicTree = removeTabFromMosaicTree( parentGroupTab.mosaicTree as MosaicNode, @@ -1401,18 +1451,6 @@ export function MainScreen() { }); } - // Delete the tab - const result = await window.ipcRenderer.invoke("tab-delete", { - workspaceId: currentWorkspace.id, - worktreeId: selectedWorktreeId, - tabId: tabToClose, - }); - - if (!result.success) { - console.error("Failed to close tab:", result.error); - return; - } - // Refresh workspace to get updated tab list const refreshedWorkspace = await window.ipcRenderer.invoke( "workspace-get", @@ -1420,22 +1458,26 @@ export function MainScreen() { ); if (refreshedWorkspace) { - // Update workspace state first - setCurrentWorkspace(refreshedWorkspace); - // Also refresh workspaces list for sidebar + // Force update workspaces list first for sidebar await loadAllWorkspaces(); + // Update workspace state with new object reference + setCurrentWorkspace(refreshedWorkspace); + // Find the worktree and updated parent group if applicable const updatedWorktree = refreshedWorkspace.worktrees.find( (wt) => wt.id === selectedWorktreeId, ); if (updatedWorktree) { - // If we were in a group, find the updated group and select adjacent tab within it - if (isInGroup && parentGroupTab) { + // Re-find the parent group from the refreshed workspace + const wasInGroup = isInGroup; + const oldParentId = parentGroupTab?.id; + + if (wasInGroup && oldParentId) { const updatedGroupTab = findTabById( updatedWorktree.tabs, - parentGroupTab.id, + oldParentId, ); if ( updatedGroupTab && @@ -1448,11 +1490,11 @@ export function MainScreen() { updatedGroupTab.tabs.length - 1, ); handleTabSelect( - selectedWorktreeId, + savedWorktreeId, updatedGroupTab.tabs[newIndex].id, ); } else { - // Group is now empty, clear selection or select the group itself + // Group is now empty, clear selection setSelectedTabId(null); } } else if (updatedWorktree.tabs.length > 0) { @@ -1462,7 +1504,7 @@ export function MainScreen() { updatedWorktree.tabs.length - 1, ); handleTabSelect( - selectedWorktreeId, + savedWorktreeId, updatedWorktree.tabs[newIndex].id, ); } else { @@ -1539,14 +1581,49 @@ export function MainScreen() {
+ {/* Hover trigger area when sidebar is hidden */} + {!isSidebarOpen && ( +
setShowSidebarOverlay(true)} + /> + )} + + {/* Sidebar overlay when hidden and hovering */} + {!isSidebarOpen && showSidebarOverlay && workspaces && ( +
setShowSidebarOverlay(false)} + > +
+ { + setShowSidebarOverlay(false); + }} + isDragging={!!activeId} + /> +
+
+ )} + {/* App Frame - continuous border + sidebar + topbar */} setIsSidebarOpen(false)} + onExpand={() => setIsSidebarOpen(true)} > {isSidebarOpen && workspaces && ( Date: Wed, 5 Nov 2025 19:34:16 -0800 Subject: [PATCH 16/17] Revert "fix keyboard shortcuts: auto-focus terminals and cmd+w reliability" This reverts commit 562e8a7ff1d4154d12d9fbea373b883ba936688c. --- .../main/components/MainContent/Terminal.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/MainContent/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/MainContent/Terminal.tsx index 08043a63a5e..5625d3b7386 100644 --- a/apps/desktop/src/renderer/screens/main/components/MainContent/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/MainContent/Terminal.tsx @@ -97,15 +97,15 @@ export default function TerminalComponent({ onFocusRef.current = onFocus; }, [onFocus]); - // Auto-focus terminal when selected (new tab or switched tab) - useEffect(() => { - if (terminal && terminalId && isSelected) { - // Small delay to ensure terminal is fully mounted - setTimeout(() => { - terminal.focus(); - }, 50); - } - }, [terminal, terminalId, isSelected]); + // // Auto-focus terminal when selected (new tab or switched tab) + // useEffect(() => { + // if (terminal && terminalId && isSelected) { + // // Small delay to ensure terminal is fully mounted + // setTimeout(() => { + // terminal?.textarea?.focus(); + // }, 50); + // } + // }, [terminal, terminalId, isSelected]); useEffect(() => { if (terminal) { From eba2f8500542e40d0dc1e7569f942f87866a947b Mon Sep 17 00:00:00 2001 From: Aleksey Bykhun Date: Wed, 5 Nov 2025 21:58:37 -0800 Subject: [PATCH 17/17] fix: handle undefined mosaicTree and correct worktree ID in tab selection - Added null check for parentGroupTab.mosaicTree to prevent runtime errors when creating vertical splits - Fixed tab selection to use selectedWorktreeId instead of savedWorktreeId for accurate worktree context --- .../src/renderer/screens/main/MainScreen.tsx | 35 ++++++++++--------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/MainScreen.tsx b/apps/desktop/src/renderer/screens/main/MainScreen.tsx index be8cacef3aa..db7a7b26640 100644 --- a/apps/desktop/src/renderer/screens/main/MainScreen.tsx +++ b/apps/desktop/src/renderer/screens/main/MainScreen.tsx @@ -1235,21 +1235,22 @@ export function MainScreen() { targetIndex: parentGroupTab.tabs?.length || 0, }); - // Update mosaic tree with column direction for vertical split - const updatedMosaicTree: MosaicNode = - typeof parentGroupTab.mosaicTree === "string" - ? { - direction: "column", - first: parentGroupTab.mosaicTree, - second: newTab.id, - splitPercentage: 50, - } - : { - direction: "column", - first: parentGroupTab.mosaicTree, - second: newTab.id, - splitPercentage: 50, - }; + const first = parentGroupTab.mosaicTree; + + if (!first) { + console.error( + "Failed to create vertical split: parentGroupTab.mosaicTree is undefined" + ); + return; + } + + // Update mosaic tree with column direction for vertical split + const updatedMosaicTree: MosaicNode = { + direction: "column", + first, + second: newTab.id, + splitPercentage: 50, + } satisfies MosaicNode; await window.ipcRenderer.invoke("tab-update-mosaic-tree", { workspaceId: currentWorkspace.id, @@ -1490,7 +1491,7 @@ export function MainScreen() { updatedGroupTab.tabs.length - 1, ); handleTabSelect( - savedWorktreeId, + selectedWorktreeId, updatedGroupTab.tabs[newIndex].id, ); } else { @@ -1504,7 +1505,7 @@ export function MainScreen() { updatedWorktree.tabs.length - 1, ); handleTabSelect( - savedWorktreeId, + selectedWorktreeId, updatedWorktree.tabs[newIndex].id, ); } else {