Files
kandev/apps/web/components/global-commands.tsx
T
José Almeida e626d33a7c fix(web): make toggle-sidebar shortcut work on every route (#1283)
* fix(web): make toggle-sidebar shortcut work on every route

TOGGLE_SIDEBAR (Cmd/Ctrl+B) was handled only inside useEditorKeybinds,
mounted just by the dockview desktop layout, so it was dead on Kanban,
the task list, Office, Settings, etc. Lift it to a global app-root
listener (useAppShortcuts, mounted in GlobalCommands) that honors user
overrides via getShortcut and keeps the isEditableTarget guard, and drop
the now-duplicate branch from useEditorKeybinds. Adds a hook unit test
and a Playwright e2e covering Kanban, Settings, and the dockview session.

* fix(web): hydrate userSettings into the root store on settings routes

Each StateProvider creates its own store; the settings/general page mounts
a nested one that the root-mounted GlobalCommands/AppSidebar can't see. The
settings layout hydrated the root store but omitted userSettings, so
override-driven global shortcuts (TOGGLE_SIDEBAR, Quick Chat) were dead on
every /settings route. Hydrate userSettings there too, matching the
kanban/tasks/office/github routes.

* test(web): cover the macOS Cmd path; note the settings-layout catch trade-off

Address review feedback: add a metaKey (Cmd) variant to the useAppShortcuts
test so both ctrlOrCmd branches are exercised, and document that the settings
layout's outer catch intentionally drops userSettings overrides in the
already-degraded error path.
2026-06-06 23:09:12 +01:00

224 lines
7.0 KiB
TypeScript

"use client";
import { useMemo, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useTheme } from "next-themes";
import {
IconHome,
IconList,
IconSettings,
IconChartBar,
IconSun,
IconMoon,
IconRobot,
IconCpu,
IconServer,
IconFolder,
IconMessageCircle,
IconSparkles,
IconBrandGithub,
} from "@tabler/icons-react";
import { useRegisterCommands } from "@/hooks/use-register-commands";
import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut";
import { useAppShortcuts } from "@/hooks/use-app-shortcuts";
import { useAppStore } from "@/components/state-provider";
import { getShortcut } from "@/lib/keyboard/shortcut-overrides";
import type { CommandItem } from "@/lib/commands/types";
type PushFn = ReturnType<typeof useRouter>["push"];
function buildNavigationCommands(push: PushFn): CommandItem[] {
return [
{
id: "nav-home",
label: "Go to Home",
group: "Navigation",
icon: <IconHome className="size-3.5" />,
keywords: ["home", "kanban", "board"],
action: () => push("/"),
},
{
id: "nav-tasks",
label: "Go to All Tasks",
group: "Navigation",
icon: <IconList className="size-3.5" />,
keywords: ["tasks", "list", "all"],
action: () => push("/tasks"),
},
{
id: "nav-settings",
label: "Go to Settings",
group: "Navigation",
icon: <IconSettings className="size-3.5" />,
keywords: ["settings", "preferences", "config"],
action: () => push("/settings/general"),
},
{
id: "nav-stats",
label: "Go to Stats",
group: "Navigation",
icon: <IconChartBar className="size-3.5" />,
keywords: ["stats", "analytics", "metrics"],
action: () => push("/stats"),
},
{
id: "nav-github",
label: "Go to GitHub Dashboard",
group: "Navigation",
icon: <IconBrandGithub className="size-3.5" />,
keywords: ["github", "dashboard", "pr", "pull request", "review"],
action: () => push("/github"),
},
{
id: "settings-agents",
label: "Agents Settings",
group: "Settings",
icon: <IconRobot className="size-3.5" />,
keywords: ["agents", "ai", "claude"],
action: () => push("/settings/agents"),
},
{
id: "settings-executors",
label: "Executors Settings",
group: "Settings",
icon: <IconCpu className="size-3.5" />,
keywords: ["executors", "compute", "run"],
action: () => push("/settings/executors"),
},
{
id: "settings-environments",
label: "Environments Settings",
group: "Settings",
icon: <IconServer className="size-3.5" />,
keywords: ["environments", "env", "variables"],
action: () => push("/settings/environments"),
},
{
id: "settings-workspace",
label: "Workspace Settings",
group: "Settings",
icon: <IconFolder className="size-3.5" />,
keywords: ["workspace", "project"],
action: () => push("/settings/workspace"),
},
{
id: "settings-prompts",
label: "Prompts Settings",
group: "Settings",
icon: <IconMessageCircle className="size-3.5" />,
keywords: ["prompts", "templates", "message"],
action: () => push("/settings/prompts"),
},
];
}
function buildThemeCommand(
resolvedTheme: string | undefined,
setTheme: (theme: string) => void,
): CommandItem {
const isDark = resolvedTheme === "dark";
return {
id: "pref-theme",
label: isDark ? "Switch to Light Mode" : "Switch to Dark Mode",
group: "Preferences",
icon: isDark ? <IconSun className="size-3.5" /> : <IconMoon className="size-3.5" />,
keywords: ["theme", "dark", "light", "mode"],
action: () => setTheme(isDark ? "light" : "dark"),
};
}
export function GlobalCommands() {
const router = useRouter();
const { resolvedTheme, setTheme } = useTheme();
const activeWorkspaceId = useAppStore((s) => s.workspaces.activeId);
const quickChatSessions = useAppStore((s) => s.quickChat.sessions);
const openQuickChat = useAppStore((s) => s.openQuickChat);
const startNewConfigChat = useAppStore((s) => s.startNewConfigChat);
const openConfigChat = useAppStore((s) => s.openConfigChat);
const configChatSessions = useAppStore((s) => s.configChat.sessions);
const configChatActiveSessionId = useAppStore((s) => s.configChat.activeSessionId);
const configChatWorkspaceId = useAppStore((s) => s.configChat.workspaceId);
const handleOpenQuickChat = useCallback(() => {
if (!activeWorkspaceId) {
return;
}
// If there's an existing session for this workspace, open it
const existingSession = quickChatSessions.find((s) => s.workspaceId === activeWorkspaceId);
if (existingSession) {
openQuickChat(existingSession.sessionId, activeWorkspaceId);
} else {
// Open modal without a session - will show agent picker
openQuickChat("", activeWorkspaceId);
}
}, [activeWorkspaceId, quickChatSessions, openQuickChat]);
const handleOpenConfigChat = useCallback(() => {
if (!activeWorkspaceId) return;
// Reuse existing session if one is active for this workspace
if (configChatActiveSessionId && configChatWorkspaceId === activeWorkspaceId) {
openConfigChat(configChatActiveSessionId, activeWorkspaceId);
return;
}
// Check for a persisted session for this workspace
const existingSession = configChatSessions.find((s) => s.workspaceId === activeWorkspaceId);
if (existingSession) {
openConfigChat(existingSession.sessionId, activeWorkspaceId);
return;
}
startNewConfigChat(activeWorkspaceId);
}, [
activeWorkspaceId,
configChatSessions,
configChatActiveSessionId,
configChatWorkspaceId,
openConfigChat,
startNewConfigChat,
]);
const keyboardShortcuts = useAppStore((s) => s.userSettings.keyboardShortcuts);
const quickChatShortcut = getShortcut("QUICK_CHAT", keyboardShortcuts);
const quickChatCommand: CommandItem = useMemo(
() => ({
id: "quick-chat",
label: "Quick Chat",
group: "Actions",
icon: <IconMessageCircle className="size-3.5" />,
keywords: ["quick", "chat", "ai", "agent"],
shortcut: quickChatShortcut,
action: handleOpenQuickChat,
}),
[handleOpenQuickChat, quickChatShortcut],
);
const configChatCommand: CommandItem = useMemo(
() => ({
id: "config-chat",
label: "Configuration Chat",
group: "Actions",
icon: <IconSparkles className="size-3.5" />,
keywords: ["config", "configure", "settings", "chat", "ai"],
action: handleOpenConfigChat,
}),
[handleOpenConfigChat],
);
const commands = useMemo<CommandItem[]>(
() => [
...buildNavigationCommands(router.push),
buildThemeCommand(resolvedTheme, setTheme),
quickChatCommand,
configChatCommand,
],
[router.push, resolvedTheme, setTheme, quickChatCommand, configChatCommand],
);
useRegisterCommands(commands);
useKeyboardShortcut(quickChatShortcut, handleOpenQuickChat);
useAppShortcuts();
return null;
}