feat: add workspace and tab navigation keyboard shortcuts

This commit is contained in:
Mohamed Boudra
2026-03-05 11:28:02 +07:00
parent 6f2b967599
commit 6dc49ccbc7
17 changed files with 944 additions and 110 deletions
@@ -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) => {
+1 -1
View File
@@ -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,
},
@@ -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) {
+8 -2
View File
@@ -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;
@@ -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", () => {
+225 -47
View File
@@ -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: {
@@ -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,
@@ -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<string>());
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({
@@ -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);
});
@@ -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);
@@ -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;
};
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<KeyboardShortcutsState>(
@@ -39,8 +86,11 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>(
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<KeyboardShortcutsState>(
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<KeyboardShortcutsState>(
}
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"]),
});
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<string>(),
});
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" });
+7 -1
View File
@@ -9,6 +9,7 @@ export interface SidebarShortcutWorkspaceTarget {
}
export interface SidebarShortcutModel {
visibleTargets: SidebarShortcutWorkspaceTarget[]
shortcutTargets: SidebarShortcutWorkspaceTarget[]
shortcutIndexByWorkspaceKey: Map<string, number>
}
@@ -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<string, number>()
@@ -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 }
}