Skip to content
83 changes: 83 additions & 0 deletions packages/apps/app/e2e/core/16-tab-management.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <input>s remain visible per the
* layout; only the store knows which one is actually active. */
async function getActiveTabTitle(page: import('@playwright/test').Page): Promise<string | null> {
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

Expand Down Expand Up @@ -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 <input>) — 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')
})
})
51 changes: 50 additions & 1 deletion packages/apps/app/src/renderer/src/app-shell/useAppShortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? ''
},
Expand Down Expand Up @@ -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(
Comment thread
greptile-apps[bot] marked this conversation as resolved.
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) => {
Expand Down
16 changes: 16 additions & 0 deletions packages/shared/shortcuts/src/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading