diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx
index 12f5e7f2a..ab898f32e 100644
--- a/packages/app/src/app/_layout.tsx
+++ b/packages/app/src/app/_layout.tsx
@@ -63,6 +63,8 @@ import { CommandCenter } from "@/components/command-center";
import { ProjectPickerModal } from "@/components/project-picker-modal";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
+import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
+import { resolveActiveHost } from "@/utils/active-host";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
@@ -412,6 +414,10 @@ function AppContainer({
const isCompactLayout = useIsCompactFormFactor();
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
const pathname = usePathname();
+ const activeServerId = useMemo(
+ () => resolveActiveHost({ hosts: daemons, pathname })?.serverId ?? null,
+ [daemons, pathname],
+ );
const toggleAgentList = isCompactLayout ? toggleMobileAgentList : toggleDesktopAgentList;
const toggleDesktopSidebars = useCallback(() => {
const { desktop } = usePanelStore.getState();
@@ -492,6 +498,10 @@ function AppContainer({
+
diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx
index 964390cc9..75c2b4528 100644
--- a/packages/app/src/components/left-sidebar.tsx
+++ b/packages/app/src/components/left-sidebar.tsx
@@ -57,10 +57,10 @@ import {
buildHostSessionsRoute,
buildSettingsRoute,
mapPathnameToServer,
- parseServerIdFromPathname,
} from "@/utils/host-routes";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { isWeb } from "@/constants/platform";
+import { resolveActiveHost } from "@/utils/active-host";
const MIN_CHAT_WIDTH = 400;
@@ -127,19 +127,10 @@ export const LeftSidebar = memo(function LeftSidebar({
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const pathname = usePathname();
const daemons = useHosts();
- const activeServerIdFromPath = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
- const activeDaemon = useMemo(() => {
- if (daemons.length === 0) {
- return null;
- }
- if (activeServerIdFromPath) {
- const routeMatch = daemons.find((entry) => entry.serverId === activeServerIdFromPath);
- if (routeMatch) {
- return routeMatch;
- }
- }
- return daemons[0] ?? null;
- }, [activeServerIdFromPath, daemons]);
+ const activeDaemon = useMemo(
+ () => resolveActiveHost({ hosts: daemons, pathname }),
+ [daemons, pathname],
+ );
const activeServerId = activeDaemon?.serverId ?? null;
const activeHostLabel = useMemo(() => {
if (!activeDaemon) return "No host";
diff --git a/packages/app/src/components/project-picker-modal.tsx b/packages/app/src/components/project-picker-modal.tsx
index 7bc2e7239..bb3856f29 100644
--- a/packages/app/src/components/project-picker-modal.tsx
+++ b/packages/app/src/components/project-picker-modal.tsx
@@ -3,30 +3,22 @@ import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-nativ
import { Folder } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useQuery } from "@tanstack/react-query";
-import { usePathname } from "expo-router";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { shortenPath } from "@/utils/shorten-path";
import { useRecommendedProjectPaths } from "@/stores/session-store-hooks";
-import { useHosts, useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
+import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useOpenProject } from "@/hooks/use-open-project";
-import { parseServerIdFromPathname } from "@/utils/host-routes";
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
import { isNative } from "@/constants/platform";
+import { useActiveServerId } from "@/hooks/use-active-server-id";
export function ProjectPickerModal() {
const { theme } = useUnistyles();
- const pathname = usePathname();
- const daemons = useHosts();
+ const serverId = useActiveServerId();
const open = useKeyboardShortcutsStore((s) => s.projectPickerOpen);
const setOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen);
- const serverId = useMemo(() => {
- const fromPath = parseServerIdFromPathname(pathname);
- if (fromPath) return fromPath;
- return daemons[0]?.serverId ?? null;
- }, [pathname, daemons]);
-
const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const recommendedPaths = useRecommendedProjectPaths(serverId);
diff --git a/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx b/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx
new file mode 100644
index 000000000..b8e3692f4
--- /dev/null
+++ b/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx
@@ -0,0 +1,108 @@
+/**
+ * @vitest-environment jsdom
+ */
+import React from "react";
+import { act } from "@testing-library/react";
+import type { DaemonClient } from "@server/client/daemon-client";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
+import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
+import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
+import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
+import { WorkspaceShortcutTargetsSubscriber } from "./workspace-shortcut-targets-subscriber";
+
+vi.hoisted(() => {
+ (globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
+});
+
+function workspaceDescriptor(input: {
+ id: string;
+ name?: string;
+ projectId?: string;
+}): WorkspaceDescriptor {
+ return {
+ id: input.id,
+ projectId: input.projectId ?? "project-1",
+ projectDisplayName: "Project 1",
+ projectRootPath: "/repo/main",
+ workspaceDirectory: `/repo/main/${input.id}`,
+ projectKind: "git",
+ workspaceKind: "worktree",
+ name: input.name ?? input.id,
+ status: "done",
+ diffStat: null,
+ scripts: [],
+ };
+}
+
+describe("WorkspaceShortcutTargetsSubscriber", () => {
+ let root: Root | null = null;
+ let container: HTMLElement | null = null;
+
+ beforeEach(() => {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+
+ useKeyboardShortcutsStore.setState({
+ sidebarShortcutWorkspaceTargets: [],
+ });
+ useSidebarCollapsedSectionsStore.setState({
+ collapsedProjectKeys: new Set(),
+ });
+ useSidebarOrderStore.setState({
+ projectOrderByServerId: {},
+ workspaceOrderByServerAndProject: {},
+ });
+
+ act(() => {
+ useSessionStore.getState().initializeSession("srv", null as unknown as DaemonClient);
+ useSessionStore.getState().setWorkspaces(
+ "srv",
+ new Map([
+ ["ws-1", workspaceDescriptor({ id: "ws-1", name: "Workspace 1" })],
+ ["ws-2", workspaceDescriptor({ id: "ws-2", name: "Workspace 2" })],
+ ]),
+ );
+ useSessionStore.getState().setHasHydratedWorkspaces("srv", true);
+ });
+ });
+
+ afterEach(() => {
+ if (root) {
+ act(() => {
+ root?.unmount();
+ });
+ }
+ root = null;
+ container?.remove();
+ container = null;
+ act(() => {
+ useSessionStore.getState().clearSession("srv");
+ });
+ });
+
+ it("publishes workspace shortcut targets without rendering the sidebar", async () => {
+ await act(async () => {
+ root?.render();
+ });
+
+ expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([
+ { serverId: "srv", workspaceId: "ws-1" },
+ { serverId: "srv", workspaceId: "ws-2" },
+ ]);
+ });
+
+ it("clears targets when disabled", async () => {
+ await act(async () => {
+ root?.render();
+ });
+
+ await act(async () => {
+ root?.render();
+ });
+
+ expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([]);
+ });
+});
diff --git a/packages/app/src/components/workspace-shortcut-targets-subscriber.tsx b/packages/app/src/components/workspace-shortcut-targets-subscriber.tsx
new file mode 100644
index 000000000..be91cd606
--- /dev/null
+++ b/packages/app/src/components/workspace-shortcut-targets-subscriber.tsx
@@ -0,0 +1,47 @@
+import { useEffect, useMemo } from "react";
+import { useSidebarWorkspacesList } from "@/hooks/use-sidebar-workspaces-list";
+import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
+import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
+import { buildSidebarShortcutModel } from "@/utils/sidebar-shortcuts";
+
+export function WorkspaceShortcutTargetsSubscriber({
+ enabled,
+ serverId,
+}: {
+ enabled: boolean;
+ serverId: string | null;
+}) {
+ const { projects } = useSidebarWorkspacesList({ serverId, enabled });
+ const collapsedProjectKeys = useSidebarCollapsedSectionsStore(
+ (state) => state.collapsedProjectKeys,
+ );
+ const setSidebarShortcutWorkspaceTargets = useKeyboardShortcutsStore(
+ (state) => state.setSidebarShortcutWorkspaceTargets,
+ );
+
+ const shortcutModel = useMemo(
+ () =>
+ buildSidebarShortcutModel({
+ projects,
+ collapsedProjectKeys,
+ }),
+ [collapsedProjectKeys, projects],
+ );
+
+ useEffect(() => {
+ if (!enabled || !serverId) {
+ setSidebarShortcutWorkspaceTargets([]);
+ return;
+ }
+
+ setSidebarShortcutWorkspaceTargets(shortcutModel.shortcutTargets);
+ }, [enabled, serverId, setSidebarShortcutWorkspaceTargets, shortcutModel.shortcutTargets]);
+
+ useEffect(() => {
+ return () => {
+ setSidebarShortcutWorkspaceTargets([]);
+ };
+ }, [setSidebarShortcutWorkspaceTargets]);
+
+ return null;
+}
diff --git a/packages/app/src/hooks/use-active-server-id.ts b/packages/app/src/hooks/use-active-server-id.ts
new file mode 100644
index 000000000..369cd005e
--- /dev/null
+++ b/packages/app/src/hooks/use-active-server-id.ts
@@ -0,0 +1,15 @@
+import { useMemo } from "react";
+import { usePathname } from "expo-router";
+import { useHosts } from "@/runtime/host-runtime";
+import { resolveActiveHost } from "@/utils/active-host";
+
+export function useActiveHost() {
+ const pathname = usePathname();
+ const hosts = useHosts();
+
+ return useMemo(() => resolveActiveHost({ hosts, pathname }), [hosts, pathname]);
+}
+
+export function useActiveServerId(): string | null {
+ return useActiveHost()?.serverId ?? null;
+}
diff --git a/packages/app/src/hooks/use-command-center.ts b/packages/app/src/hooks/use-command-center.ts
index 6f2bd9a00..33f9c2606 100644
--- a/packages/app/src/hooks/use-command-center.ts
+++ b/packages/app/src/hooks/use-command-center.ts
@@ -4,7 +4,6 @@ import { router, usePathname, type Href } from "expo-router";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useSessionStore } from "@/stores/session-store";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
-import { useHosts } from "@/runtime/host-runtime";
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
@@ -12,11 +11,7 @@ import {
clearCommandCenterFocusRestoreElement,
takeCommandCenterFocusRestoreElement,
} from "@/utils/command-center-focus-restore";
-import {
- buildHostAgentDetailRoute,
- buildSettingsRoute,
- parseServerIdFromPathname,
-} from "@/utils/host-routes";
+import { buildHostAgentDetailRoute, buildSettingsRoute } from "@/utils/host-routes";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
@@ -26,6 +21,7 @@ import { getIsElectronRuntime } from "@/constants/layout";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { focusWithRetries } from "@/utils/web-focus";
+import { useActiveServerId } from "@/hooks/use-active-server-id";
const EMPTY_AGENTS: AggregatedAgent[] = [];
const EMPTY_ACTION_ITEMS: CommandCenterActionItem[] = [];
@@ -128,7 +124,7 @@ function resolveActionShortcutKeys(
export function useCommandCenter() {
const pathname = usePathname();
- const daemons = useHosts();
+ const routeActiveServerId = useActiveServerId();
const { overrides } = useKeyboardShortcutOverrides();
const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen);
const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen);
@@ -142,19 +138,7 @@ export function useCommandCenter() {
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
- const activeServerId = useMemo(() => {
- if (!open) {
- return null;
- }
- const serverIdFromPath = parseServerIdFromPathname(pathname);
- if (serverIdFromPath) {
- const routeMatch = daemons.find((entry) => entry.serverId === serverIdFromPath);
- if (routeMatch) {
- return routeMatch.serverId;
- }
- }
- return daemons[0]?.serverId ?? null;
- }, [daemons, open, pathname]);
+ const activeServerId = open ? routeActiveServerId : null;
const { agents } = useAllAgentsList({
serverId: activeServerId,
diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.test.tsx b/packages/app/src/hooks/use-keyboard-shortcuts.test.tsx
new file mode 100644
index 000000000..91db839ed
--- /dev/null
+++ b/packages/app/src/hooks/use-keyboard-shortcuts.test.tsx
@@ -0,0 +1,135 @@
+/**
+ * @vitest-environment jsdom
+ */
+import React from "react";
+import { act } from "@testing-library/react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const { navigateToWorkspaceMock, routerMock, pathState } = vi.hoisted(() => ({
+ navigateToWorkspaceMock: vi.fn(),
+ routerMock: {
+ back: vi.fn(),
+ push: vi.fn(),
+ },
+ pathState: {
+ pathname: "/h/srv/workspace/ws-2",
+ },
+}));
+
+vi.hoisted(() => {
+ (globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
+});
+
+vi.mock("expo-router", () => ({
+ usePathname: () => pathState.pathname,
+ useRouter: () => routerMock,
+}));
+
+vi.mock("@/constants/layout", () => ({
+ getIsElectronRuntime: () => true,
+}));
+
+vi.mock("@/constants/platform", () => ({
+ isNative: false,
+ isWeb: true,
+}));
+
+vi.mock("@/utils/shortcut-platform", () => ({
+ getShortcutOs: () => "mac",
+}));
+
+vi.mock("@/hooks/use-active-server-id", () => ({
+ useActiveServerId: () => "srv",
+}));
+
+vi.mock("@/hooks/use-open-project-picker", () => ({
+ useOpenProjectPicker: () => vi.fn(),
+}));
+
+vi.mock("@/hooks/use-keyboard-shortcut-overrides", () => ({
+ useKeyboardShortcutOverrides: () => ({ overrides: {} }),
+}));
+
+vi.mock("@/hooks/use-workspace-navigation", () => ({
+ navigateToWorkspace: navigateToWorkspaceMock,
+}));
+
+import { useKeyboardShortcuts } from "./use-keyboard-shortcuts";
+import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
+import {
+ activateNavigationWorkspaceSelection,
+ syncNavigationActiveWorkspace,
+} from "@/stores/navigation-active-workspace-store";
+
+function Probe() {
+ useKeyboardShortcuts({
+ enabled: true,
+ isMobile: false,
+ toggleAgentList: vi.fn(),
+ });
+
+ return null;
+}
+
+describe("useKeyboardShortcuts", () => {
+ let root: Root | null = null;
+ let container: HTMLElement | null = null;
+
+ beforeEach(() => {
+ navigateToWorkspaceMock.mockReset();
+ routerMock.back.mockReset();
+ routerMock.push.mockReset();
+ pathState.pathname = "/h/srv/workspace/ws-2";
+ syncNavigationActiveWorkspace({ current: null });
+ useKeyboardShortcutsStore.setState({
+ capturingShortcut: false,
+ commandCenterOpen: false,
+ sidebarShortcutWorkspaceTargets: [
+ { serverId: "srv", workspaceId: "ws-1" },
+ { serverId: "srv", workspaceId: "ws-2" },
+ { serverId: "srv", workspaceId: "ws-3" },
+ { serverId: "srv", workspaceId: "ws-4" },
+ { serverId: "srv", workspaceId: "ws-5" },
+ ],
+ });
+
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+ });
+
+ afterEach(() => {
+ if (root) {
+ act(() => {
+ root?.unmount();
+ });
+ }
+ root = null;
+ container?.remove();
+ container = null;
+ syncNavigationActiveWorkspace({ current: null });
+ });
+
+ it("uses the retained active workspace instead of stale pathname for bracket navigation", async () => {
+ activateNavigationWorkspaceSelection({ serverId: "srv", workspaceId: "ws-4" });
+
+ await act(async () => {
+ root?.render();
+ });
+
+ const event = new KeyboardEvent("keydown", {
+ key: "]",
+ code: "BracketRight",
+ metaKey: true,
+ bubbles: true,
+ cancelable: true,
+ });
+ window.dispatchEvent(event);
+
+ expect(navigateToWorkspaceMock).toHaveBeenCalledWith("srv", "ws-5", {
+ currentPathname: "/h/srv/workspace/ws-2",
+ });
+ expect(event.defaultPrevented).toBe(true);
+ });
+});
diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts
index 998444cab..5a0a71ee6 100644
--- a/packages/app/src/hooks/use-keyboard-shortcuts.ts
+++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts
@@ -1,13 +1,11 @@
import { useEffect, useMemo, useRef } from "react";
import { usePathname, useRouter } from "expo-router";
import { getIsElectronRuntime } from "@/constants/layout";
-import { useHosts } from "@/runtime/host-runtime";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import {
buildSettingsRoute,
parseHostAgentRouteFromPathname,
- parseServerIdFromPathname,
parseHostWorkspaceRouteFromPathname,
} from "@/utils/host-routes";
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
@@ -27,6 +25,9 @@ import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { isNative } from "@/constants/platform";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
+import { getRelativeSidebarShortcutTarget } from "@/utils/sidebar-shortcuts";
+import { useActiveServerId } from "@/hooks/use-active-server-id";
+import { getNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
export function useKeyboardShortcuts({
enabled,
@@ -45,7 +46,6 @@ export function useKeyboardShortcuts({
}) {
const pathname = usePathname();
const router = useRouter();
- const hosts = useHosts();
const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers);
const { overrides } = useKeyboardShortcutOverrides();
const bindings = useMemo(() => buildEffectiveBindings(overrides), [overrides]);
@@ -54,11 +54,7 @@ export function useKeyboardShortcuts({
step: 0,
timeoutId: null,
});
- const activeServerIdFromPath = parseServerIdFromPathname(pathname);
- const activeServerId =
- hosts.find((host) => host.serverId === activeServerIdFromPath)?.serverId ??
- hosts[0]?.serverId ??
- null;
+ const activeServerId = useActiveServerId();
const openProjectPickerAction = useOpenProjectPicker(activeServerId);
useEffect(() => {
@@ -87,31 +83,23 @@ export function useKeyboardShortcuts({
};
const navigateRelativeWorkspace = (delta: 1 | -1): boolean => {
const state = useKeyboardShortcutsStore.getState();
- const targets = state.visibleWorkspaceTargets;
+ const targets = state.sidebarShortcutWorkspaceTargets;
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;
- }
- navigateToWorkspace(fallback.serverId, fallback.workspaceId, {
- currentPathname: pathname,
- });
- 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;
+ const workspaceRoute =
+ getNavigationActiveWorkspaceSelection() ?? parseHostWorkspaceRouteFromPathname(pathname);
+ const target = getRelativeSidebarShortcutTarget({
+ targets,
+ currentTarget: workspaceRoute
+ ? {
+ serverId: workspaceRoute.serverId,
+ workspaceId: workspaceRoute.workspaceId,
+ }
+ : null,
+ delta,
+ });
if (!target) {
return false;
}
diff --git a/packages/app/src/hooks/use-sidebar-shortcut-model.ts b/packages/app/src/hooks/use-sidebar-shortcut-model.ts
index 63602c314..ec06085f4 100644
--- a/packages/app/src/hooks/use-sidebar-shortcut-model.ts
+++ b/packages/app/src/hooks/use-sidebar-shortcut-model.ts
@@ -1,16 +1,18 @@
-import { useCallback, useEffect, useMemo, useState } from "react";
+import { useEffect, useMemo } from "react";
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
-import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { buildSidebarShortcutModel } from "@/utils/sidebar-shortcuts";
import { isSidebarProjectFlattened } from "@/utils/sidebar-project-row-model";
+import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
export function useSidebarShortcutModel(projects: SidebarProjectEntry[]) {
- const [collapsedProjectKeys, setCollapsedProjectKeys] = useState>(new Set());
- const setSidebarShortcutWorkspaceTargets = useKeyboardShortcutsStore(
- (state) => state.setSidebarShortcutWorkspaceTargets,
+ const collapsedProjectKeys = useSidebarCollapsedSectionsStore(
+ (state) => state.collapsedProjectKeys,
);
- const setVisibleWorkspaceTargets = useKeyboardShortcutsStore(
- (state) => state.setVisibleWorkspaceTargets,
+ const setProjectCollapsed = useSidebarCollapsedSectionsStore(
+ (state) => state.setProjectCollapsed,
+ );
+ const toggleProjectCollapsed = useSidebarCollapsedSectionsStore(
+ (state) => state.toggleProjectCollapsed,
);
const shortcutModel = useMemo(
@@ -23,62 +25,17 @@ export function useSidebarShortcutModel(projects: SidebarProjectEntry[]) {
);
useEffect(() => {
- setCollapsedProjectKeys((prev) => {
- const collapsibleProjectKeys = new Set(
- projects
- .filter((project) => !isSidebarProjectFlattened(project))
- .map((project) => project.projectKey),
- );
- const next = new Set();
- for (const key of prev) {
- if (collapsibleProjectKeys.has(key)) {
- next.add(key);
- }
+ const collapsibleProjectKeys = new Set(
+ projects
+ .filter((project) => !isSidebarProjectFlattened(project))
+ .map((project) => project.projectKey),
+ );
+ for (const key of collapsedProjectKeys) {
+ if (!collapsibleProjectKeys.has(key)) {
+ setProjectCollapsed(key, false);
}
- return next;
- });
- }, [projects]);
-
- useEffect(() => {
- setSidebarShortcutWorkspaceTargets(shortcutModel.shortcutTargets);
- setVisibleWorkspaceTargets(shortcutModel.visibleTargets);
- }, [
- setSidebarShortcutWorkspaceTargets,
- setVisibleWorkspaceTargets,
- shortcutModel.shortcutTargets,
- shortcutModel.visibleTargets,
- ]);
-
- useEffect(() => {
- return () => {
- setSidebarShortcutWorkspaceTargets([]);
- setVisibleWorkspaceTargets([]);
- };
- }, [setSidebarShortcutWorkspaceTargets, setVisibleWorkspaceTargets]);
-
- const toggleProjectCollapsed = useCallback((projectKey: string) => {
- setCollapsedProjectKeys((prev) => {
- const next = new Set(prev);
- if (next.has(projectKey)) {
- next.delete(projectKey);
- } else {
- next.add(projectKey);
- }
- return next;
- });
- }, []);
-
- const setProjectCollapsed = useCallback((projectKey: string, collapsed: boolean) => {
- setCollapsedProjectKeys((prev) => {
- const next = new Set(prev);
- if (collapsed) {
- next.add(projectKey);
- } else {
- next.delete(projectKey);
- }
- return next;
- });
- }, []);
+ }
+ }, [collapsedProjectKeys, projects, setProjectCollapsed]);
return {
collapsedProjectKeys,
diff --git a/packages/app/src/stores/keyboard-shortcuts-store.test.ts b/packages/app/src/stores/keyboard-shortcuts-store.test.ts
index 57a505237..d0291dad5 100644
--- a/packages/app/src/stores/keyboard-shortcuts-store.test.ts
+++ b/packages/app/src/stores/keyboard-shortcuts-store.test.ts
@@ -10,7 +10,6 @@ beforeEach(() => {
altDown: false,
cmdOrCtrlDown: false,
sidebarShortcutWorkspaceTargets: [],
- visibleWorkspaceTargets: [],
});
});
diff --git a/packages/app/src/stores/keyboard-shortcuts-store.ts b/packages/app/src/stores/keyboard-shortcuts-store.ts
index a3b2e2eca..d47084a58 100644
--- a/packages/app/src/stores/keyboard-shortcuts-store.ts
+++ b/packages/app/src/stores/keyboard-shortcuts-store.ts
@@ -13,8 +13,6 @@ interface KeyboardShortcutsState {
showShortcutBadges: 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[];
setCommandCenterOpen: (open: boolean) => void;
setProjectPickerOpen: (open: boolean) => void;
@@ -23,7 +21,6 @@ interface KeyboardShortcutsState {
setAltDown: (down: boolean) => void;
setCmdOrCtrlDown: (down: boolean) => void;
setSidebarShortcutWorkspaceTargets: (targets: SidebarShortcutWorkspaceTarget[]) => void;
- setVisibleWorkspaceTargets: (targets: SidebarShortcutWorkspaceTarget[]) => void;
resetModifiers: () => void;
}
@@ -59,7 +56,6 @@ export const useKeyboardShortcutsStore = create((set, ge
cmdOrCtrlDown: false,
showShortcutBadges: false,
sidebarShortcutWorkspaceTargets: [],
- visibleWorkspaceTargets: [],
setCommandCenterOpen: (open) => set({ commandCenterOpen: open }),
setProjectPickerOpen: (open) => set({ projectPickerOpen: open }),
@@ -75,7 +71,6 @@ export const useKeyboardShortcutsStore = create((set, ge
},
setSidebarShortcutWorkspaceTargets: (targets) =>
set({ sidebarShortcutWorkspaceTargets: targets }),
- setVisibleWorkspaceTargets: (targets) => set({ visibleWorkspaceTargets: targets }),
resetModifiers: () => {
set({ altDown: false, cmdOrCtrlDown: false });
updateBadgeTimer(set, get);
diff --git a/packages/app/src/utils/active-host.test.ts b/packages/app/src/utils/active-host.test.ts
new file mode 100644
index 000000000..b791f6e74
--- /dev/null
+++ b/packages/app/src/utils/active-host.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from "vitest";
+import type { HostProfile } from "@/types/host-connection";
+import { resolveActiveHost } from "./active-host";
+
+function host(serverId: string): HostProfile {
+ return {
+ serverId,
+ label: serverId,
+ lifecycle: {},
+ connections: [],
+ preferredConnectionId: null,
+ createdAt: "",
+ updatedAt: "",
+ };
+}
+
+describe("resolveActiveHost", () => {
+ it("uses the route host when it exists", () => {
+ expect(
+ resolveActiveHost({
+ hosts: [host("srv-1"), host("srv-2")],
+ pathname: "/h/srv-2/workspace/ws-main",
+ })?.serverId,
+ ).toBe("srv-2");
+ });
+
+ it("falls back to the first host when the route host is missing", () => {
+ expect(
+ resolveActiveHost({
+ hosts: [host("srv-1"), host("srv-2")],
+ pathname: "/h/missing/workspace/ws-main",
+ })?.serverId,
+ ).toBe("srv-1");
+ });
+
+ it("returns null when no hosts exist", () => {
+ expect(resolveActiveHost({ hosts: [], pathname: "/settings" })).toBeNull();
+ });
+});
diff --git a/packages/app/src/utils/active-host.ts b/packages/app/src/utils/active-host.ts
new file mode 100644
index 000000000..9662d9048
--- /dev/null
+++ b/packages/app/src/utils/active-host.ts
@@ -0,0 +1,20 @@
+import type { HostProfile } from "@/types/host-connection";
+import { parseServerIdFromPathname } from "@/utils/host-routes";
+
+export function resolveActiveHost({
+ hosts,
+ pathname,
+}: {
+ hosts: readonly HostProfile[];
+ pathname: string;
+}): HostProfile | null {
+ const serverIdFromPath = parseServerIdFromPathname(pathname);
+ if (serverIdFromPath) {
+ const routeMatch = hosts.find((host) => host.serverId === serverIdFromPath);
+ if (routeMatch) {
+ return routeMatch;
+ }
+ }
+
+ return hosts[0] ?? null;
+}
diff --git a/packages/app/src/utils/sidebar-shortcuts.test.ts b/packages/app/src/utils/sidebar-shortcuts.test.ts
index 3fe0d20eb..19e6d3464 100644
--- a/packages/app/src/utils/sidebar-shortcuts.test.ts
+++ b/packages/app/src/utils/sidebar-shortcuts.test.ts
@@ -4,7 +4,7 @@ import type {
SidebarWorkspaceEntry,
} from "@/hooks/use-sidebar-workspaces-list";
-import { buildSidebarShortcutModel } from "./sidebar-shortcuts";
+import { buildSidebarShortcutModel, getRelativeSidebarShortcutTarget } from "./sidebar-shortcuts";
function workspace(input: {
serverId: string;
@@ -75,10 +75,6 @@ describe("buildSidebarShortcutModel", () => {
collapsedProjectKeys: new Set(["p2"]),
});
- expect(model.visibleTargets).toEqual([
- { serverId: "s1", workspaceId: "ws-main" },
- { serverId: "s1", workspaceId: "ws-feat-a" },
- ]);
expect(model.shortcutTargets).toEqual([
{ serverId: "s1", workspaceId: "ws-main" },
{ serverId: "s1", workspaceId: "ws-feat-a" },
@@ -104,8 +100,6 @@ describe("buildSidebarShortcutModel", () => {
collapsedProjectKeys: new Set(),
});
- expect(model.visibleTargets).toHaveLength(20);
- expect(model.visibleTargets[19]).toEqual({ serverId: "s", workspaceId: "ws-20" });
expect(model.shortcutTargets).toHaveLength(9);
expect(model.shortcutTargets[0]).toEqual({ serverId: "s", workspaceId: "ws-1" });
expect(model.shortcutTargets[8]).toEqual({ serverId: "s", workspaceId: "ws-9" });
@@ -128,7 +122,68 @@ describe("buildSidebarShortcutModel", () => {
collapsedProjectKeys: new Set(["p1"]),
});
- expect(model.visibleTargets).toEqual([]);
expect(model.shortcutTargets).toEqual([]);
});
});
+
+describe("getRelativeSidebarShortcutTarget", () => {
+ const targets = [
+ { serverId: "s1", workspaceId: "ws-1" },
+ { serverId: "s1", workspaceId: "ws-2" },
+ { serverId: "s1", workspaceId: "ws-3" },
+ ];
+
+ it("moves backward and forward through the numbered shortcut target list", () => {
+ expect(
+ getRelativeSidebarShortcutTarget({
+ targets,
+ currentTarget: { serverId: "s1", workspaceId: "ws-2" },
+ delta: -1,
+ }),
+ ).toEqual({ serverId: "s1", workspaceId: "ws-1" });
+
+ expect(
+ getRelativeSidebarShortcutTarget({
+ targets,
+ currentTarget: { serverId: "s1", workspaceId: "ws-2" },
+ delta: 1,
+ }),
+ ).toEqual({ serverId: "s1", workspaceId: "ws-3" });
+ });
+
+ it("wraps around the numbered shortcut target list", () => {
+ expect(
+ getRelativeSidebarShortcutTarget({
+ targets,
+ currentTarget: { serverId: "s1", workspaceId: "ws-1" },
+ delta: -1,
+ }),
+ ).toEqual({ serverId: "s1", workspaceId: "ws-3" });
+
+ expect(
+ getRelativeSidebarShortcutTarget({
+ targets,
+ currentTarget: { serverId: "s1", workspaceId: "ws-3" },
+ delta: 1,
+ }),
+ ).toEqual({ serverId: "s1", workspaceId: "ws-1" });
+ });
+
+ it("falls back to the nearest edge when the current route is not in the numbered list", () => {
+ expect(
+ getRelativeSidebarShortcutTarget({
+ targets,
+ currentTarget: { serverId: "s1", workspaceId: "ws-hidden" },
+ delta: 1,
+ }),
+ ).toEqual({ serverId: "s1", workspaceId: "ws-1" });
+
+ expect(
+ getRelativeSidebarShortcutTarget({
+ targets,
+ currentTarget: { serverId: "s1", workspaceId: "ws-hidden" },
+ delta: -1,
+ }),
+ ).toEqual({ serverId: "s1", workspaceId: "ws-3" });
+ });
+});
diff --git a/packages/app/src/utils/sidebar-shortcuts.ts b/packages/app/src/utils/sidebar-shortcuts.ts
index be2193892..22237756c 100644
--- a/packages/app/src/utils/sidebar-shortcuts.ts
+++ b/packages/app/src/utils/sidebar-shortcuts.ts
@@ -10,7 +10,6 @@ export interface SidebarShortcutWorkspaceTarget {
}
export interface SidebarShortcutModel {
- visibleTargets: SidebarShortcutWorkspaceTarget[];
shortcutTargets: SidebarShortcutWorkspaceTarget[];
shortcutIndexByWorkspaceKey: Map;
}
@@ -28,7 +27,6 @@ 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();
@@ -38,8 +36,6 @@ export function buildSidebarShortcutModel(input: {
}
for (const workspace of project.workspaces) {
- visibleTargets.push(createShortcutTarget(workspace));
-
if (shortcutTargets.length >= maxShortcuts) {
continue;
}
@@ -50,5 +46,32 @@ export function buildSidebarShortcutModel(input: {
}
}
- return { visibleTargets, shortcutTargets, shortcutIndexByWorkspaceKey };
+ return { shortcutTargets, shortcutIndexByWorkspaceKey };
+}
+
+export function getRelativeSidebarShortcutTarget(input: {
+ targets: readonly SidebarShortcutWorkspaceTarget[];
+ currentTarget: SidebarShortcutWorkspaceTarget | null;
+ delta: 1 | -1;
+}): SidebarShortcutWorkspaceTarget | null {
+ if (input.targets.length === 0) {
+ return null;
+ }
+
+ if (!input.currentTarget) {
+ return input.targets[input.delta > 0 ? 0 : input.targets.length - 1] ?? null;
+ }
+
+ const currentTarget = input.currentTarget;
+ const currentIndex = input.targets.findIndex(
+ (target) =>
+ target.serverId === currentTarget.serverId &&
+ target.workspaceId === currentTarget.workspaceId,
+ );
+ if (currentIndex < 0) {
+ return input.targets[input.delta > 0 ? 0 : input.targets.length - 1] ?? null;
+ }
+
+ const nextIndex = (currentIndex + input.delta + input.targets.length) % input.targets.length;
+ return input.targets[nextIndex] ?? null;
}