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))