Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
289 changes: 149 additions & 140 deletions apps/web/src/components/AppSidebarLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,12 @@ import { useAtomValue } from "@effect/atom-react";
import * as Schema from "effect/Schema";
import {
useEffect,
useRef,
useState,
useSyncExternalStore,
type CSSProperties,
type ReactNode,
} from "react";
import { useLocation, useNavigate } from "@tanstack/react-router";
import { useLocation, useRouter, type AnyRouter } from "@tanstack/react-router";

import { isCommandPaletteOpen } from "../commandPaletteBus";
import { isElectron } from "../env";
Expand Down Expand Up @@ -62,7 +61,9 @@ import {
useSidebarVisibility,
} from "./ui/sidebar";
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
import { useThreadRouteRef } from "../hooks/useThreadRouteTarget";
import { appAtomRegistry } from "../rpc/atomRegistry";
import { registerCommands, registerMenuAction } from "../shortcuts/dispatcher";
import { resolveThreadRouteRef } from "../threadRoutes";

const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "var(--desktop-window-controls-inset, 90px)";

Expand Down Expand Up @@ -124,7 +125,144 @@ function readInitialThreadSidebarWidth(): number {
}
}

function readUsagePageOpen(router: AnyRouter) {
return router.state.location.pathname === "/usage";
}

function isKeybindingCaptureTarget(event: KeyboardEvent) {
return event.target instanceof HTMLElement && event.target.closest("[data-keybinding-capture]");
}

function readRoutePanelsOpen(router: AnyRouter) {
const routeThreadRef = resolveThreadRouteRef(router.state.matches.at(-1)?.params ?? {});
if (!routeThreadRef) return { terminalOpen: false, previewOpen: false };
return {
terminalOpen: selectThreadTerminalUiState(
useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
routeThreadRef,
).terminalOpen,
previewOpen:
selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, routeThreadRef) ===
"preview",
};
}

export function registerNavigationHistoryCommands(router: AnyRouter): () => void {
return registerCommands(
{
"navigation.back": (event) => {
event.preventDefault();
event.stopPropagation();
window.history.back();
},
"navigation.forward": (event) => {
event.preventDefault();
event.stopPropagation();
window.history.forward();
},
},
{
when: (event) => !isKeybindingCaptureTarget(event),
context: () => ({
...readRoutePanelsOpen(router),
usagePageOpen: readUsagePageOpen(router),
}),
},
);
}

const MODIFIER_KEYS = new Set(["Alt", "Control", "Meta", "Shift"]);

// A held key undoes once. Module state: undoing an archive navigates, and the
// hold must survive whatever that re-renders.
let heldUndoKey: string | null = null;

// Undoes the thread actions in the sidebar's notice. Settings and Usage own
// mod+z while open. Captured so mod+z reaches it before the composer's own
// undo handler consumes the key.
function registerThreadUndoCommands(
router: AnyRouter,
readSidebarVisible: () => boolean,
): () => void {
const threadSidebarShown = () => !isAppModalPage(router.state.location.pathname);
const unregister = registerCommands(
{},
{
capture: true,
when: threadSidebarShown,
onKeyDown: (event) => {
// Cancel a held key's repeats so they cannot fall through to the
// editor's own undo, even after the last notice is gone.
if (event.repeat) {
if (event.code !== heldUndoKey) return false;
event.preventDefault();
event.stopPropagation();
return true;
}
// A modifier tapped mid-hold leaves the held key repeating.
if (!MODIFIER_KEYS.has(event.key)) heldUndoKey = null;
const command = resolveShortcutCommand(
event,
appAtomRegistry.get(primaryServerKeybindingsAtom),
{
context: {
terminalFocus: isTerminalFocused(),
previewFocus: isPreviewFocused(),
...readRoutePanelsOpen(router),
// A collapsed sidebar hides the notice, so text fields keep their own undo.
editableFocus:
isEditableFocused(event.target) &&
!(readSidebarVisible() && threadUndoClaimsShortcut()),
modelPickerOpen: isModelPickerOpen(),
},
},
);
if (command !== "thread.undo") {
if (!MODIFIER_KEYS.has(event.key)) releaseThreadUndoShortcut();
return false;
}
if (isCommandPaletteOpen() || isModelPickerOpen()) return false;
if (!undoLatestThreadAction()) return false;
heldUndoKey = event.code;
event.preventDefault();
event.stopPropagation();
return true;
},
},
);
// Pastes from a menu, dictation and drops edit text without a keypress.
const onInput = () => {
if (threadSidebarShown()) releaseThreadUndoShortcut();
};
window.addEventListener("input", onInput, true);
return () => {
unregister();
window.removeEventListener("input", onInput, true);
};
}

