diff --git a/packages/apps/app/e2e/core/16-tab-management.spec.ts b/packages/apps/app/e2e/core/16-tab-management.spec.ts index 1663de502..ef681c79e 100644 --- a/packages/apps/app/e2e/core/16-tab-management.spec.ts +++ b/packages/apps/app/e2e/core/16-tab-management.spec.ts @@ -12,6 +12,16 @@ async function getVisibleInputValue(page: import('@playwright/test').Page): Prom }) } +/** Read the title of the currently-active tab from the tab store. Task tabs + * stay mounted (display:none) so all their s remain visible per the + * layout; only the store knows which one is actually active. */ +async function getActiveTabTitle(page: import('@playwright/test').Page): Promise { + return page.evaluate(() => { + const s = (window as any).__slayzone_tabStore.getState() + return s.tabs[s.activeTabIndex]?.title ?? null + }) +} + test.describe('Tab management & keyboard shortcuts', () => { let projectAbbrev: string @@ -173,4 +183,77 @@ test.describe('Tab management & keyboard shortcuts', () => { const value = await getVisibleInputValue(mainWindow) expect(value).toBe('Tab task A') }) + + test('Cmd+Option+Right cycles forward through task tabs and wraps', async ({ mainWindow }) => { + // Ensure all 3 task tabs (A, B, C) are open, then start on the first one. + for (const title of ['Tab task A', 'Tab task B', 'Tab task C']) { + await goHome(mainWindow) + await expect(mainWindow.getByText(title).first()).toBeVisible({ timeout: 5_000 }) + await mainWindow.getByText(title).first().click() + await expect( + mainWindow.locator('[data-testid="terminal-mode-trigger"]:visible').first() + ).toBeVisible({ timeout: 5_000 }) + } + await mainWindow.keyboard.press('Meta+1') + await expect( + mainWindow.locator('[data-testid="terminal-mode-trigger"]:visible').first() + ).toBeVisible({ timeout: 5_000 }) + expect(await getActiveTabTitle(mainWindow)).toBe('Tab task A') + + await mainWindow.keyboard.press('Meta+Alt+ArrowRight') + expect(await getActiveTabTitle(mainWindow)).toBe('Tab task B') + + await mainWindow.keyboard.press('Meta+Alt+ArrowRight') + expect(await getActiveTabTitle(mainWindow)).toBe('Tab task C') + + // Wrap-around: last → first + await mainWindow.keyboard.press('Meta+Alt+ArrowRight') + expect(await getActiveTabTitle(mainWindow)).toBe('Tab task A') + }) + + test('Cmd+Option+Left cycles backward through task tabs and wraps', async ({ mainWindow }) => { + await mainWindow.keyboard.press('Meta+1') + await expect( + mainWindow.locator('[data-testid="terminal-mode-trigger"]:visible').first() + ).toBeVisible({ timeout: 5_000 }) + expect(await getActiveTabTitle(mainWindow)).toBe('Tab task A') + + // Wrap-around: first → last + await mainWindow.keyboard.press('Meta+Alt+ArrowLeft') + expect(await getActiveTabTitle(mainWindow)).toBe('Tab task C') + + await mainWindow.keyboard.press('Meta+Alt+ArrowLeft') + expect(await getActiveTabTitle(mainWindow)).toBe('Tab task B') + + await mainWindow.keyboard.press('Meta+Alt+ArrowLeft') + expect(await getActiveTabTitle(mainWindow)).toBe('Tab task A') + }) + + test('Cmd+Option+Right is suppressed while focused in a text input', async ({ mainWindow }) => { + await mainWindow.keyboard.press('Meta+1') + await expect( + mainWindow.locator('[data-testid="terminal-mode-trigger"]:visible').first() + ).toBeVisible({ timeout: 5_000 }) + expect(await getActiveTabTitle(mainWindow)).toBe('Tab task A') + + // Focus the visible task title input (an ) — guard should NOT switch tabs. + const titleInput = mainWindow.locator('input:visible').first() + await titleInput.focus() + await mainWindow.keyboard.press('Meta+Alt+ArrowRight') + + // Active task tab is unchanged. + expect(await getActiveTabTitle(mainWindow)).toBe('Tab task A') + }) + + test('Cmd+Option+Right/Left from home jumps to first / last task tab', async ({ mainWindow }) => { + await goHome(mainWindow) + // Home tab active — next jumps to FIRST task tab. + await mainWindow.keyboard.press('Meta+Alt+ArrowRight') + expect(await getActiveTabTitle(mainWindow)).toBe('Tab task A') + + await goHome(mainWindow) + // Home tab active — prev jumps to LAST task tab. + await mainWindow.keyboard.press('Meta+Alt+ArrowLeft') + expect(await getActiveTabTitle(mainWindow)).toBe('Tab task C') + }) }) diff --git a/packages/apps/app/src/renderer/src/app-shell/useAppShortcuts.ts b/packages/apps/app/src/renderer/src/app-shell/useAppShortcuts.ts index bb75000ef..9346b721f 100644 --- a/packages/apps/app/src/renderer/src/app-shell/useAppShortcuts.ts +++ b/packages/apps/app/src/renderer/src/app-shell/useAppShortcuts.ts @@ -95,7 +95,10 @@ export function useAppShortcuts(deps: AppShortcutsDeps): void { // ensures re-render when shortcuts change, so useHotkeys picks up new key strings. const getKeys = useCallback( (id: string): string => { - if (overrides[id]) return overrides[id] + // `id in overrides` distinguishes an explicit `null` (user disabled the + // shortcut) from a missing override (never customized → use default). + // A `null` override resolves to '' so nothing binds. + if (id in overrides) return overrides[id] ?? '' const def = shortcutDefinitions.find((d) => d.id === id) return def?.defaultKeys ?? '' }, @@ -193,6 +196,52 @@ export function useAppShortcuts(deps: AppShortcutsDeps): void { { enableOnFormTags: true, enabled: !isRecording } ) + // Cycle through open task tabs (skip the home tab), wrapping at the edges. + const navigateTaskTabs = useCallback( + (direction: 1 | -1) => { + // Task tabs live at visibleIndex 1..length-1 (visibleTabs[0] is home). + if (visibleTabs.length <= 1) return + const taskCount = visibleTabs.length - 1 + const visibleIdx = toVisibleIndex(useTabStore.getState().activeTabIndex) + // Treat home / unknown position as "before the first task tab" so + // next jumps to first and prev jumps to last — same as Chrome. + const currentTaskPos = visibleIdx >= 1 ? visibleIdx - 1 : direction === 1 ? -1 : 0 + const nextTaskPos = (currentTaskPos + direction + taskCount) % taskCount + useTabStore.getState().setActiveView('tabs') + setActiveTabIndex(toFullIndex(nextTaskPos + 1)) + }, + [visibleTabs.length, toFullIndex, toVisibleIndex, setActiveTabIndex] + ) + + useGuardedHotkeys( + getKeys('next-task-tab'), + (e) => { + // macOS Cmd+Option+Right is "next word" in text fields; don't hijack. + const el = e.target as HTMLElement + if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT') return + if (el.isContentEditable || el.getAttribute('role') === 'textbox') return + if (el.closest?.('.cm-editor') || el.closest?.('.xterm')) return + if (el.closest?.('.milkdown') || el.closest?.('.ProseMirror')) return + e.preventDefault() + navigateTaskTabs(1) + }, + { enableOnFormTags: true, enabled: !isRecording } + ) + + useGuardedHotkeys( + getKeys('prev-task-tab'), + (e) => { + const el = e.target as HTMLElement + if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT') return + if (el.isContentEditable || el.getAttribute('role') === 'textbox') return + if (el.closest?.('.cm-editor') || el.closest?.('.xterm')) return + if (el.closest?.('.milkdown') || el.closest?.('.ProseMirror')) return + e.preventDefault() + navigateTaskTabs(-1) + }, + { enableOnFormTags: true, enabled: !isRecording } + ) + useGuardedHotkeys( 'mod+shift+1,mod+shift+2,mod+shift+3,mod+shift+4,mod+shift+5,mod+shift+6,mod+shift+7,mod+shift+8,mod+shift+9', (e) => { diff --git a/packages/shared/shortcuts/src/definitions.ts b/packages/shared/shortcuts/src/definitions.ts index 51d2ef0e3..1c3b5cd74 100644 --- a/packages/shared/shortcuts/src/definitions.ts +++ b/packages/shared/shortcuts/src/definitions.ts @@ -152,6 +152,22 @@ export const shortcutDefinitions: ShortcutDefinition[] = [ defaultKeys: 'ctrl+shift+tab', scope: 'global' }, + { + id: 'next-task-tab', + label: 'Next Task Tab', + group: 'Tabs', + defaultKeys: 'mod+alt+arrowright', + scope: 'global', + customizable: true + }, + { + id: 'prev-task-tab', + label: 'Previous Task Tab', + group: 'Tabs', + defaultKeys: 'mod+alt+arrowleft', + scope: 'global', + customizable: true + }, { id: 'reopen-closed-tab', label: 'Reopen Closed Tab',