feat: add workspace and tab navigation keyboard shortcuts
This commit is contained in:
@@ -0,0 +1,24 @@
|
|||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
export async function openNewAgentComposer(page: Page): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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<number> {
|
||||||
|
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<unknown[]>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).__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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { execSync } from 'node:child_process';
|
|||||||
import { test, expect, type Page } from './fixtures';
|
import { test, expect, type Page } from './fixtures';
|
||||||
import { setWorkingDirectory } from './helpers/app';
|
import { setWorkingDirectory } from './helpers/app';
|
||||||
import { createTempGitRepo } from './helpers/workspace';
|
import { createTempGitRepo } from './helpers/workspace';
|
||||||
|
import { openNewAgentComposer, seedWorkspaceActivity } from './helpers/workspace-ui';
|
||||||
import { buildHostWorkspaceRoute } from '@/utils/host-routes';
|
import { buildHostWorkspaceRoute } from '@/utils/host-routes';
|
||||||
|
|
||||||
function escapeRegex(value: string): string {
|
function escapeRegex(value: string): string {
|
||||||
@@ -33,27 +34,6 @@ function workspaceRowLocator(page: Page, serverId: string, workspacePath: string
|
|||||||
return page.locator(ids.join(',')).first();
|
return page.locator(ids.join(',')).first();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openNewAgentComposer(page: Page): Promise<void> {
|
|
||||||
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<void> {
|
|
||||||
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: {
|
async function switchViaSidebar(input: {
|
||||||
page: Page;
|
page: Page;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
|||||||
@@ -866,6 +866,9 @@ export function SidebarWorkspaceList({
|
|||||||
const setSidebarShortcutWorkspaceTargets = useKeyboardShortcutsStore(
|
const setSidebarShortcutWorkspaceTargets = useKeyboardShortcutsStore(
|
||||||
(state) => state.setSidebarShortcutWorkspaceTargets
|
(state) => state.setSidebarShortcutWorkspaceTargets
|
||||||
)
|
)
|
||||||
|
const setVisibleWorkspaceTargets = useKeyboardShortcutsStore(
|
||||||
|
(state) => state.setVisibleWorkspaceTargets
|
||||||
|
)
|
||||||
const showShortcutBadges = altDown || (isTauri && cmdOrCtrlDown)
|
const showShortcutBadges = altDown || (isTauri && cmdOrCtrlDown)
|
||||||
|
|
||||||
const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder)
|
const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder)
|
||||||
@@ -978,13 +981,20 @@ export function SidebarWorkspaceList({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSidebarShortcutWorkspaceTargets(shortcutModel.shortcutTargets)
|
setSidebarShortcutWorkspaceTargets(shortcutModel.shortcutTargets)
|
||||||
}, [setSidebarShortcutWorkspaceTargets, shortcutModel.shortcutTargets])
|
setVisibleWorkspaceTargets(shortcutModel.visibleTargets)
|
||||||
|
}, [
|
||||||
|
setSidebarShortcutWorkspaceTargets,
|
||||||
|
setVisibleWorkspaceTargets,
|
||||||
|
shortcutModel.shortcutTargets,
|
||||||
|
shortcutModel.visibleTargets,
|
||||||
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
setSidebarShortcutWorkspaceTargets([])
|
setSidebarShortcutWorkspaceTargets([])
|
||||||
|
setVisibleWorkspaceTargets([])
|
||||||
}
|
}
|
||||||
}, [setSidebarShortcutWorkspaceTargets])
|
}, [setSidebarShortcutWorkspaceTargets, setVisibleWorkspaceTargets])
|
||||||
|
|
||||||
const toggleProjectCollapsed = useCallback((projectKey: string) => {
|
const toggleProjectCollapsed = useCallback((projectKey: string) => {
|
||||||
setCollapsedProjectKeys((prev) => {
|
setCollapsedProjectKeys((prev) => {
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
|
|||||||
id: "new-agent",
|
id: "new-agent",
|
||||||
title: "New agent",
|
title: "New agent",
|
||||||
icon: "plus",
|
icon: "plus",
|
||||||
shortcutKeys: ["mod", "alt", "N"],
|
shortcutKeys: ["mod", "shift", "O"],
|
||||||
keywords: ["new", "new agent", "create", "start", "launch", "agent"],
|
keywords: ["new", "new agent", "create", "start", "launch", "agent"],
|
||||||
buildRoute: ({ newAgentRoute }) => newAgentRoute,
|
buildRoute: ({ newAgentRoute }) => newAgentRoute,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -59,9 +59,9 @@ export function useKeyboardShortcuts({
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const navigateToSidebarShortcut = (digit: number): boolean => {
|
const navigateToWorkspaceShortcut = (index: number): boolean => {
|
||||||
const state = useKeyboardShortcutsStore.getState();
|
const state = useKeyboardShortcutsStore.getState();
|
||||||
const target = state.sidebarShortcutWorkspaceTargets[digit - 1] ?? null;
|
const target = state.sidebarShortcutWorkspaceTargets[index - 1] ?? null;
|
||||||
if (!target) {
|
if (!target) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -73,6 +73,37 @@ export function useKeyboardShortcuts({
|
|||||||
navigate(buildHostWorkspaceRoute(target.serverId, target.workspaceId) as any);
|
navigate(buildHostWorkspaceRoute(target.serverId, target.workspaceId) as any);
|
||||||
return true;
|
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 => {
|
const navigateToNewAgent = (): boolean => {
|
||||||
let targetServerId = parseServerIdFromPathname(pathname);
|
let targetServerId = parseServerIdFromPathname(pathname);
|
||||||
@@ -103,6 +134,21 @@ export function useKeyboardShortcuts({
|
|||||||
});
|
});
|
||||||
return true;
|
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: {
|
const handleAction = (input: {
|
||||||
action: string;
|
action: string;
|
||||||
@@ -112,6 +158,36 @@ export function useKeyboardShortcuts({
|
|||||||
switch (input.action) {
|
switch (input.action) {
|
||||||
case "agent.new":
|
case "agent.new":
|
||||||
return navigateToNewAgent();
|
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":
|
case "sidebar.toggle.left":
|
||||||
toggleAgentList();
|
toggleAgentList();
|
||||||
return true;
|
return true;
|
||||||
@@ -130,11 +206,6 @@ export function useKeyboardShortcuts({
|
|||||||
}
|
}
|
||||||
toggleFileExplorer();
|
toggleFileExplorer();
|
||||||
return true;
|
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": {
|
case "command-center.toggle": {
|
||||||
const store = useKeyboardShortcutsStore.getState();
|
const store = useKeyboardShortcutsStore.getState();
|
||||||
if (!store.commandCenterOpen) {
|
if (!store.commandCenterOpen) {
|
||||||
|
|||||||
@@ -14,14 +14,20 @@ export type MessageInputKeyboardActionKind =
|
|||||||
|
|
||||||
export type KeyboardActionId =
|
export type KeyboardActionId =
|
||||||
| "agent.new"
|
| "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.left"
|
||||||
| "sidebar.toggle.right"
|
| "sidebar.toggle.right"
|
||||||
| "sidebar.navigate.shortcut"
|
|
||||||
| "command-center.toggle"
|
| "command-center.toggle"
|
||||||
| "shortcuts.dialog.toggle"
|
| "shortcuts.dialog.toggle"
|
||||||
| "message-input.action";
|
| "message-input.action";
|
||||||
|
|
||||||
export type KeyboardShortcutPayload =
|
export type KeyboardShortcutPayload =
|
||||||
| { digit: number }
|
| { index: number }
|
||||||
|
| { delta: 1 | -1 }
|
||||||
| { kind: MessageInputKeyboardActionKind }
|
| { kind: MessageInputKeyboardActionKind }
|
||||||
| null;
|
| null;
|
||||||
|
|||||||
@@ -32,6 +32,34 @@ function shortcutContext(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("keyboard-shortcuts", () => {
|
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", () => {
|
it("matches question-mark shortcut to toggle the shortcuts dialog", () => {
|
||||||
const match = resolveKeyboardShortcut({
|
const match = resolveKeyboardShortcut({
|
||||||
event: keyboardEvent({
|
event: keyboardEvent({
|
||||||
@@ -58,6 +86,134 @@ describe("keyboard-shortcuts", () => {
|
|||||||
expect(match).toBeNull();
|
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", () => {
|
it("matches Cmd+B sidebar toggle on macOS", () => {
|
||||||
const match = resolveKeyboardShortcut({
|
const match = resolveKeyboardShortcut({
|
||||||
event: keyboardEvent({
|
event: keyboardEvent({
|
||||||
@@ -163,20 +319,6 @@ describe("keyboard-shortcuts", () => {
|
|||||||
expect(match?.preventDefault).toBe(false);
|
expect(match?.preventDefault).toBe(false);
|
||||||
expect(match?.stopPropagation).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", () => {
|
describe("keyboard-shortcut help sections", () => {
|
||||||
@@ -193,30 +335,30 @@ describe("keyboard-shortcut help sections", () => {
|
|||||||
return null;
|
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({
|
const sections = buildKeyboardShortcutHelpSections({
|
||||||
isMac: true,
|
isMac: true,
|
||||||
isTauri: false,
|
isTauri: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(findRow(sections, "new-agent")?.keys).toEqual(["mod", "alt", "N"]);
|
expect(findRow(sections, "new-agent")?.keys).toEqual(["mod", "shift", "O"]);
|
||||||
expect(findRow(sections, "quick-open-workspace")?.keys).toEqual([
|
expect(findRow(sections, "workspace-jump-index")?.keys).toEqual(["alt", "1-9"]);
|
||||||
|
expect(findRow(sections, "workspace-tab-jump-index")?.keys).toEqual([
|
||||||
"alt",
|
"alt",
|
||||||
|
"shift",
|
||||||
"1-9",
|
"1-9",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("switches to tauri bindings in help rows", () => {
|
it("uses tauri defaults for workspace and tab jump", () => {
|
||||||
const sections = buildKeyboardShortcutHelpSections({
|
const sections = buildKeyboardShortcutHelpSections({
|
||||||
isMac: true,
|
isMac: true,
|
||||||
isTauri: true,
|
isTauri: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(findRow(sections, "new-agent")?.keys).toEqual(["mod", "N"]);
|
expect(findRow(sections, "new-agent")?.keys).toEqual(["mod", "shift", "O"]);
|
||||||
expect(findRow(sections, "quick-open-workspace")?.keys).toEqual([
|
expect(findRow(sections, "workspace-jump-index")?.keys).toEqual(["mod", "1-9"]);
|
||||||
"mod",
|
expect(findRow(sections, "workspace-tab-jump-index")?.keys).toEqual(["alt", "1-9"]);
|
||||||
"1-9",
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses mod+period as non-mac left sidebar shortcut", () => {
|
it("uses mod+period as non-mac left sidebar shortcut", () => {
|
||||||
|
|||||||
@@ -109,41 +109,253 @@ function withMessageInputAction(
|
|||||||
return () => ({ kind });
|
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[] = [
|
const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
||||||
{
|
{
|
||||||
id: "agent-new-mod-alt-n",
|
id: "agent-new-mod-shift-o",
|
||||||
action: "agent.new",
|
action: "agent.new",
|
||||||
matches: (event) =>
|
matches: (event) =>
|
||||||
isMod(event) &&
|
isMod(event) &&
|
||||||
event.altKey &&
|
!event.altKey &&
|
||||||
!event.shiftKey &&
|
event.shiftKey &&
|
||||||
(event.code === "KeyN" || event.key.toLowerCase() === "n"),
|
(event.code === "KeyO" || event.key.toLowerCase() === "o"),
|
||||||
when: () => true,
|
when: () => true,
|
||||||
help: {
|
help: {
|
||||||
id: "new-agent",
|
id: "new-agent",
|
||||||
section: "global",
|
section: "global",
|
||||||
label: "Create new agent",
|
label: "Create new agent",
|
||||||
keys: ["mod", "alt", "N"],
|
keys: ["mod", "shift", "O"],
|
||||||
when: (context) => !context.isTauri,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "agent-new-tauri-mod-n",
|
id: "workspace-tab-new-alt-shift-t",
|
||||||
action: "agent.new",
|
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) =>
|
matches: (event) =>
|
||||||
isMod(event) &&
|
isMod(event) &&
|
||||||
!event.altKey &&
|
!event.altKey &&
|
||||||
!event.shiftKey &&
|
!event.shiftKey &&
|
||||||
(event.code === "KeyN" || event.key.toLowerCase() === "n"),
|
hasDigit(event),
|
||||||
when: (context) => context.isTauri,
|
payload: withIndexPayload,
|
||||||
|
when: (context) => context.isTauri && !context.commandCenterOpen,
|
||||||
help: {
|
help: {
|
||||||
id: "new-agent",
|
id: "workspace-jump-index",
|
||||||
section: "global",
|
section: "global",
|
||||||
label: "Create new agent",
|
label: "Jump to workspace",
|
||||||
keys: ["mod", "N"],
|
keys: ["mod", "1-9"],
|
||||||
when: (context) => context.isTauri,
|
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",
|
id: "command-center-toggle",
|
||||||
action: "command-center.toggle",
|
action: "command-center.toggle",
|
||||||
@@ -308,40 +520,6 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
|||||||
keys: ["Space"],
|
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: {
|
export function resolveKeyboardShortcut(input: {
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
actionsReservedWidth: Math.max(0, tabsActionsWidth),
|
actionsReservedWidth: Math.max(0, tabsActionsWidth),
|
||||||
rowPaddingHorizontal: theme.spacing[2],
|
rowPaddingHorizontal: theme.spacing[2],
|
||||||
tabGap: theme.spacing[1],
|
tabGap: theme.spacing[1],
|
||||||
maxTabWidth: 150,
|
maxTabWidth: 200,
|
||||||
tabIconWidth: 14,
|
tabIconWidth: 14,
|
||||||
tabHorizontalPadding: theme.spacing[3],
|
tabHorizontalPadding: theme.spacing[3],
|
||||||
estimatedCharWidth: 7,
|
estimatedCharWidth: 7,
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
buildWorkspaceTabPersistenceKey,
|
buildWorkspaceTabPersistenceKey,
|
||||||
useWorkspaceTabsStore,
|
useWorkspaceTabsStore,
|
||||||
} from "@/stores/workspace-tabs-store";
|
} from "@/stores/workspace-tabs-store";
|
||||||
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
import {
|
import {
|
||||||
buildWorkspaceOpenIntentParam,
|
buildWorkspaceOpenIntentParam,
|
||||||
type WorkspaceOpenIntent,
|
type WorkspaceOpenIntent,
|
||||||
@@ -408,6 +409,12 @@ function WorkspaceScreenContent({
|
|||||||
const closeWorkspaceTab = useWorkspaceTabsStore((state) => state.closeTab);
|
const closeWorkspaceTab = useWorkspaceTabsStore((state) => state.closeTab);
|
||||||
const promoteDraftToAgent = useWorkspaceTabsStore((state) => state.promoteDraftToAgent);
|
const promoteDraftToAgent = useWorkspaceTabsStore((state) => state.promoteDraftToAgent);
|
||||||
const reorderWorkspaceTabs = useWorkspaceTabsStore((state) => state.reorderTabs);
|
const reorderWorkspaceTabs = useWorkspaceTabsStore((state) => state.reorderTabs);
|
||||||
|
const workspaceTabActionRequest = useKeyboardShortcutsStore(
|
||||||
|
(state) => state.workspaceTabActionRequest
|
||||||
|
);
|
||||||
|
const clearWorkspaceTabActionRequest = useKeyboardShortcutsStore(
|
||||||
|
(state) => state.clearWorkspaceTabActionRequest
|
||||||
|
);
|
||||||
const consumedOpenIntentsRef = useRef(new Set<string>());
|
const consumedOpenIntentsRef = useRef(new Set<string>());
|
||||||
|
|
||||||
useEffect(() => {
|
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 = () => {
|
const renderContent = () => {
|
||||||
if (
|
if (
|
||||||
shouldRenderMissingWorkspaceDescriptor({
|
shouldRenderMissingWorkspaceDescriptor({
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const metrics = {
|
|||||||
actionsReservedWidth: 120,
|
actionsReservedWidth: 120,
|
||||||
rowPaddingHorizontal: 8,
|
rowPaddingHorizontal: 8,
|
||||||
tabGap: 4,
|
tabGap: 4,
|
||||||
maxTabWidth: 150,
|
maxTabWidth: 200,
|
||||||
tabIconWidth: 14,
|
tabIconWidth: 14,
|
||||||
tabHorizontalPadding: 12,
|
tabHorizontalPadding: 12,
|
||||||
estimatedCharWidth: 7,
|
estimatedCharWidth: 7,
|
||||||
@@ -14,7 +14,7 @@ const metrics = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("computeWorkspaceTabLayout", () => {
|
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({
|
const result = computeWorkspaceTabLayout({
|
||||||
viewportWidth: 1200,
|
viewportWidth: 1200,
|
||||||
tabLabelLengths: [8, 10, 7],
|
tabLabelLengths: [8, 10, 7],
|
||||||
@@ -25,7 +25,7 @@ describe("computeWorkspaceTabLayout", () => {
|
|||||||
expect(result.requiresHorizontalScrollFallback).toBe(false);
|
expect(result.requiresHorizontalScrollFallback).toBe(false);
|
||||||
expect(result.items).toHaveLength(3);
|
expect(result.items).toHaveLength(3);
|
||||||
expect(result.items.every((item) => item.showLabel)).toBe(true);
|
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", () => {
|
it("shrinks tab widths proportionally before hiding labels", () => {
|
||||||
@@ -37,7 +37,7 @@ describe("computeWorkspaceTabLayout", () => {
|
|||||||
|
|
||||||
expect(result.closeButtonPolicy).toBe("all");
|
expect(result.closeButtonPolicy).toBe("all");
|
||||||
expect(result.requiresHorizontalScrollFallback).toBe(false);
|
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);
|
expect(result.items.every((item) => item.showLabel)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -67,10 +67,11 @@ export function computeWorkspaceTabLayout(
|
|||||||
const availableTabsWidth = Math.max(0, availableWidth - rowOverhead);
|
const availableTabsWidth = Math.max(0, availableWidth - rowOverhead);
|
||||||
const iconOnlyTabWidth =
|
const iconOnlyTabWidth =
|
||||||
input.metrics.tabIconWidth + input.metrics.tabHorizontalPadding * 2 + input.metrics.closeButtonWidth;
|
input.metrics.tabIconWidth + input.metrics.tabHorizontalPadding * 2 + input.metrics.closeButtonWidth;
|
||||||
|
const labelSafetyPadding = 10;
|
||||||
const estimateLabelWidth = (labelLength: number) => labelLength * input.metrics.estimatedCharWidth;
|
const estimateLabelWidth = (labelLength: number) => labelLength * input.metrics.estimatedCharWidth;
|
||||||
const desiredTabWidths = input.tabLabelLengths.map((rawLength) => {
|
const desiredTabWidths = input.tabLabelLengths.map((rawLength) => {
|
||||||
const labelLength = Math.max(rawLength, 1);
|
const labelLength = Math.max(rawLength, 1);
|
||||||
const estimatedWidth = iconOnlyTabWidth + estimateLabelWidth(labelLength);
|
const estimatedWidth = iconOnlyTabWidth + estimateLabelWidth(labelLength) + labelSafetyPadding;
|
||||||
return clamp(estimatedWidth, iconOnlyTabWidth, input.metrics.maxTabWidth);
|
return clamp(estimatedWidth, iconOnlyTabWidth, input.metrics.maxTabWidth);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -80,6 +81,7 @@ export function computeWorkspaceTabLayout(
|
|||||||
|
|
||||||
let resolvedWidths: number[];
|
let resolvedWidths: number[];
|
||||||
if (desiredTotalTabsWidth <= availableTabsWidth) {
|
if (desiredTotalTabsWidth <= availableTabsWidth) {
|
||||||
|
// Fit-content baseline: do not stretch tabs when there is spare space.
|
||||||
resolvedWidths = desiredTabWidths;
|
resolvedWidths = desiredTabWidths;
|
||||||
} else if (availableTabsWidth <= iconOnlyTotalTabsWidth) {
|
} else if (availableTabsWidth <= iconOnlyTotalTabsWidth) {
|
||||||
resolvedWidths = new Array(tabCount).fill(iconOnlyTabWidth);
|
resolvedWidths = new Array(tabCount).fill(iconOnlyTabWidth);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,28 @@ export type MessageInputActionRequest = {
|
|||||||
kind: MessageInputKeyboardActionKind;
|
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 {
|
interface KeyboardShortcutsState {
|
||||||
commandCenterOpen: boolean;
|
commandCenterOpen: boolean;
|
||||||
shortcutsDialogOpen: boolean;
|
shortcutsDialogOpen: boolean;
|
||||||
@@ -15,14 +37,19 @@ interface KeyboardShortcutsState {
|
|||||||
cmdOrCtrlDown: boolean;
|
cmdOrCtrlDown: boolean;
|
||||||
/** Sidebar-visible workspace targets (up to 9), in top-to-bottom visual order. */
|
/** Sidebar-visible workspace targets (up to 9), in top-to-bottom visual order. */
|
||||||
sidebarShortcutWorkspaceTargets: SidebarShortcutWorkspaceTarget[];
|
sidebarShortcutWorkspaceTargets: SidebarShortcutWorkspaceTarget[];
|
||||||
|
/** All visible workspace targets in top-to-bottom visual order. */
|
||||||
|
visibleWorkspaceTargets: SidebarShortcutWorkspaceTarget[];
|
||||||
messageInputActionRequest: MessageInputActionRequest | null;
|
messageInputActionRequest: MessageInputActionRequest | null;
|
||||||
nextMessageInputActionRequestId: number;
|
nextMessageInputActionRequestId: number;
|
||||||
|
workspaceTabActionRequest: WorkspaceTabActionRequest | null;
|
||||||
|
nextWorkspaceTabActionRequestId: number;
|
||||||
|
|
||||||
setCommandCenterOpen: (open: boolean) => void;
|
setCommandCenterOpen: (open: boolean) => void;
|
||||||
setShortcutsDialogOpen: (open: boolean) => void;
|
setShortcutsDialogOpen: (open: boolean) => void;
|
||||||
setAltDown: (down: boolean) => void;
|
setAltDown: (down: boolean) => void;
|
||||||
setCmdOrCtrlDown: (down: boolean) => void;
|
setCmdOrCtrlDown: (down: boolean) => void;
|
||||||
setSidebarShortcutWorkspaceTargets: (targets: SidebarShortcutWorkspaceTarget[]) => void;
|
setSidebarShortcutWorkspaceTargets: (targets: SidebarShortcutWorkspaceTarget[]) => void;
|
||||||
|
setVisibleWorkspaceTargets: (targets: SidebarShortcutWorkspaceTarget[]) => void;
|
||||||
resetModifiers: () => void;
|
resetModifiers: () => void;
|
||||||
|
|
||||||
requestMessageInputAction: (input: {
|
requestMessageInputAction: (input: {
|
||||||
@@ -30,6 +57,26 @@ interface KeyboardShortcutsState {
|
|||||||
kind: MessageInputKeyboardActionKind;
|
kind: MessageInputKeyboardActionKind;
|
||||||
}) => void;
|
}) => void;
|
||||||
clearMessageInputActionRequest: (id: number) => 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<KeyboardShortcutsState>(
|
export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>(
|
||||||
@@ -39,8 +86,11 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>(
|
|||||||
altDown: false,
|
altDown: false,
|
||||||
cmdOrCtrlDown: false,
|
cmdOrCtrlDown: false,
|
||||||
sidebarShortcutWorkspaceTargets: [],
|
sidebarShortcutWorkspaceTargets: [],
|
||||||
|
visibleWorkspaceTargets: [],
|
||||||
messageInputActionRequest: null,
|
messageInputActionRequest: null,
|
||||||
nextMessageInputActionRequestId: 1,
|
nextMessageInputActionRequestId: 1,
|
||||||
|
workspaceTabActionRequest: null,
|
||||||
|
nextWorkspaceTabActionRequestId: 1,
|
||||||
|
|
||||||
setCommandCenterOpen: (open) => set({ commandCenterOpen: open }),
|
setCommandCenterOpen: (open) => set({ commandCenterOpen: open }),
|
||||||
setShortcutsDialogOpen: (open) => set({ shortcutsDialogOpen: open }),
|
setShortcutsDialogOpen: (open) => set({ shortcutsDialogOpen: open }),
|
||||||
@@ -48,6 +98,7 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>(
|
|||||||
setCmdOrCtrlDown: (down) => set({ cmdOrCtrlDown: down }),
|
setCmdOrCtrlDown: (down) => set({ cmdOrCtrlDown: down }),
|
||||||
setSidebarShortcutWorkspaceTargets: (targets) =>
|
setSidebarShortcutWorkspaceTargets: (targets) =>
|
||||||
set({ sidebarShortcutWorkspaceTargets: targets }),
|
set({ sidebarShortcutWorkspaceTargets: targets }),
|
||||||
|
setVisibleWorkspaceTargets: (targets) => set({ visibleWorkspaceTargets: targets }),
|
||||||
resetModifiers: () => set({ altDown: false, cmdOrCtrlDown: false }),
|
resetModifiers: () => set({ altDown: false, cmdOrCtrlDown: false }),
|
||||||
|
|
||||||
requestMessageInputAction: ({ agentKey, kind }) => {
|
requestMessageInputAction: ({ agentKey, kind }) => {
|
||||||
@@ -64,5 +115,23 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>(
|
|||||||
}
|
}
|
||||||
set({ messageInputActionRequest: null });
|
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 });
|
||||||
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ describe("buildSidebarShortcutModel", () => {
|
|||||||
collapsedProjectKeys: new Set<string>(["p2"]),
|
collapsedProjectKeys: new Set<string>(["p2"]),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(model.visibleTargets).toEqual([
|
||||||
|
{ serverId: "s1", workspaceId: "/repo/main" },
|
||||||
|
{ serverId: "s1", workspaceId: "/repo/feat-a" },
|
||||||
|
]);
|
||||||
expect(model.shortcutTargets).toEqual([
|
expect(model.shortcutTargets).toEqual([
|
||||||
{ serverId: "s1", workspaceId: "/repo/main" },
|
{ serverId: "s1", workspaceId: "/repo/main" },
|
||||||
{ serverId: "s1", workspaceId: "/repo/feat-a" },
|
{ serverId: "s1", workspaceId: "/repo/feat-a" },
|
||||||
@@ -57,6 +61,8 @@ describe("buildSidebarShortcutModel", () => {
|
|||||||
collapsedProjectKeys: new Set<string>(),
|
collapsedProjectKeys: new Set<string>(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(model.visibleTargets).toHaveLength(20);
|
||||||
|
expect(model.visibleTargets[19]).toEqual({ serverId: "s", workspaceId: "/repo/w20" });
|
||||||
expect(model.shortcutTargets).toHaveLength(9);
|
expect(model.shortcutTargets).toHaveLength(9);
|
||||||
expect(model.shortcutTargets[0]).toEqual({ serverId: "s", workspaceId: "/repo/w1" });
|
expect(model.shortcutTargets[0]).toEqual({ serverId: "s", workspaceId: "/repo/w1" });
|
||||||
expect(model.shortcutTargets[8]).toEqual({ serverId: "s", workspaceId: "/repo/w9" });
|
expect(model.shortcutTargets[8]).toEqual({ serverId: "s", workspaceId: "/repo/w9" });
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface SidebarShortcutWorkspaceTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SidebarShortcutModel {
|
export interface SidebarShortcutModel {
|
||||||
|
visibleTargets: SidebarShortcutWorkspaceTarget[]
|
||||||
shortcutTargets: SidebarShortcutWorkspaceTarget[]
|
shortcutTargets: SidebarShortcutWorkspaceTarget[]
|
||||||
shortcutIndexByWorkspaceKey: Map<string, number>
|
shortcutIndexByWorkspaceKey: Map<string, number>
|
||||||
}
|
}
|
||||||
@@ -19,6 +20,7 @@ export function buildSidebarShortcutModel(input: {
|
|||||||
shortcutLimit?: number
|
shortcutLimit?: number
|
||||||
}): SidebarShortcutModel {
|
}): SidebarShortcutModel {
|
||||||
const maxShortcuts = Math.max(0, Math.floor(input.shortcutLimit ?? 9))
|
const maxShortcuts = Math.max(0, Math.floor(input.shortcutLimit ?? 9))
|
||||||
|
const visibleTargets: SidebarShortcutWorkspaceTarget[] = []
|
||||||
const shortcutTargets: SidebarShortcutWorkspaceTarget[] = []
|
const shortcutTargets: SidebarShortcutWorkspaceTarget[] = []
|
||||||
const shortcutIndexByWorkspaceKey = new Map<string, number>()
|
const shortcutIndexByWorkspaceKey = new Map<string, number>()
|
||||||
|
|
||||||
@@ -28,6 +30,10 @@ export function buildSidebarShortcutModel(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const workspace of project.workspaces) {
|
for (const workspace of project.workspaces) {
|
||||||
|
visibleTargets.push({
|
||||||
|
serverId: workspace.serverId,
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
})
|
||||||
const shortcutNumber =
|
const shortcutNumber =
|
||||||
shortcutTargets.length < maxShortcuts ? shortcutTargets.length + 1 : null
|
shortcutTargets.length < maxShortcuts ? shortcutTargets.length + 1 : null
|
||||||
if (shortcutNumber !== null) {
|
if (shortcutNumber !== null) {
|
||||||
@@ -40,5 +46,5 @@ export function buildSidebarShortcutModel(input: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { shortcutTargets, shortcutIndexByWorkspaceKey }
|
return { visibleTargets, shortcutTargets, shortcutIndexByWorkspaceKey }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user