diff --git a/apps/web/src/components/AppSidebarLayout.browser.test.tsx b/apps/web/src/components/AppSidebarLayout.browser.test.tsx new file mode 100644 index 0000000000000..acaa79957bce4 --- /dev/null +++ b/apps/web/src/components/AppSidebarLayout.browser.test.tsx @@ -0,0 +1,103 @@ +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { expect, it, vi } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; + +import { render } from "../../test/render"; +import { + clearComposerDraftsEnvironment, + DraftId, + useComposerDraftStore, +} from "../composerDraftStore"; +import { primaryServerKeybindingsAtom } from "../state/server"; +import { AppSidebarLayout } from "./AppSidebarLayout"; + +const environmentId = EnvironmentId.make("shortcut-environment"); +const projectId = ProjectId.make("shortcut-project"); +const projects = [ + { + environmentId, + id: projectId, + title: "Shortcut project", + defaultModelSelection: null, + defaultThreadEnvMode: "local" as const, + workspaceRoot: "/workspace/shortcut-project", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + scripts: [], + }, +]; + +// Keep draft creation and routing real; the sidebar's thread list is unrelated to shortcuts. +vi.mock("./Sidebar", () => ({ default: () => null })); +vi.mock("./LegacySidebar", () => ({ default: () => null })); +vi.mock(import("../state/entities"), async (original) => ({ + ...(await original()), + useProjects: () => projects, + readProjects: () => projects, + readProject: () => projects[0] ?? null, + readThreadShells: () => [], + useQuietThreadShell: () => null, +})); +vi.mock(import("@effect/atom-react"), async (original) => { + const actual = await original(); + return { + ...actual, + useAtomValue: ((atom: Parameters[0]) => + atom === primaryServerKeybindingsAtom + ? DEFAULT_RESOLVED_KEYBINDINGS + : actual.useAtomValue(atom)) as typeof actual.useAtomValue, + }; +}); +vi.mock("../lib/t3ProjectFileDefaults", () => ({ readT3ProjectFile: async () => null })); + +it.each([ + ["/usage", "Linux x86_64", "{Control>}{Shift>}n{/Shift}{/Control}"], + ["/usage", "MacIntel", "{Meta>}{Shift>}n{/Shift}{/Meta}"], + ["/usage", "MacIntel", "{Meta>}{Shift>}o{/Shift}{/Meta}"], + ["/usage", "Linux x86_64", "{Control>}{Shift>}o{/Shift}{/Control}"], + ["/settings/general", "Linux x86_64", "{Control>}{Shift>}n{/Shift}{/Control}"], + ["/projects/shortcut-project", "Linux x86_64", "{Control>}{Shift>}n{/Shift}{/Control}"], +])("opens a project draft from %s on %s with %s", async (path, platform, shortcut) => { + clearComposerDraftsEnvironment(environmentId); + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue(platform); + await using screen = await render({null}, { router: path }); + + await userEvent.keyboard(shortcut); + + await vi.waitFor(() => expect(screen.router?.state.location.pathname).toMatch(/^\/draft\//)); + const draftId = screen.router!.state.location.pathname.slice("/draft/".length); + expect(useComposerDraftStore.getState().getDraftSession(DraftId.make(draftId))).toMatchObject({ + environmentId, + projectId, + }); +}); + +it.each(["keybinding capture", "terminal", "command palette"])( + "leaves new-thread shortcuts alone during %s", + async (context) => { + clearComposerDraftsEnvironment(environmentId); + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux x86_64"); + await using screen = await render( + + + , + { router: "/usage" }, + ); + await screen.getByRole("textbox", { name: "Focused control" }).click(); + + await userEvent.keyboard("{Control>}{Shift>}n{/Shift}{/Control}"); + + expect(screen.router?.state.location.pathname).toBe("/usage"); + expect( + Object.values(useComposerDraftStore.getState().draftThreadsByThreadKey).filter( + (draft) => draft.environmentId === environmentId, + ), + ).toEqual([]); + }, +); diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 03f1903ae4b99..1cc4f4cf01f12 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -10,7 +10,7 @@ import { } from "react"; import { useLocation, useNavigate } from "@tanstack/react-router"; -import { isCommandPaletteOpen } from "../commandPaletteBus"; +import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; import { isElectron } from "../env"; import { releaseThreadUndoShortcut, @@ -46,6 +46,13 @@ import { MainAppLocationTracker } from "./sidebar/mainAppLocation"; import type { SidebarRouteReveal } from "./sidebar/routeReveal"; import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; import { useProjects } from "../state/entities"; +import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { useScratchProject } from "../hooks/useScratchProject"; +import { useClientSettings } from "../hooks/useSettings"; +import { startNewThreadFromContext } from "../lib/chatThreadActions"; +import { selectProjectGroupingSettings } from "../logicalProject"; +import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; +import { usePrimaryEnvironmentId } from "../state/environments"; import { clampThreadSidebarWidth, resolveInitialThreadSidebarWidth, @@ -220,6 +227,104 @@ function routePanelsOpen(routeThreadRef: ReturnType) { }; } +function NewThreadShortcuts() { + const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } = + useHandleNewThread(); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const legacySidebarEnabled = useLegacySidebarEnabled(); + const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); + const projects = useProjects(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const { scratchEnvironmentId, startScratchThread } = useScratchProject(); + const usagePageOpen = useLocation({ select: (location) => location.pathname === "/usage" }); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || isCommandPaletteOpen()) 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 === "chat.newLocal") { + event.preventDefault(); + event.stopPropagation(); + void startNewThreadFromContext({ + activeDraftThread, + activeThread: activeThread ?? undefined, + defaultProjectRef, + handleNewThread, + }); + return; + } + + if (command === "chat.newWithoutProject") { + const environmentId = scratchEnvironmentId( + activeThread?.environmentId ?? activeDraftThread?.environmentId ?? primaryEnvironmentId, + ); + if (environmentId === null) return; + event.preventDefault(); + event.stopPropagation(); + void startScratchThread(environmentId); + return; + } + + if (command === "chat.new") { + event.preventDefault(); + event.stopPropagation(); + const projectGroupCount = buildSidebarProjectSnapshots({ + projects, + settings: projectGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: () => null, + }).length; + if (!legacySidebarEnabled && projectGroupCount > 1) { + openCommandPalette({ open: "new-thread-in" }); + return; + } + void startNewThreadFromContext({ + activeDraftThread, + activeThread: activeThread ?? undefined, + defaultProjectRef, + handleNewThread, + }); + return; + } + }; + + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [ + activeDraftThread, + activeThread, + defaultProjectRef, + handleNewThread, + keybindings, + legacySidebarEnabled, + primaryEnvironmentId, + projectGroupingSettings, + projects, + routeThreadRef, + scratchEnvironmentId, + startScratchThread, + usagePageOpen, + ]); + + return null; +} + export function NavigationHistoryShortcuts() { const usagePageOpen = useLocation({ select: (location) => location.pathname === "/usage" }); const keybindings = useAtomValue(primaryServerKeybindingsAtom); @@ -436,6 +541,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { {children} + {isOnSettings ? null : } diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index de99486f0f4a2..832b0380e1131 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -6,18 +6,9 @@ import { useEffect } from "react"; import { isCommandPaletteOpen } from "../commandPaletteBus"; import { ThreadRouteView } from "../components/ThreadRouteView"; import { ThreadLinkPrompt } from "../components/promptLinks/ThreadLinkPrompt"; -import { useClientSettings, useLegacySidebarEnabled } from "../hooks/useSettings"; -import { openCommandPalette } from "../commandPaletteBus"; -import { useProjects } from "../state/entities"; import { isPreviewAvailableFor } from "../browser/previewRuntime"; -import { usePrimaryEnvironmentId } from "../state/environments"; import { useEnvironmentScope } from "../state/session"; -import { selectProjectGroupingSettings } from "../logicalProject"; -import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; import { dispatchPreviewAction } from "../components/preview/previewActionBus"; -import { useHandleNewThread } from "../hooks/useHandleNewThread"; -import { useScratchProject } from "../hooks/useScratchProject"; -import { startNewThreadFromContext } from "../lib/chatThreadActions"; import { isPreviewFocused } from "../lib/previewFocus"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isEditableFocused } from "../lib/editableFocus"; @@ -28,29 +19,17 @@ import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { stackedThreadToast, toastManager } from "~/components/ui/toast"; import { primaryServerKeybindingsAtom } from "~/state/server"; -import { useThreadRouteTarget } from "../hooks/useThreadRouteTarget"; +import { useThreadRouteRef, useThreadRouteTarget } from "../hooks/useThreadRouteTarget"; function ChatRouteGlobalShortcuts() { const clearSelection = useThreadSelectionStore((state) => state.clearSelection); const selectedThreadKeysSize = useThreadSelectionStore((state) => state.selectedThreadKeys.size); - const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } = - useHandleNewThread(); + const routeThreadRef = useThreadRouteRef(); const canOperatePreview = useEnvironmentScope( routeThreadRef?.environmentId ?? null, AuthPreviewOperateScope, ); const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const legacySidebarEnabled = useLegacySidebarEnabled(); - const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); - const projects = useProjects(); - const primaryEnvironmentId = usePrimaryEnvironmentId(); - const { scratchEnvironmentId, startScratchThread } = useScratchProject(); - const projectGroupCount = buildSidebarProjectSnapshots({ - projects, - settings: projectGroupingSettings, - primaryEnvironmentId, - resolveEnvironmentLabel: () => null, - }).length; const terminalOpen = useTerminalUiStateStore((state) => routeThreadRef ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen @@ -88,45 +67,6 @@ function ChatRouteGlobalShortcuts() { return; } - if (command === "chat.newLocal") { - event.preventDefault(); - event.stopPropagation(); - void startNewThreadFromContext({ - activeDraftThread, - activeThread: activeThread ?? undefined, - defaultProjectRef, - handleNewThread, - }); - return; - } - - if (command === "chat.newWithoutProject") { - const environmentId = scratchEnvironmentId( - activeThread?.environmentId ?? activeDraftThread?.environmentId ?? primaryEnvironmentId, - ); - if (environmentId === null) return; - event.preventDefault(); - event.stopPropagation(); - void startScratchThread(environmentId); - return; - } - - if (command === "chat.new") { - event.preventDefault(); - event.stopPropagation(); - if (!legacySidebarEnabled && projectGroupCount > 1) { - openCommandPalette({ open: "new-thread-in" }); - return; - } - void startNewThreadFromContext({ - activeDraftThread, - activeThread: activeThread ?? undefined, - defaultProjectRef, - handleNewThread, - }); - return; - } - if (command === "preview.toggle") { event.preventDefault(); event.stopPropagation(); @@ -174,21 +114,12 @@ function ChatRouteGlobalShortcuts() { window.removeEventListener("keydown", onWindowKeyDown); }; }, [ - activeDraftThread, - activeThread, clearSelection, canOperatePreview, - handleNewThread, keybindings, - defaultProjectRef, previewOpen, - primaryEnvironmentId, - projectGroupCount, routeThreadRef, - scratchEnvironmentId, selectedThreadKeysSize, - startScratchThread, - legacySidebarEnabled, terminalOpen, ]);