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 1663de50..ef681c79 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/package.json b/packages/apps/app/package.json
index d4975de1..9174b678 100644
--- a/packages/apps/app/package.json
+++ b/packages/apps/app/package.json
@@ -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"
},
@@ -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",
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 bb75000e..8b392173 100644
--- a/packages/apps/app/src/renderer/src/app-shell/useAppShortcuts.ts
+++ b/packages/apps/app/src/renderer/src/app-shell/useAppShortcuts.ts
@@ -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(
+ 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 51d2ef0e..1c3b5cd7 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',
diff --git a/packages/shared/transport/src/server/http/rest-api/sessions/resolve-task.ts b/packages/shared/transport/src/server/http/rest-api/sessions/resolve-task.ts
new file mode 100644
index 00000000..112241d7
--- /dev/null
+++ b/packages/shared/transport/src/server/http/rest-api/sessions/resolve-task.ts
@@ -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' })
+ })
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 02b26709..f6565d6c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -226,6 +226,9 @@ importers:
'@tanstack/react-query':
specifier: ^5.100.9
version: 5.100.10(react@19.2.3)
+ '@trpc/server':
+ specifier: ^11.17.0
+ version: 11.17.0(typescript@5.9.3)
'@xyflow/react':
specifier: ^12.10.2
version: 12.10.2(@types/react@19.2.8)(immer@11.1.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -286,6 +289,9 @@ importers:
tw-animate-css:
specifier: ^1.4.0
version: 1.4.0
+ ws:
+ specifier: ^8.18.0
+ version: 8.18.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
zod:
specifier: ^4
version: 4.3.5
@@ -332,6 +338,9 @@ importers:
'@types/react-dom':
specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.8)
+ '@types/ws':
+ specifier: ^8.5.13
+ version: 8.18.1
'@vitejs/plugin-react':
specifier: ^5.1.1
version: 5.1.2(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))