Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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')
})
})
3 changes: 3 additions & 0 deletions packages/apps/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0",
"ws": "^8.18.0",
"@trpc/server": "^11.17.0",
"zod": "^4.3.5",
"zustand": "^5.0.0"
},
Expand All @@ -112,6 +114,7 @@
"@types/node": "^22.19.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/ws": "^8.5.13",
"@vitejs/plugin-react": "^5.1.1",
"babel-plugin-react-compiler": "^1.0.0",
"electron": "41.0.3",
Expand Down
46 changes: 46 additions & 0 deletions packages/apps/app/src/renderer/src/app-shell/useAppShortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Disabled shortcuts still fire

next-task-tab and prev-task-tab are registered as customizable shortcuts, but this call reads them through the local getKeys() helper that treats a null override as missing and falls back to the default key. When a user disables Cmd+Option+Right or Cmd+Option+Left in shortcut settings, the handler is still bound to the default and can still switch task tabs while the user expected it to be unbound. Please make this path preserve explicit null overrides instead of falling back to the default.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/apps/app/src/renderer/src/app-shell/useAppShortcuts.ts
Line: 213

Comment:
**Disabled shortcuts still fire**

`next-task-tab` and `prev-task-tab` are registered as customizable shortcuts, but this call reads them through the local `getKeys()` helper that treats a `null` override as missing and falls back to the default key. When a user disables Cmd+Option+Right or Cmd+Option+Left in shortcut settings, the handler is still bound to the default and can still switch task tabs while the user expected it to be unbound. Please make this path preserve explicit `null` overrides instead of falling back to the default.

How can I resolve this? If you propose a fix, please make it concise.

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
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { Express } from 'express'
import type { RestApiDeps } from '../types'

// Stub: session → bound task resolution for the slay CLI. Returns 501 until wired.
export function registerResolveSessionTaskRoute(app: Express, _deps: RestApiDeps): void {
app.get('/api/sessions/:id/task', (_req, res) => {
res.status(501).json({ error: 'not implemented' })
})
}
9 changes: 9 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading