fix(app): pin the active workspace from a collapsed sidebar section (#2299)
Cmd+Shift+P did nothing whenever the active workspace's sidebar row was not rendered. The `workspace.pin` handler was registered by the row itself, gated on `selected && canPin`, so collapsing the row's section unmounted the only handler for the action and the dispatcher found zero candidates. The keypress was swallowed with no toast and no error. Move the action to a single always-mounted handler keyed on the active route selection, following the existing `useGlobalNewWorkspaceAction` / `useActiveWorktreeNewAction` pattern, and delete the two per-row registrations. Besides the reported case this also fixes a collapsed status group, a collapsed Pinned section (so unpinning works too), and focus mode. The handler lives in a headless component rather than being called from the root layout, so subscribing to the active workspace's pin state does not re-render the whole app shell. Two supporting changes: - The controller now takes a narrow `PinnableWorkspace` instead of a full `SidebarWorkspaceEntry`, so a caller without a sidebar row can build one. Its in-flight guard moves to module scope, because the row menus and the shortcut hold separate controller instances and a per-instance guard would let a keypress and a menu click fire two concurrent, opposite RPCs. - The handler resolves the descriptor id via `useWorkspaceFields` rather than reusing the route id. The route carries an opaque workspace id that is not guaranteed to equal the descriptor id, which is why `selectWorkspace` resolves it through `resolveWorkspaceMapKeyByIdentity`. Both the RPC and the in-flight key need the descriptor id so that rows and this handler agree on one identity. `workspace.archive` has the same row-scoped defect and is deliberately left alone: it carries per-row state (`isArchiving`, optimistic hiding, the risky-worktree confirm) and needs its own change. Covered by a Playwright spec: the three collapse cases fail without this fix, plus one-RPC-per-press and a rejected-pin case that asserts the error toast and that the next press still succeeds. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
99440201bd
commit
cf36b2cc40
@@ -32,6 +32,7 @@ import { AppDiagnosticHost } from "@/components/app-diagnostic-host";
|
||||
import { LeftSidebar } from "@/components/left-sidebar";
|
||||
import { WindowSidebarMenuToggle } from "@/components/headers/menu-header";
|
||||
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
|
||||
import { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut-handler";
|
||||
import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host";
|
||||
import { ProviderSettingsHost } from "@/components/provider-settings-host";
|
||||
import { RootErrorBoundary } from "@/components/root-error-boundary";
|
||||
@@ -551,6 +552,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
<UpdateCalloutSource />
|
||||
<WorktreeSetupCalloutSource />
|
||||
<CommandCenterRootActions />
|
||||
<WorkspacePinShortcutHandler />
|
||||
<CommandCenter />
|
||||
<AddProjectFlowHost />
|
||||
<HostChooserModal />
|
||||
|
||||
@@ -1368,17 +1368,6 @@ function WorkspaceRowWithMenu({
|
||||
},
|
||||
});
|
||||
|
||||
useKeyboardActionHandler({
|
||||
handlerId: `workspace-pin-${workspace.workspaceKey}`,
|
||||
actions: ["workspace.pin"],
|
||||
enabled: selected && canPin,
|
||||
priority: 0,
|
||||
handle: () => {
|
||||
onTogglePin?.();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<WorkspaceRowInner
|
||||
|
||||
@@ -549,17 +549,6 @@ function StatusWorkspaceRowWithMenu({
|
||||
},
|
||||
});
|
||||
|
||||
useKeyboardActionHandler({
|
||||
handlerId: `workspace-pin-${workspace.workspaceKey}`,
|
||||
actions: ["workspace.pin"],
|
||||
enabled: selected && canPin,
|
||||
priority: 0,
|
||||
handle: () => {
|
||||
onTogglePin?.();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatusWorkspaceRowInner
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useGlobalWorkspacePinAction } from "@/hooks/use-global-workspace-pin-action";
|
||||
|
||||
// Headless host for the pin shortcut. The hook subscribes to the active workspace's pin state, so
|
||||
// it lives in its own component rather than the root layout — otherwise every pin toggle would
|
||||
// re-render the whole app shell.
|
||||
export function WorkspacePinShortcutHandler() {
|
||||
useGlobalWorkspacePinAction();
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useCallback } from "react";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import { useSidebarWorkspacePinController } from "@/hooks/use-sidebar-workspace-pin";
|
||||
import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
|
||||
|
||||
const WORKSPACE_PIN_ACTIONS: readonly KeyboardActionId[] = ["workspace.pin"];
|
||||
|
||||
// The pin shortcut used to live on the sidebar row, so it disappeared whenever the row was not
|
||||
// rendered — a collapsed project or status group, a collapsed Pinned section, or focus mode.
|
||||
// It belongs here instead: one registration keyed on the active route selection.
|
||||
//
|
||||
// "Active workspace" means the route selection, not a focused pane. Those are equivalent today
|
||||
// (panes belong to the routed workspace, and /settings parses to no selection, which correctly
|
||||
// disables the handler). Revisit this if panes ever span workspaces.
|
||||
export function useGlobalWorkspacePinAction() {
|
||||
const selection = useActiveWorkspaceSelection();
|
||||
const serverId = selection?.serverId ?? null;
|
||||
const routeWorkspaceId = selection?.workspaceId ?? null;
|
||||
// Narrow projection so pin state changes don't re-render on every gitRuntime/diffStat tick.
|
||||
// A null result means the workspace is gone, which `pinnedAt: null` alone could not express.
|
||||
//
|
||||
// `id` is projected rather than reusing the route id: the route carries an opaque workspace id
|
||||
// that is not guaranteed to equal the descriptor id (that is why `selectWorkspace` resolves it
|
||||
// through `resolveWorkspaceMapKeyByIdentity`). The RPC and the in-flight key both need the
|
||||
// descriptor id, so that sidebar rows and this handler agree on one identity.
|
||||
const fields = useWorkspaceFields(serverId, routeWorkspaceId, (workspace) => ({
|
||||
id: workspace.id,
|
||||
pinnedAt: workspace.pinnedAt ?? null,
|
||||
}));
|
||||
const canPin = useHostFeature(serverId, "workspacePinning");
|
||||
const togglePin = useSidebarWorkspacePinController();
|
||||
|
||||
const handle = useCallback(() => {
|
||||
if (!serverId || !fields || !canPin) {
|
||||
return false;
|
||||
}
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey({
|
||||
serverId,
|
||||
workspaceId: fields.id,
|
||||
});
|
||||
if (!workspaceKey) {
|
||||
return false;
|
||||
}
|
||||
togglePin({
|
||||
serverId,
|
||||
workspaceId: fields.id,
|
||||
workspaceKey,
|
||||
pinnedAt: fields.pinnedAt,
|
||||
});
|
||||
return true;
|
||||
}, [canPin, fields, serverId, togglePin]);
|
||||
|
||||
useKeyboardActionHandler({
|
||||
handlerId: "workspace-pin-global",
|
||||
actions: WORKSPACE_PIN_ACTIONS,
|
||||
enabled: serverId !== null && fields !== null && canPin,
|
||||
priority: 0,
|
||||
handle,
|
||||
});
|
||||
}
|
||||
@@ -1,22 +1,33 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
|
||||
export type ToggleSidebarWorkspacePin = (workspace: SidebarWorkspaceEntry) => void;
|
||||
// Everything the pin toggle actually needs. Kept narrower than SidebarWorkspaceEntry so the
|
||||
// global keyboard handler can build one from the active route selection without a sidebar row.
|
||||
export type PinnableWorkspace = Pick<
|
||||
SidebarWorkspaceEntry,
|
||||
"serverId" | "workspaceId" | "workspaceKey" | "pinnedAt"
|
||||
>;
|
||||
|
||||
export type ToggleSidebarWorkspacePin = (workspace: PinnableWorkspace) => void;
|
||||
|
||||
// Module scope, not a per-hook ref: the sidebar row menus and the global keyboard shortcut each
|
||||
// hold their own controller instance, and a per-instance guard would let a keypress and a menu
|
||||
// click fire two concurrent, opposite setWorkspacePinned calls for the same workspace.
|
||||
const pendingWorkspaceKeys = new Set<string>();
|
||||
|
||||
export function useSidebarWorkspacePinController(): ToggleSidebarWorkspacePin {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const pendingWorkspaceKeysRef = useRef(new Set<string>());
|
||||
const mutation = useMutation({
|
||||
mutationFn: async ({
|
||||
workspace,
|
||||
pinned,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
workspace: PinnableWorkspace;
|
||||
pinned: boolean;
|
||||
}) => {
|
||||
const client = getHostRuntimeStore().getClient(workspace.serverId);
|
||||
@@ -31,17 +42,17 @@ export function useSidebarWorkspacePinController(): ToggleSidebarWorkspacePin {
|
||||
);
|
||||
},
|
||||
onSettled: (_data, _error, { workspace }) => {
|
||||
pendingWorkspaceKeysRef.current.delete(workspace.workspaceKey);
|
||||
pendingWorkspaceKeys.delete(workspace.workspaceKey);
|
||||
},
|
||||
});
|
||||
const mutate = mutation.mutate;
|
||||
|
||||
return useCallback(
|
||||
(workspace: SidebarWorkspaceEntry) => {
|
||||
if (pendingWorkspaceKeysRef.current.has(workspace.workspaceKey)) {
|
||||
(workspace: PinnableWorkspace) => {
|
||||
if (pendingWorkspaceKeys.has(workspace.workspaceKey)) {
|
||||
return;
|
||||
}
|
||||
pendingWorkspaceKeysRef.current.add(workspace.workspaceKey);
|
||||
pendingWorkspaceKeys.add(workspace.workspaceKey);
|
||||
mutate({ workspace, pinned: workspace.pinnedAt == null });
|
||||
},
|
||||
[mutate],
|
||||
|
||||
Reference in New Issue
Block a user