/** Keyboard and menu commands that live as long as the app shell. */
function registerAppShellCommands(router: AnyRouter, shellElement: Element): () => void {
// The provider mirrors the visibility it renders onto its wrapper, so the
// committed state is readable when a key arrives.
const readSidebarVisible = () =>
shellElement.closest('[data-slot="sidebar-wrapper"]')?.getAttribute("data-sidebar-state") ===
"expanded";
const disposers = [
registerThreadUndoCommands(router, readSidebarVisible),
registerNavigationHistoryCommands(router),
registerMenuAction("open-settings", () => {
if (!/^\/settings(\/|$)/.test(router.state.location.pathname))
void router.navigate({ to: "/settings" });
}),
];
return () => {
for (const dispose of disposers) dispose();
};
}

function SidebarControl() {
const router = useRouter();
const usagePageOpen = useLocation({ select: (location) => location.pathname === "/usage" });
const keybindings = useAtomValue(primaryServerKeybindingsAtom);
const { toggleSidebar, isMobile, mobileSheetMounted } = useSidebar();
Expand Down Expand Up @@ -176,8 +314,16 @@ function SidebarControl() {
};
}, [keybindings, toggleSidebar, usagePageOpen]);

// The control lives as long as the shell, so it carries the shell's commands. Created
// once so re-renders never re-register them.
const [appShellCommandsRef] = useState(
() => (node: HTMLDivElement | null) =>
node === null ? undefined : registerAppShellCommands(router, node),
);

return (
<div
ref={appShellCommandsRef}
className={cn(
"pointer-events-none fixed left-[var(--workspace-controls-left)] top-[var(--workspace-controls-top)] z-50 ml-px flex h-[var(--workspace-topbar-height)] items-center",
isMobile && mobileSheetMounted && "hidden",
Expand Down Expand Up @@ -205,121 +351,7 @@ function SidebarControl() {
);
}

function routePanelsOpen(routeThreadRef: ReturnType<typeof useThreadRouteRef>) {
if (!routeThreadRef) return { terminalOpen: false, previewOpen: false };
return {
terminalOpen: selectThreadTerminalUiState(
useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
routeThreadRef,
).terminalOpen,
previewOpen:
selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, routeThreadRef) ===
"preview",
};
}

export function NavigationHistoryShortcuts() {
const usagePageOpen = useLocation({ select: (location) => location.pathname === "/usage" });
const keybindings = useAtomValue(primaryServerKeybindingsAtom);
const routeThreadRef = useThreadRouteRef();

useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented) return;
if (
event.target instanceof HTMLElement &&
event.target.closest("[data-keybinding-capture]")
) {
return;
}
const command = resolveShortcutCommand(event, keybindings, {
context: {
terminalFocus: isTerminalFocused(),
previewFocus: isPreviewFocused(),
...routePanelsOpen(routeThreadRef),
editableFocus: isEditableFocused(event.target),
modelPickerOpen: isModelPickerOpen(),
usagePageOpen,
},
});
if (command !== "navigation.back" && command !== "navigation.forward") return;

event.preventDefault();
event.stopPropagation();
if (command === "navigation.back") window.history.back();
else window.history.forward();
};

window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [keybindings, routeThreadRef, usagePageOpen]);

return null;
}

const MODIFIER_KEYS = new Set(["Alt", "Control", "Meta", "Shift"]);

// Undoes the thread actions in the sidebar's notice. Settings and Usage own
// mod+z while open. Listens in the capture phase
// so mod+z reaches it before the composer's own undo handler consumes the key.
function ThreadUndoShortcut() {
const keybindings = useAtomValue(primaryServerKeybindingsAtom);
const routeThreadRef = useThreadRouteRef();
// A collapsed sidebar hides the notice, so text fields keep their own undo.
const noticeVisible = useSidebarVisibility();
// Outlives the effect: undoing an archive navigates, which re-runs it mid-hold.
const heldUndoKey = useRef<string | null>(null);

useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
// A held key undoes once. Cancel its repeats so they cannot fall through
// to the editor's own undo, even after the last notice is gone.
if (event.repeat) {
if (event.code === heldUndoKey.current) {
event.preventDefault();
event.stopPropagation();
}
return;
}
// A modifier tapped mid-hold leaves the held key repeating.
if (!MODIFIER_KEYS.has(event.key)) heldUndoKey.current = null;
const command = resolveShortcutCommand(event, keybindings, {
context: {
terminalFocus: isTerminalFocused(),
previewFocus: isPreviewFocused(),
...routePanelsOpen(routeThreadRef),
editableFocus:
isEditableFocused(event.target) && !(noticeVisible && threadUndoClaimsShortcut()),
modelPickerOpen: isModelPickerOpen(),
},
});
if (command !== "thread.undo") {
if (!MODIFIER_KEYS.has(event.key)) releaseThreadUndoShortcut();
return;
}
if (isCommandPaletteOpen() || isModelPickerOpen()) return;
if (undoLatestThreadAction()) {
heldUndoKey.current = event.code;
event.preventDefault();
event.stopPropagation();
}
};
// Pastes from a menu, dictation and drops edit text without a keypress.
const onInput = () => releaseThreadUndoShortcut();

