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',