diff --git a/packages/app/e2e/helpers/workspace-ui.ts b/packages/app/e2e/helpers/workspace-ui.ts new file mode 100644 index 000000000..6b939e977 --- /dev/null +++ b/packages/app/e2e/helpers/workspace-ui.ts @@ -0,0 +1,24 @@ +import { expect, type Page } from "@playwright/test"; + +export async function openNewAgentComposer(page: Page): Promise { + await page.goto("/"); + + const sidebarNewAgent = page.getByTestId("sidebar-new-agent").first(); + if (await sidebarNewAgent.isVisible().catch(() => false)) { + await sidebarNewAgent.click(); + } else { + await page.getByText("New agent", { exact: true }).first().click(); + } + + await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible({ + timeout: 30_000, + }); +} + +export async function seedWorkspaceActivity(page: Page, marker: string): Promise { + const input = page.getByRole("textbox", { name: "Message agent..." }); + await expect(input).toBeEditable({ timeout: 30_000 }); + await input.fill(marker); + await input.press("Enter"); + await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 }); +} diff --git a/packages/app/e2e/terminal-responsiveness-benchmark.spec.ts b/packages/app/e2e/terminal-responsiveness-benchmark.spec.ts new file mode 100644 index 000000000..65aa4f5f1 --- /dev/null +++ b/packages/app/e2e/terminal-responsiveness-benchmark.spec.ts @@ -0,0 +1,234 @@ +import { test, expect } from "./fixtures"; +import { setWorkingDirectory } from "./helpers/app"; +import { createTempGitRepo } from "./helpers/workspace"; +import { openNewAgentComposer, seedWorkspaceActivity } from "./helpers/workspace-ui"; +import { buildHostWorkspaceRoute } from "@/utils/host-routes"; + +function buildWorkspaceRoute(serverId: string, workspacePath: string): string { + return buildHostWorkspaceRoute(serverId, workspacePath); +} + +async function openWorkspace(page: Page, workspacePath: string): Promise { + const serverId = process.env.E2E_SERVER_ID; + if (!serverId) { + throw new Error("E2E_SERVER_ID is not set."); + } + + await page.goto(buildWorkspaceRoute(serverId, workspacePath)); + await expect(page).toHaveURL(new RegExp(`/h/${encodeURIComponent(serverId)}/workspace/`), { + timeout: 30_000, + }); + await expect(page.getByTestId("workspace-new-terminal-tab").first()).toBeVisible({ + timeout: 30_000, + }); +} + +function percentile(values: number[], p: number): number { + if (values.length === 0) { + return 0; + } + const sorted = [...values].sort((a, b) => a - b); + const rank = Math.ceil((p / 100) * sorted.length); + const index = Math.min(sorted.length - 1, Math.max(0, rank - 1)); + return sorted[index] ?? 0; +} + +function summarize(values: number[]) { + if (values.length === 0) { + return { + count: 0, + minMs: 0, + maxMs: 0, + avgMs: 0, + p95Ms: 0, + p99Ms: 0, + }; + } + + const sum = values.reduce((acc, value) => acc + value, 0); + return { + count: values.length, + minMs: Math.min(...values), + maxMs: Math.max(...values), + avgMs: Math.round((sum / values.length) * 100) / 100, + p95Ms: percentile(values, 95), + p99Ms: percentile(values, 99), + }; +} + +function buildStressCommand(doneMarker: string): string { + // Deterministic synthetic "TUI-like" redraw loop: alternate screen + cursor-home repaint. + return [ + "i=1", + "printf '\\033[?1049h\\033[2J'", + "while [ $i -le 240 ]; do", + "printf '\\033[H'", + "r=1", + "while [ $r -le 24 ]; do", + "printf 'bench frame:%03d row:%02d ########################################\\n' \"$i\" \"$r\"", + "r=$((r+1))", + "done", + "sleep 0.01", + "i=$((i+1))", + "done", + `printf '\\033[?1049l\\n${doneMarker}\\n'`, + ].join("; "); +} + +async function toggleExplorerAndMeasureLatency(page: Page): Promise { + const toggle = page.getByTestId("workspace-explorer-toggle").first(); + await expect(toggle).toBeVisible({ timeout: 30_000 }); + const currentlyExpanded = (await toggle.getAttribute("aria-expanded")) === "true"; + const expected = currentlyExpanded ? "false" : "true"; + + const start = Date.now(); + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-expanded", expected, { timeout: 15_000 }); + return Date.now() - start; +} + +test("workspace terminal responsiveness benchmark (report-only, single stress profile)", async ({ + page, +}, testInfo) => { + test.setTimeout(180_000); + const repo = await createTempGitRepo("paseo-e2e-terminal-benchmark-"); + + try { + await openNewAgentComposer(page); + await setWorkingDirectory(page, repo.path); + await seedWorkspaceActivity(page, `terminal benchmark seed ${Date.now()}`); + + await openWorkspace(page, repo.path); + + const newTerminalButton = page.getByTestId("workspace-new-terminal-tab").first(); + await expect(newTerminalButton).toBeVisible({ timeout: 30_000 }); + await newTerminalButton.click(); + + const surface = page.locator('[data-testid="terminal-surface"]:visible').first(); + await expect(surface).toBeVisible({ timeout: 60_000 }); + await surface.click({ force: true }); + + await page.evaluate(() => { + const monitor = { + samples: [] as number[], + active: true, + rafId: 0, + lastTs: performance.now(), + }; + + const tick = (ts: number) => { + if (!monitor.active) { + return; + } + monitor.samples.push(ts - monitor.lastTs); + monitor.lastTs = ts; + monitor.rafId = requestAnimationFrame(tick); + }; + + monitor.rafId = requestAnimationFrame(tick); + + (window as { __PASEO_E2E_RAF_MONITOR__?: { stop: () => { samples: number[] } } }).__PASEO_E2E_RAF_MONITOR__ = + { + stop: () => { + if (!monitor.active) { + return { samples: monitor.samples }; + } + monitor.active = false; + cancelAnimationFrame(monitor.rafId); + return { samples: monitor.samples }; + }, + }; + }); + + const doneMarker = `TERMINAL_BENCH_DONE_${Date.now()}`; + const postMarker = `TERMINAL_BENCH_POST_${Date.now()}`; + const stressCommand = buildStressCommand(doneMarker); + await page.keyboard.type(stressCommand, { delay: 0 }); + await page.keyboard.press("Enter"); + + const interactionLatenciesMs: number[] = []; + for (let attempt = 0; attempt < 18; attempt += 1) { + const latency = await toggleExplorerAndMeasureLatency(page); + interactionLatenciesMs.push(latency); + } + + const rafResult = await page.evaluate(() => { + const handle = ( + window as { __PASEO_E2E_RAF_MONITOR__?: { stop: () => { samples: number[] } } } + ).__PASEO_E2E_RAF_MONITOR__; + if (!handle || typeof handle.stop !== "function") { + return { samples: [] as number[] }; + } + return handle.stop(); + }); + + await surface.click({ force: true }); + await page.keyboard.type(`echo ${postMarker}`, { delay: 0 }); + await page.keyboard.press("Enter"); + await expect(page.getByText(postMarker).first()).toBeVisible({ + timeout: 120_000, + }); + + const diagnostics = await page.evaluate(async () => { + const debug = ( + window as { + __PASEO_PERF_DIAGNOSTICS_DEBUG__?: { + consumeReports?: () => Promise; + }; + } + ).__PASEO_PERF_DIAGNOSTICS_DEBUG__; + if (!debug || typeof debug.consumeReports !== "function") { + return { available: false, reports: [] as unknown[] }; + } + try { + const reports = await debug.consumeReports(); + return { available: true, reports: Array.isArray(reports) ? reports : [] }; + } catch (error) { + return { + available: true, + reports: [] as unknown[], + error: error instanceof Error ? error.message : String(error), + }; + } + }); + + const frameGapsMs = (rafResult.samples ?? []).filter( + (sample) => Number.isFinite(sample) && sample > 0 + ); + const report = { + mode: "report-only", + profile: "single-stress", + generatedAt: new Date().toISOString(), + workload: { + frames: 240, + rowsPerFrame: 24, + frameSleepMs: 10, + doneMarker, + postMarker, + doneMarkerObserved: + (await page.getByText(doneMarker).first().isVisible().catch(() => false)) || + (await page.getByText(doneMarker).count()) > 0, + }, + frameGapMs: { + ...summarize(frameGapsMs), + over100Ms: frameGapsMs.filter((gap) => gap > 100).length, + over250Ms: frameGapsMs.filter((gap) => gap > 250).length, + over500Ms: frameGapsMs.filter((gap) => gap > 500).length, + }, + explorerToggleLatencyMs: summarize(interactionLatenciesMs), + diagnostics: { + available: diagnostics.available, + reportCount: diagnostics.reports.length, + reports: diagnostics.reports, + error: "error" in diagnostics ? diagnostics.error : undefined, + }, + }; + + await testInfo.attach("terminal-responsiveness-report", { + body: JSON.stringify(report, null, 2), + contentType: "application/json", + }); + } finally { + await repo.cleanup(); + } +}); diff --git a/packages/app/e2e/workspace-sidebar-content-sync.spec.ts b/packages/app/e2e/workspace-sidebar-content-sync.spec.ts index ce554cfab..4a88cf3ac 100644 --- a/packages/app/e2e/workspace-sidebar-content-sync.spec.ts +++ b/packages/app/e2e/workspace-sidebar-content-sync.spec.ts @@ -2,6 +2,7 @@ import { execSync } from 'node:child_process'; import { test, expect, type Page } from './fixtures'; import { setWorkingDirectory } from './helpers/app'; import { createTempGitRepo } from './helpers/workspace'; +import { openNewAgentComposer, seedWorkspaceActivity } from './helpers/workspace-ui'; import { buildHostWorkspaceRoute } from '@/utils/host-routes'; function escapeRegex(value: string): string { @@ -33,27 +34,6 @@ function workspaceRowLocator(page: Page, serverId: string, workspacePath: string return page.locator(ids.join(',')).first(); } -async function openNewAgentComposer(page: Page): Promise { - await page.goto('/'); - - const sidebarNewAgent = page.getByTestId('sidebar-new-agent').first(); - if (await sidebarNewAgent.isVisible().catch(() => false)) { - await sidebarNewAgent.click(); - } else { - await page.getByText('New agent', { exact: true }).first().click(); - } - - await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeVisible({ timeout: 30000 }); -} - -async function seedWorkspaceActivity(page: Page, marker: string): Promise { - const input = page.getByRole('textbox', { name: 'Message agent...' }); - await expect(input).toBeEditable({ timeout: 30000 }); - await input.fill(marker); - await input.press('Enter'); - await expect(page).toHaveURL(/\/workspace\//, { timeout: 30000 }); -} - async function switchViaSidebar(input: { page: Page; serverId: string; diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index ce7b53350..0cff12155 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -866,6 +866,9 @@ export function SidebarWorkspaceList({ const setSidebarShortcutWorkspaceTargets = useKeyboardShortcutsStore( (state) => state.setSidebarShortcutWorkspaceTargets ) + const setVisibleWorkspaceTargets = useKeyboardShortcutsStore( + (state) => state.setVisibleWorkspaceTargets + ) const showShortcutBadges = altDown || (isTauri && cmdOrCtrlDown) const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder) @@ -978,13 +981,20 @@ export function SidebarWorkspaceList({ useEffect(() => { setSidebarShortcutWorkspaceTargets(shortcutModel.shortcutTargets) - }, [setSidebarShortcutWorkspaceTargets, shortcutModel.shortcutTargets]) + setVisibleWorkspaceTargets(shortcutModel.visibleTargets) + }, [ + setSidebarShortcutWorkspaceTargets, + setVisibleWorkspaceTargets, + shortcutModel.shortcutTargets, + shortcutModel.visibleTargets, + ]) useEffect(() => { return () => { setSidebarShortcutWorkspaceTargets([]) + setVisibleWorkspaceTargets([]) } - }, [setSidebarShortcutWorkspaceTargets]) + }, [setSidebarShortcutWorkspaceTargets, setVisibleWorkspaceTargets]) const toggleProjectCollapsed = useCallback((projectKey: string) => { setCollapsedProjectKeys((prev) => { diff --git a/packages/app/src/hooks/use-command-center.ts b/packages/app/src/hooks/use-command-center.ts index f1d6dc6af..f5eeecf2f 100644 --- a/packages/app/src/hooks/use-command-center.ts +++ b/packages/app/src/hooks/use-command-center.ts @@ -68,7 +68,7 @@ const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [ id: "new-agent", title: "New agent", icon: "plus", - shortcutKeys: ["mod", "alt", "N"], + shortcutKeys: ["mod", "shift", "O"], keywords: ["new", "new agent", "create", "start", "launch", "agent"], buildRoute: ({ newAgentRoute }) => newAgentRoute, }, diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index 846f56564..e478e4615 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -59,9 +59,9 @@ export function useKeyboardShortcuts({ return true; }; - const navigateToSidebarShortcut = (digit: number): boolean => { + const navigateToWorkspaceShortcut = (index: number): boolean => { const state = useKeyboardShortcutsStore.getState(); - const target = state.sidebarShortcutWorkspaceTargets[digit - 1] ?? null; + const target = state.sidebarShortcutWorkspaceTargets[index - 1] ?? null; if (!target) { return false; } @@ -73,6 +73,37 @@ export function useKeyboardShortcuts({ navigate(buildHostWorkspaceRoute(target.serverId, target.workspaceId) as any); return true; }; + const navigateRelativeWorkspace = (delta: 1 | -1): boolean => { + const state = useKeyboardShortcutsStore.getState(); + const targets = state.visibleWorkspaceTargets; + if (targets.length === 0) { + return false; + } + + const workspaceRoute = parseHostWorkspaceRouteFromPathname(pathname); + if (!workspaceRoute) { + const fallback = targets[delta > 0 ? 0 : targets.length - 1] ?? null; + if (!fallback) { + return false; + } + router.push(buildHostWorkspaceRoute(fallback.serverId, fallback.workspaceId) as any); + return true; + } + + const currentIndex = targets.findIndex( + (target) => + target.serverId === workspaceRoute.serverId && + target.workspaceId === workspaceRoute.workspaceId + ); + const fromIndex = currentIndex >= 0 ? currentIndex : delta > 0 ? -1 : 0; + const nextIndex = (fromIndex + delta + targets.length) % targets.length; + const target = targets[nextIndex] ?? null; + if (!target) { + return false; + } + router.replace(buildHostWorkspaceRoute(target.serverId, target.workspaceId) as any); + return true; + }; const navigateToNewAgent = (): boolean => { let targetServerId = parseServerIdFromPathname(pathname); @@ -103,6 +134,21 @@ export function useKeyboardShortcuts({ }); return true; }; + const requestWorkspaceTabAction = (input: + | { kind: "new" | "close-current" } + | { kind: "navigate-index"; index: number } + | { kind: "navigate-relative"; delta: 1 | -1 }): boolean => { + const route = parseHostWorkspaceRouteFromPathname(pathname); + if (!route) { + return false; + } + useKeyboardShortcutsStore.getState().requestWorkspaceTabAction({ + serverId: route.serverId, + workspaceId: route.workspaceId, + ...input, + }); + return true; + }; const handleAction = (input: { action: string; @@ -112,6 +158,36 @@ export function useKeyboardShortcuts({ switch (input.action) { case "agent.new": return navigateToNewAgent(); + case "workspace.tab.new": + return requestWorkspaceTabAction({ kind: "new" }); + case "workspace.tab.close.current": + return requestWorkspaceTabAction({ kind: "close-current" }); + case "workspace.tab.navigate.index": + if (!input.payload || typeof input.payload !== "object" || !("index" in input.payload)) { + return false; + } + return requestWorkspaceTabAction({ + kind: "navigate-index", + index: input.payload.index, + }); + case "workspace.tab.navigate.relative": + if (!input.payload || typeof input.payload !== "object" || !("delta" in input.payload)) { + return false; + } + return requestWorkspaceTabAction({ + kind: "navigate-relative", + delta: input.payload.delta, + }); + case "workspace.navigate.index": + if (!input.payload || typeof input.payload !== "object" || !("index" in input.payload)) { + return false; + } + return navigateToWorkspaceShortcut(input.payload.index); + case "workspace.navigate.relative": + if (!input.payload || typeof input.payload !== "object" || !("delta" in input.payload)) { + return false; + } + return navigateRelativeWorkspace(input.payload.delta); case "sidebar.toggle.left": toggleAgentList(); return true; @@ -130,11 +206,6 @@ export function useKeyboardShortcuts({ } toggleFileExplorer(); return true; - case "sidebar.navigate.shortcut": - if (!input.payload || typeof input.payload !== "object" || !("digit" in input.payload)) { - return false; - } - return navigateToSidebarShortcut(input.payload.digit); case "command-center.toggle": { const store = useKeyboardShortcutsStore.getState(); if (!store.commandCenterOpen) { diff --git a/packages/app/src/keyboard/actions.ts b/packages/app/src/keyboard/actions.ts index a626d5c60..62f64ef3d 100644 --- a/packages/app/src/keyboard/actions.ts +++ b/packages/app/src/keyboard/actions.ts @@ -14,14 +14,20 @@ export type MessageInputKeyboardActionKind = export type KeyboardActionId = | "agent.new" + | "workspace.tab.new" + | "workspace.tab.close.current" + | "workspace.tab.navigate.index" + | "workspace.tab.navigate.relative" + | "workspace.navigate.index" + | "workspace.navigate.relative" | "sidebar.toggle.left" | "sidebar.toggle.right" - | "sidebar.navigate.shortcut" | "command-center.toggle" | "shortcuts.dialog.toggle" | "message-input.action"; export type KeyboardShortcutPayload = - | { digit: number } + | { index: number } + | { delta: 1 | -1 } | { kind: MessageInputKeyboardActionKind } | null; diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts index f331f699c..18c987076 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.test.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts @@ -32,6 +32,34 @@ function shortcutContext( } describe("keyboard-shortcuts", () => { + it("matches Mod+Shift+O to create new agent", () => { + const match = resolveKeyboardShortcut({ + event: keyboardEvent({ + key: "O", + code: "KeyO", + metaKey: true, + shiftKey: true, + }), + context: shortcutContext({ isMac: true }), + }); + + expect(match?.action).toBe("agent.new"); + }); + + it("does not keep old Mod+Alt+N binding", () => { + const match = resolveKeyboardShortcut({ + event: keyboardEvent({ + key: "n", + code: "KeyN", + metaKey: true, + altKey: true, + }), + context: shortcutContext({ isMac: true }), + }); + + expect(match).toBeNull(); + }); + it("matches question-mark shortcut to toggle the shortcuts dialog", () => { const match = resolveKeyboardShortcut({ event: keyboardEvent({ @@ -58,6 +86,134 @@ describe("keyboard-shortcuts", () => { expect(match).toBeNull(); }); + it("matches workspace index jump on web via Alt+digit", () => { + const match = resolveKeyboardShortcut({ + event: keyboardEvent({ + key: "2", + code: "Digit2", + altKey: true, + }), + context: shortcutContext({ isTauri: false }), + }); + + expect(match?.action).toBe("workspace.navigate.index"); + expect(match?.payload).toEqual({ index: 2 }); + }); + + it("matches workspace index jump on tauri via Mod+digit", () => { + const match = resolveKeyboardShortcut({ + event: keyboardEvent({ + key: "2", + code: "Digit2", + metaKey: true, + }), + context: shortcutContext({ isMac: true, isTauri: true }), + }); + + expect(match?.action).toBe("workspace.navigate.index"); + expect(match?.payload).toEqual({ index: 2 }); + }); + + it("matches tab index jump on tauri via Alt+digit", () => { + const match = resolveKeyboardShortcut({ + event: keyboardEvent({ + key: "2", + code: "Digit2", + altKey: true, + }), + context: shortcutContext({ isTauri: true }), + }); + + expect(match?.action).toBe("workspace.tab.navigate.index"); + expect(match?.payload).toEqual({ index: 2 }); + }); + + it("matches tab index jump on web via Alt+Shift+digit", () => { + const match = resolveKeyboardShortcut({ + event: keyboardEvent({ + key: "@", + code: "Digit2", + altKey: true, + shiftKey: true, + }), + context: shortcutContext({ isTauri: false }), + }); + + expect(match?.action).toBe("workspace.tab.navigate.index"); + expect(match?.payload).toEqual({ index: 2 }); + }); + + it("matches workspace relative navigation on web via Alt+[", () => { + const match = resolveKeyboardShortcut({ + event: keyboardEvent({ + key: "[", + code: "BracketLeft", + altKey: true, + }), + context: shortcutContext({ isTauri: false }), + }); + + expect(match?.action).toBe("workspace.navigate.relative"); + expect(match?.payload).toEqual({ delta: -1 }); + }); + + it("matches workspace relative navigation on tauri via Mod+]", () => { + const match = resolveKeyboardShortcut({ + event: keyboardEvent({ + key: "]", + code: "BracketRight", + ctrlKey: true, + }), + context: shortcutContext({ isTauri: true }), + }); + + expect(match?.action).toBe("workspace.navigate.relative"); + expect(match?.payload).toEqual({ delta: 1 }); + }); + + it("matches tab relative navigation via Alt+Shift+]", () => { + const match = resolveKeyboardShortcut({ + event: keyboardEvent({ + key: "}", + code: "BracketRight", + altKey: true, + shiftKey: true, + }), + context: shortcutContext(), + }); + + expect(match?.action).toBe("workspace.tab.navigate.relative"); + expect(match?.payload).toEqual({ delta: 1 }); + }); + + it("matches Alt+Shift+T to open new tab", () => { + const match = resolveKeyboardShortcut({ + event: keyboardEvent({ + key: "T", + code: "KeyT", + altKey: true, + shiftKey: true, + }), + context: shortcutContext(), + }); + + expect(match?.action).toBe("workspace.tab.new"); + }); + + it("matches Alt+Shift+W to close current tab", () => { + const match = resolveKeyboardShortcut({ + event: keyboardEvent({ + key: "W", + code: "KeyW", + altKey: true, + shiftKey: true, + }), + context: shortcutContext(), + }); + + expect(match?.action).toBe("workspace.tab.close.current"); + }); + it("matches Cmd+B sidebar toggle on macOS", () => { const match = resolveKeyboardShortcut({ event: keyboardEvent({ @@ -163,20 +319,6 @@ describe("keyboard-shortcuts", () => { expect(match?.preventDefault).toBe(false); expect(match?.stopPropagation).toBe(false); }); - - it("parses Alt+digit sidebar shortcut payload", () => { - const match = resolveKeyboardShortcut({ - event: keyboardEvent({ - key: "2", - code: "Digit2", - altKey: true, - }), - context: shortcutContext(), - }); - - expect(match?.action).toBe("sidebar.navigate.shortcut"); - expect(match?.payload).toEqual({ digit: 2 }); - }); }); describe("keyboard-shortcut help sections", () => { @@ -193,30 +335,30 @@ describe("keyboard-shortcut help sections", () => { return null; } - it("uses non-tauri defaults for new-agent and quick-open", () => { + it("uses web defaults for workspace and tab jump", () => { const sections = buildKeyboardShortcutHelpSections({ isMac: true, isTauri: false, }); - expect(findRow(sections, "new-agent")?.keys).toEqual(["mod", "alt", "N"]); - expect(findRow(sections, "quick-open-workspace")?.keys).toEqual([ + expect(findRow(sections, "new-agent")?.keys).toEqual(["mod", "shift", "O"]); + expect(findRow(sections, "workspace-jump-index")?.keys).toEqual(["alt", "1-9"]); + expect(findRow(sections, "workspace-tab-jump-index")?.keys).toEqual([ "alt", + "shift", "1-9", ]); }); - it("switches to tauri bindings in help rows", () => { + it("uses tauri defaults for workspace and tab jump", () => { const sections = buildKeyboardShortcutHelpSections({ isMac: true, isTauri: true, }); - expect(findRow(sections, "new-agent")?.keys).toEqual(["mod", "N"]); - expect(findRow(sections, "quick-open-workspace")?.keys).toEqual([ - "mod", - "1-9", - ]); + expect(findRow(sections, "new-agent")?.keys).toEqual(["mod", "shift", "O"]); + expect(findRow(sections, "workspace-jump-index")?.keys).toEqual(["mod", "1-9"]); + expect(findRow(sections, "workspace-tab-jump-index")?.keys).toEqual(["alt", "1-9"]); }); it("uses mod+period as non-mac left sidebar shortcut", () => { diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index ecc1042f8..72c8c6826 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -109,41 +109,253 @@ function withMessageInputAction( return () => ({ kind }); } +function withIndexPayload(event: KeyboardEvent): KeyboardShortcutPayload { + const index = parseDigit(event); + return index ? { index } : null; +} + +function withRelativeDelta( + delta: 1 | -1 +): (event: KeyboardEvent) => KeyboardShortcutPayload { + return () => ({ delta }); +} + const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [ { - id: "agent-new-mod-alt-n", + id: "agent-new-mod-shift-o", action: "agent.new", matches: (event) => isMod(event) && - event.altKey && - !event.shiftKey && - (event.code === "KeyN" || event.key.toLowerCase() === "n"), + !event.altKey && + event.shiftKey && + (event.code === "KeyO" || event.key.toLowerCase() === "o"), when: () => true, help: { id: "new-agent", section: "global", label: "Create new agent", - keys: ["mod", "alt", "N"], - when: (context) => !context.isTauri, + keys: ["mod", "shift", "O"], }, }, { - id: "agent-new-tauri-mod-n", - action: "agent.new", + id: "workspace-tab-new-alt-shift-t", + action: "workspace.tab.new", + matches: (event) => + !event.metaKey && + !event.ctrlKey && + event.altKey && + event.shiftKey && + (event.code === "KeyT" || event.key.toLowerCase() === "t"), + when: (context) => !context.commandCenterOpen, + help: { + id: "workspace-tab-new", + section: "global", + label: "New agent tab", + keys: ["alt", "shift", "T"], + }, + }, + { + id: "workspace-tab-close-current-alt-shift-w", + action: "workspace.tab.close.current", + matches: (event) => + !event.metaKey && + !event.ctrlKey && + event.altKey && + event.shiftKey && + (event.code === "KeyW" || event.key.toLowerCase() === "w"), + when: (context) => !context.commandCenterOpen, + help: { + id: "workspace-tab-close-current", + section: "global", + label: "Close current tab", + keys: ["alt", "shift", "W"], + }, + }, + { + id: "workspace-navigate-index-mod-digit-tauri", + action: "workspace.navigate.index", matches: (event) => isMod(event) && !event.altKey && !event.shiftKey && - (event.code === "KeyN" || event.key.toLowerCase() === "n"), - when: (context) => context.isTauri, + hasDigit(event), + payload: withIndexPayload, + when: (context) => context.isTauri && !context.commandCenterOpen, help: { - id: "new-agent", + id: "workspace-jump-index", section: "global", - label: "Create new agent", - keys: ["mod", "N"], + label: "Jump to workspace", + keys: ["mod", "1-9"], when: (context) => context.isTauri, }, }, + { + id: "workspace-navigate-index-alt-digit-web", + action: "workspace.navigate.index", + matches: (event) => + !event.metaKey && + !event.ctrlKey && + event.altKey && + !event.shiftKey && + hasDigit(event), + payload: withIndexPayload, + when: (context) => !context.isTauri && !context.commandCenterOpen, + help: { + id: "workspace-jump-index", + section: "global", + label: "Jump to workspace", + keys: ["alt", "1-9"], + when: (context) => !context.isTauri, + }, + }, + { + id: "workspace-tab-navigate-index-alt-digit-tauri", + action: "workspace.tab.navigate.index", + matches: (event) => + !event.metaKey && + !event.ctrlKey && + event.altKey && + !event.shiftKey && + hasDigit(event), + payload: withIndexPayload, + when: (context) => context.isTauri && !context.commandCenterOpen, + help: { + id: "workspace-tab-jump-index", + section: "global", + label: "Jump to tab", + keys: ["alt", "1-9"], + when: (context) => context.isTauri, + }, + }, + { + id: "workspace-tab-navigate-index-alt-shift-digit-web", + action: "workspace.tab.navigate.index", + matches: (event) => + !event.metaKey && + !event.ctrlKey && + event.altKey && + event.shiftKey && + hasDigit(event), + payload: withIndexPayload, + when: (context) => !context.isTauri && !context.commandCenterOpen, + help: { + id: "workspace-tab-jump-index", + section: "global", + label: "Jump to tab", + keys: ["alt", "shift", "1-9"], + when: (context) => !context.isTauri, + }, + }, + { + id: "workspace-navigate-relative-mod-left", + action: "workspace.navigate.relative", + matches: (event) => + isMod(event) && + !event.altKey && + !event.shiftKey && + (event.code === "BracketLeft" || event.key === "["), + payload: withRelativeDelta(-1), + when: (context) => context.isTauri && !context.commandCenterOpen, + help: { + id: "workspace-prev", + section: "global", + label: "Previous workspace", + keys: ["mod", "["], + when: (context) => context.isTauri, + }, + }, + { + id: "workspace-navigate-relative-mod-right", + action: "workspace.navigate.relative", + matches: (event) => + isMod(event) && + !event.altKey && + !event.shiftKey && + (event.code === "BracketRight" || event.key === "]"), + payload: withRelativeDelta(1), + when: (context) => context.isTauri && !context.commandCenterOpen, + help: { + id: "workspace-next", + section: "global", + label: "Next workspace", + keys: ["mod", "]"], + when: (context) => context.isTauri, + }, + }, + { + id: "workspace-navigate-relative-alt-left", + action: "workspace.navigate.relative", + matches: (event) => + !event.metaKey && + !event.ctrlKey && + event.altKey && + !event.shiftKey && + (event.code === "BracketLeft" || event.key === "["), + payload: withRelativeDelta(-1), + when: (context) => !context.isTauri && !context.commandCenterOpen, + help: { + id: "workspace-prev", + section: "global", + label: "Previous workspace", + keys: ["alt", "["], + when: (context) => !context.isTauri, + }, + }, + { + id: "workspace-navigate-relative-alt-right", + action: "workspace.navigate.relative", + matches: (event) => + !event.metaKey && + !event.ctrlKey && + event.altKey && + !event.shiftKey && + (event.code === "BracketRight" || event.key === "]"), + payload: withRelativeDelta(1), + when: (context) => !context.isTauri && !context.commandCenterOpen, + help: { + id: "workspace-next", + section: "global", + label: "Next workspace", + keys: ["alt", "]"], + when: (context) => !context.isTauri, + }, + }, + { + id: "workspace-tab-navigate-relative-alt-shift-left", + action: "workspace.tab.navigate.relative", + matches: (event) => + !event.metaKey && + !event.ctrlKey && + event.altKey && + event.shiftKey && + event.code === "BracketLeft", + payload: withRelativeDelta(-1), + when: (context) => !context.commandCenterOpen, + help: { + id: "workspace-tab-prev", + section: "global", + label: "Previous tab", + keys: ["alt", "shift", "["], + }, + }, + { + id: "workspace-tab-navigate-relative-alt-shift-right", + action: "workspace.tab.navigate.relative", + matches: (event) => + !event.metaKey && + !event.ctrlKey && + event.altKey && + event.shiftKey && + event.code === "BracketRight", + payload: withRelativeDelta(1), + when: (context) => !context.commandCenterOpen, + help: { + id: "workspace-tab-next", + section: "global", + label: "Next tab", + keys: ["alt", "shift", "]"], + }, + }, { id: "command-center-toggle", action: "command-center.toggle", @@ -308,40 +520,6 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [ keys: ["Space"], }, }, - { - id: "sidebar-shortcut-alt-digit", - action: "sidebar.navigate.shortcut", - matches: (event) => event.altKey && hasDigit(event), - payload: (event) => { - const digit = parseDigit(event); - return digit ? { digit } : null; - }, - when: (context) => !context.commandCenterOpen, - help: { - id: "quick-open-workspace", - section: "global", - label: "Open sidebar workspace shortcut", - keys: ["alt", "1-9"], - when: (context) => !context.isTauri, - }, - }, - { - id: "sidebar-shortcut-tauri-mod-digit", - action: "sidebar.navigate.shortcut", - matches: (event) => isMod(event) && hasDigit(event), - payload: (event) => { - const digit = parseDigit(event); - return digit ? { digit } : null; - }, - when: (context) => context.isTauri && !context.commandCenterOpen, - help: { - id: "quick-open-workspace", - section: "global", - label: "Open sidebar workspace shortcut", - keys: ["mod", "1-9"], - when: (context) => context.isTauri, - }, - }, ]; export function resolveKeyboardShortcut(input: { diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 95acd017c..9ac032610 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -92,7 +92,7 @@ export function WorkspaceDesktopTabsRow({ actionsReservedWidth: Math.max(0, tabsActionsWidth), rowPaddingHorizontal: theme.spacing[2], tabGap: theme.spacing[1], - maxTabWidth: 150, + maxTabWidth: 200, tabIconWidth: 14, tabHorizontalPadding: theme.spacing[3], estimatedCharWidth: 7, diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index fa2cee6ab..5cf7fea6c 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -48,6 +48,7 @@ import { buildWorkspaceTabPersistenceKey, useWorkspaceTabsStore, } from "@/stores/workspace-tabs-store"; +import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { buildWorkspaceOpenIntentParam, type WorkspaceOpenIntent, @@ -408,6 +409,12 @@ function WorkspaceScreenContent({ const closeWorkspaceTab = useWorkspaceTabsStore((state) => state.closeTab); const promoteDraftToAgent = useWorkspaceTabsStore((state) => state.promoteDraftToAgent); const reorderWorkspaceTabs = useWorkspaceTabsStore((state) => state.reorderTabs); + const workspaceTabActionRequest = useKeyboardShortcutsStore( + (state) => state.workspaceTabActionRequest + ); + const clearWorkspaceTabActionRequest = useKeyboardShortcutsStore( + (state) => state.clearWorkspaceTabActionRequest + ); const consumedOpenIntentsRef = useRef(new Set()); useEffect(() => { @@ -943,6 +950,62 @@ function WorkspaceScreenContent({ ] ); + useEffect(() => { + if (!workspaceTabActionRequest) { + return; + } + if ( + workspaceTabActionRequest.serverId !== normalizedServerId || + workspaceTabActionRequest.workspaceId !== normalizedWorkspaceId + ) { + return; + } + + if (workspaceTabActionRequest.kind === "new") { + handleCreateDraftTab(); + clearWorkspaceTabActionRequest(workspaceTabActionRequest.id); + return; + } + if (workspaceTabActionRequest.kind === "close-current") { + if (activeTabId) { + void handleCloseTabById(activeTabId); + } + clearWorkspaceTabActionRequest(workspaceTabActionRequest.id); + return; + } + if (workspaceTabActionRequest.kind === "navigate-index") { + const next = tabs[workspaceTabActionRequest.index - 1] ?? null; + if (next?.tabId) { + navigateToTabId(next.tabId); + } + clearWorkspaceTabActionRequest(workspaceTabActionRequest.id); + return; + } + if (workspaceTabActionRequest.kind === "navigate-relative") { + if (tabs.length > 0) { + const currentIndex = tabs.findIndex((tab) => tab.tabId === activeTabId); + const fromIndex = currentIndex >= 0 ? currentIndex : 0; + const nextIndex = + (fromIndex + workspaceTabActionRequest.delta + tabs.length) % tabs.length; + const next = tabs[nextIndex] ?? null; + if (next?.tabId) { + navigateToTabId(next.tabId); + } + } + clearWorkspaceTabActionRequest(workspaceTabActionRequest.id); + } + }, [ + activeTabId, + clearWorkspaceTabActionRequest, + handleCloseTabById, + handleCreateDraftTab, + navigateToTabId, + normalizedServerId, + normalizedWorkspaceId, + tabs, + workspaceTabActionRequest, + ]); + const renderContent = () => { if ( shouldRenderMissingWorkspaceDescriptor({ diff --git a/packages/app/src/screens/workspace/workspace-tab-layout.test.ts b/packages/app/src/screens/workspace/workspace-tab-layout.test.ts index 198895024..1c9c0f3d9 100644 --- a/packages/app/src/screens/workspace/workspace-tab-layout.test.ts +++ b/packages/app/src/screens/workspace/workspace-tab-layout.test.ts @@ -6,7 +6,7 @@ const metrics = { actionsReservedWidth: 120, rowPaddingHorizontal: 8, tabGap: 4, - maxTabWidth: 150, + maxTabWidth: 200, tabIconWidth: 14, tabHorizontalPadding: 12, estimatedCharWidth: 7, @@ -14,7 +14,7 @@ const metrics = { }; describe("computeWorkspaceTabLayout", () => { - it("caps tab width to the fixed maximum when enough space is available", () => { + it("keeps fit-content tab widths when there is extra horizontal space", () => { const result = computeWorkspaceTabLayout({ viewportWidth: 1200, tabLabelLengths: [8, 10, 7], @@ -25,7 +25,7 @@ describe("computeWorkspaceTabLayout", () => { expect(result.requiresHorizontalScrollFallback).toBe(false); expect(result.items).toHaveLength(3); expect(result.items.every((item) => item.showLabel)).toBe(true); - expect(Math.max(...result.items.map((item) => item.width))).toBeLessThanOrEqual(150); + expect(result.items.map((item) => item.width)).toEqual([126, 140, 119]); }); it("shrinks tab widths proportionally before hiding labels", () => { @@ -37,7 +37,7 @@ describe("computeWorkspaceTabLayout", () => { expect(result.closeButtonPolicy).toBe("all"); expect(result.requiresHorizontalScrollFallback).toBe(false); - expect(result.items.map((item) => item.width)).toEqual([137, 132, 108]); + expect(result.items.map((item) => item.width)).toEqual([151, 121, 103]); expect(result.items.every((item) => item.showLabel)).toBe(true); }); diff --git a/packages/app/src/screens/workspace/workspace-tab-layout.ts b/packages/app/src/screens/workspace/workspace-tab-layout.ts index 150387316..28c99dc06 100644 --- a/packages/app/src/screens/workspace/workspace-tab-layout.ts +++ b/packages/app/src/screens/workspace/workspace-tab-layout.ts @@ -67,10 +67,11 @@ export function computeWorkspaceTabLayout( const availableTabsWidth = Math.max(0, availableWidth - rowOverhead); const iconOnlyTabWidth = input.metrics.tabIconWidth + input.metrics.tabHorizontalPadding * 2 + input.metrics.closeButtonWidth; + const labelSafetyPadding = 10; const estimateLabelWidth = (labelLength: number) => labelLength * input.metrics.estimatedCharWidth; const desiredTabWidths = input.tabLabelLengths.map((rawLength) => { const labelLength = Math.max(rawLength, 1); - const estimatedWidth = iconOnlyTabWidth + estimateLabelWidth(labelLength); + const estimatedWidth = iconOnlyTabWidth + estimateLabelWidth(labelLength) + labelSafetyPadding; return clamp(estimatedWidth, iconOnlyTabWidth, input.metrics.maxTabWidth); }); @@ -80,6 +81,7 @@ export function computeWorkspaceTabLayout( let resolvedWidths: number[]; if (desiredTotalTabsWidth <= availableTabsWidth) { + // Fit-content baseline: do not stretch tabs when there is spare space. resolvedWidths = desiredTabWidths; } else if (availableTabsWidth <= iconOnlyTotalTabsWidth) { resolvedWidths = new Array(tabCount).fill(iconOnlyTabWidth); diff --git a/packages/app/src/stores/keyboard-shortcuts-store.test.ts b/packages/app/src/stores/keyboard-shortcuts-store.test.ts new file mode 100644 index 000000000..9f99276e2 --- /dev/null +++ b/packages/app/src/stores/keyboard-shortcuts-store.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { useKeyboardShortcutsStore } from "./keyboard-shortcuts-store"; + +beforeEach(() => { + useKeyboardShortcutsStore.setState({ + commandCenterOpen: false, + shortcutsDialogOpen: false, + altDown: false, + cmdOrCtrlDown: false, + sidebarShortcutWorkspaceTargets: [], + visibleWorkspaceTargets: [], + messageInputActionRequest: null, + nextMessageInputActionRequestId: 1, + workspaceTabActionRequest: null, + nextWorkspaceTabActionRequestId: 1, + }); +}); + +describe("keyboard-shortcuts-store", () => { + it("queues and clears workspace tab action requests", () => { + useKeyboardShortcutsStore.getState().requestWorkspaceTabAction({ + serverId: "srv-1", + workspaceId: "/repo/main", + kind: "navigate-index", + index: 3, + }); + + const request = useKeyboardShortcutsStore.getState().workspaceTabActionRequest; + expect(request).toEqual({ + id: 1, + serverId: "srv-1", + workspaceId: "/repo/main", + kind: "navigate-index", + index: 3, + }); + + useKeyboardShortcutsStore.getState().clearWorkspaceTabActionRequest(999); + expect(useKeyboardShortcutsStore.getState().workspaceTabActionRequest).not.toBeNull(); + + useKeyboardShortcutsStore.getState().clearWorkspaceTabActionRequest(1); + expect(useKeyboardShortcutsStore.getState().workspaceTabActionRequest).toBeNull(); + }); +}); diff --git a/packages/app/src/stores/keyboard-shortcuts-store.ts b/packages/app/src/stores/keyboard-shortcuts-store.ts index 79341e7d9..344ee3805 100644 --- a/packages/app/src/stores/keyboard-shortcuts-store.ts +++ b/packages/app/src/stores/keyboard-shortcuts-store.ts @@ -8,6 +8,28 @@ export type MessageInputActionRequest = { kind: MessageInputKeyboardActionKind; }; +export type WorkspaceTabActionRequest = + | { + id: number; + serverId: string; + workspaceId: string; + kind: "new" | "close-current"; + } + | { + id: number; + serverId: string; + workspaceId: string; + kind: "navigate-index"; + index: number; + } + | { + id: number; + serverId: string; + workspaceId: string; + kind: "navigate-relative"; + delta: 1 | -1; + }; + interface KeyboardShortcutsState { commandCenterOpen: boolean; shortcutsDialogOpen: boolean; @@ -15,14 +37,19 @@ interface KeyboardShortcutsState { cmdOrCtrlDown: boolean; /** Sidebar-visible workspace targets (up to 9), in top-to-bottom visual order. */ sidebarShortcutWorkspaceTargets: SidebarShortcutWorkspaceTarget[]; + /** All visible workspace targets in top-to-bottom visual order. */ + visibleWorkspaceTargets: SidebarShortcutWorkspaceTarget[]; messageInputActionRequest: MessageInputActionRequest | null; nextMessageInputActionRequestId: number; + workspaceTabActionRequest: WorkspaceTabActionRequest | null; + nextWorkspaceTabActionRequestId: number; setCommandCenterOpen: (open: boolean) => void; setShortcutsDialogOpen: (open: boolean) => void; setAltDown: (down: boolean) => void; setCmdOrCtrlDown: (down: boolean) => void; setSidebarShortcutWorkspaceTargets: (targets: SidebarShortcutWorkspaceTarget[]) => void; + setVisibleWorkspaceTargets: (targets: SidebarShortcutWorkspaceTarget[]) => void; resetModifiers: () => void; requestMessageInputAction: (input: { @@ -30,6 +57,26 @@ interface KeyboardShortcutsState { kind: MessageInputKeyboardActionKind; }) => void; clearMessageInputActionRequest: (id: number) => void; + + requestWorkspaceTabAction: (input: + | { + serverId: string; + workspaceId: string; + kind: "new" | "close-current"; + } + | { + serverId: string; + workspaceId: string; + kind: "navigate-index"; + index: number; + } + | { + serverId: string; + workspaceId: string; + kind: "navigate-relative"; + delta: 1 | -1; + }) => void; + clearWorkspaceTabActionRequest: (id: number) => void; } export const useKeyboardShortcutsStore = create( @@ -39,8 +86,11 @@ export const useKeyboardShortcutsStore = create( altDown: false, cmdOrCtrlDown: false, sidebarShortcutWorkspaceTargets: [], + visibleWorkspaceTargets: [], messageInputActionRequest: null, nextMessageInputActionRequestId: 1, + workspaceTabActionRequest: null, + nextWorkspaceTabActionRequestId: 1, setCommandCenterOpen: (open) => set({ commandCenterOpen: open }), setShortcutsDialogOpen: (open) => set({ shortcutsDialogOpen: open }), @@ -48,6 +98,7 @@ export const useKeyboardShortcutsStore = create( setCmdOrCtrlDown: (down) => set({ cmdOrCtrlDown: down }), setSidebarShortcutWorkspaceTargets: (targets) => set({ sidebarShortcutWorkspaceTargets: targets }), + setVisibleWorkspaceTargets: (targets) => set({ visibleWorkspaceTargets: targets }), resetModifiers: () => set({ altDown: false, cmdOrCtrlDown: false }), requestMessageInputAction: ({ agentKey, kind }) => { @@ -64,5 +115,23 @@ export const useKeyboardShortcutsStore = create( } set({ messageInputActionRequest: null }); }, + + requestWorkspaceTabAction: (input) => { + const id = get().nextWorkspaceTabActionRequestId; + set({ + workspaceTabActionRequest: { + ...input, + id, + } as WorkspaceTabActionRequest, + nextWorkspaceTabActionRequestId: id + 1, + }); + }, + clearWorkspaceTabActionRequest: (id) => { + const current = get().workspaceTabActionRequest; + if (!current || current.id !== id) { + return; + } + set({ workspaceTabActionRequest: null }); + }, }) ); diff --git a/packages/app/src/utils/sidebar-shortcuts.test.ts b/packages/app/src/utils/sidebar-shortcuts.test.ts index 19209a5e6..b143db874 100644 --- a/packages/app/src/utils/sidebar-shortcuts.test.ts +++ b/packages/app/src/utils/sidebar-shortcuts.test.ts @@ -39,6 +39,10 @@ describe("buildSidebarShortcutModel", () => { collapsedProjectKeys: new Set(["p2"]), }); + expect(model.visibleTargets).toEqual([ + { serverId: "s1", workspaceId: "/repo/main" }, + { serverId: "s1", workspaceId: "/repo/feat-a" }, + ]); expect(model.shortcutTargets).toEqual([ { serverId: "s1", workspaceId: "/repo/main" }, { serverId: "s1", workspaceId: "/repo/feat-a" }, @@ -57,6 +61,8 @@ describe("buildSidebarShortcutModel", () => { collapsedProjectKeys: new Set(), }); + expect(model.visibleTargets).toHaveLength(20); + expect(model.visibleTargets[19]).toEqual({ serverId: "s", workspaceId: "/repo/w20" }); expect(model.shortcutTargets).toHaveLength(9); expect(model.shortcutTargets[0]).toEqual({ serverId: "s", workspaceId: "/repo/w1" }); expect(model.shortcutTargets[8]).toEqual({ serverId: "s", workspaceId: "/repo/w9" }); diff --git a/packages/app/src/utils/sidebar-shortcuts.ts b/packages/app/src/utils/sidebar-shortcuts.ts index 53c72f796..5795673bf 100644 --- a/packages/app/src/utils/sidebar-shortcuts.ts +++ b/packages/app/src/utils/sidebar-shortcuts.ts @@ -9,6 +9,7 @@ export interface SidebarShortcutWorkspaceTarget { } export interface SidebarShortcutModel { + visibleTargets: SidebarShortcutWorkspaceTarget[] shortcutTargets: SidebarShortcutWorkspaceTarget[] shortcutIndexByWorkspaceKey: Map } @@ -19,6 +20,7 @@ export function buildSidebarShortcutModel(input: { shortcutLimit?: number }): SidebarShortcutModel { const maxShortcuts = Math.max(0, Math.floor(input.shortcutLimit ?? 9)) + const visibleTargets: SidebarShortcutWorkspaceTarget[] = [] const shortcutTargets: SidebarShortcutWorkspaceTarget[] = [] const shortcutIndexByWorkspaceKey = new Map() @@ -28,6 +30,10 @@ export function buildSidebarShortcutModel(input: { } for (const workspace of project.workspaces) { + visibleTargets.push({ + serverId: workspace.serverId, + workspaceId: workspace.workspaceId, + }) const shortcutNumber = shortcutTargets.length < maxShortcuts ? shortcutTargets.length + 1 : null if (shortcutNumber !== null) { @@ -40,5 +46,5 @@ export function buildSidebarShortcutModel(input: { } } - return { shortcutTargets, shortcutIndexByWorkspaceKey } + return { visibleTargets, shortcutTargets, shortcutIndexByWorkspaceKey } }