window.addEventListener("keydown", onKeyDown, true);
window.addEventListener("input", onInput, true);
return () => {
window.removeEventListener("keydown", onKeyDown, true);
window.removeEventListener("input", onInput, true);
};
}, [keybindings, noticeVisible, routeThreadRef]);

return null;
}

export function AppSidebarLayout({ children }: { children: ReactNode }) {
const navigate = useNavigate();
const legacySidebarEnabled = useLegacySidebarEnabled();
const [sidebarRouteReveal] = useState<SidebarRouteReveal>(() => ({
observedKey: null,
Expand All @@ -331,7 +363,6 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
const pathname = useLocation({ select: (location) => location.pathname });
const panelAnimationsSuppressed = usePanelNavigationSuppression(pathname);
const routePanelAnimationsActive = panelAnimationsActive && !panelAnimationsSuppressed;
const appModalOpen = isAppModalPage(pathname);
const isMacosDesktop = isElectron && isMacPlatform(navigator.platform);
const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth);
// A clamped drag may not change width, so viewport changes need a separate subscription.
Expand Down Expand Up @@ -362,26 +393,6 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
...windowControlsInsetStyle,
} as CSSProperties;

useEffect(() => {
const onMenuAction = window.desktopBridge?.onMenuAction;
if (typeof onMenuAction !== "function") {
return;
}

const unsubscribe = onMenuAction((action) => {
if (action === "open-settings") {
const isSettingsRoute = /^\/settings(\/|$)/.test(pathname);
if (!isSettingsRoute) {
void navigate({ to: "/settings" });
}
}
});

return () => {
unsubscribe?.();
};
}, [navigate, pathname]);

return (
<PanelAnimationSuppressionProvider value={panelAnimationsSuppressed}>
<SidebarProvider
Expand Down Expand Up @@ -418,8 +429,6 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
{children}
<BackgroundThreadAnnouncer />
<SidebarControl />
<NavigationHistoryShortcuts />
{appModalOpen ? null : <ThreadUndoShortcut />}
<MainAppLocationTracker />
</SidebarProvider>
</PanelAnimationSuppressionProvider>
Expand Down
16 changes: 14 additions & 2 deletions apps/web/src/components/UsageToggleShortcut.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,32 @@ vi.mock(import("@effect/atom-react"), async (original) => ({
...(await original()),
useAtomValue: (() => keybindings) as never,
}));
vi.mock(import("../state/server"), async (original) => {
const actual = await original();
const { Atom } = await import("effect/reactivity");
const { compileResolvedKeybindingsConfig: compile } = await import("@t3tools/shared/keybindings");
// Factories are hoisted above the module's own constants.
const compiled = compile([
{ key: "mod+u", command: "navigation.back", when: "usagePageOpen" },
{ key: "mod+u", command: "usage.open", when: "!usagePageOpen" },
]);
return { ...actual, primaryServerKeybindingsAtom: Atom.make(compiled) };
});
vi.mock(import("@tanstack/react-router"), async (original) => ({
...(await original()),
useNavigate: (() => state.navigate) as never,
}));

import { NavigationHistoryShortcuts } from "./AppSidebarLayout";
import { registerNavigationHistoryCommands } from "./AppSidebarLayout";
import { CommandPalette } from "./CommandPalette";

// regression: #21357
describe("usage toggle shortcut on Usage", () => {
it("goes back", async () => {
vi.clearAllMocks();
using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux x86_64");
await using _screen = await render(<NavigationHistoryShortcuts />, { router: "/usage" });
await using screen = await render(null, { router: "/usage" });
using _commands = { [Symbol.dispose]: registerNavigationHistoryCommands(screen.router!) };

using back = vi.spyOn(window.history, "back").mockImplementation(() => undefined);
await userEvent.keyboard("{Control>}u{/Control}");
Expand Down
Loading
Loading