diff --git a/apps/web/src/components/CommandPalette.browser.test.tsx b/apps/web/src/components/CommandPalette.browser.test.tsx index c7106f0cff289..9d44d16e366d8 100644 --- a/apps/web/src/components/CommandPalette.browser.test.tsx +++ b/apps/web/src/components/CommandPalette.browser.test.tsx @@ -12,7 +12,32 @@ import { CommandPalette } from "./CommandPalette"; const entities = vi.hoisted(() => ({ projects: [] as ReadonlyArray, threads: [] as ReadonlyArray, + handleNewThread: vi.fn(async () => undefined), })); +vi.mock("../state/server", async (original) => { + const actual = await original(); + const { Atom } = await import("effect/reactivity"); + const { compileResolvedKeybindingsConfig } = await import("@t3tools/shared/keybindings"); + const { THREAD_JUMP_KEYBINDING_COMMANDS } = await import("@t3tools/contracts"); + // The default jump bindings are desktop-only; these hold in the browser too. + const jumpBindings = compileResolvedKeybindingsConfig( + THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({ + key: `mod+${index + 1}`, + command, + })), + ); + return { ...actual, primaryServerKeybindingsAtom: Atom.make(jumpBindings) }; +}); +vi.mock("../hooks/useHandleNewThread", async (original) => { + const actual = await original(); + return { + ...actual, + useHandleNewThread: () => ({ + ...actual.useHandleNewThread(), + handleNewThread: entities.handleNewThread, + }), + }; +}); vi.mock("../state/entities", async (original) => ({ ...(await original()), useProjects: () => entities.projects, @@ -23,6 +48,21 @@ function setup(state: { projects?: ReadonlyArray; threads?: ReadonlyArr useCommandPaletteStore.setState(useCommandPaletteStore.getInitialState(), true); entities.projects = state.projects ?? []; entities.threads = state.threads ?? []; + entities.handleNewThread.mockClear(); +} + +function makeProject(title: string): Project { + return { + id: ProjectId.make(title), + environmentId: EnvironmentId.make("local"), + title, + workspaceRoot: `/work/${title}`, + repositoryIdentity: null, + defaultModelSelection: null, + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + scripts: [], + }; } describe("CommandPalette open intents", () => { @@ -174,4 +214,23 @@ describe("CommandPalette open intents", () => { await vi.waitFor(() => expect(screen.router?.state.location.pathname).toBe("/local/parent")); }); + + it("numbers filtered New thread in... rows from 1 and jumps to them", async () => { + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux x86_64"); + setup({ projects: [makeProject("server"), makeProject("lima")] }); + await using _screen = await render({null}); + + openCommandPalette({ open: "new-thread-in" }); + await userEvent.fill(page.getByPlaceholder("Search..."), "li"); + await expect.element(page.getByRole("option", { name: /server/ })).not.toBeInTheDocument(); + await expect.element(page.getByRole("option", { name: /lima/ })).toMatchTextContent("Ctrl+1"); + await userEvent.keyboard("{Control>}1{/Control}"); + + await vi.waitFor(() => + expect(entities.handleNewThread).toHaveBeenCalledWith({ + environmentId: EnvironmentId.make("local"), + projectId: ProjectId.make("lima"), + }), + ); + }); }); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index a73322fc65114..1c8c31b1866ef 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -888,7 +888,7 @@ function OpenCommandPaletteDialog(props: { rowOrderKey("new-thread-in"), true, ); - // Jump shortcuts follow the rows as shown, so a pin cannot move a number to another row. + // Root search suggestions copy these numbers; the picker renumbers its shown rows below. const projectJumpItems = new Map( enumerateCommandPaletteItems( openingOrderThreadItems.filter((item) => item.value !== NEW_THREAD_NO_PROJECT_VALUE), @@ -1780,7 +1780,7 @@ function OpenCommandPaletteDialog(props: { const isLinkedThreadSearch = linkedThreadSearch?.linkedThreads !== undefined && deferredQuery === linkedThreadSearch.query; - const filteredGroups = filterCommandPaletteGroups({ + const searchedGroups = filterCommandPaletteGroups({ activeGroups, query: deferredQuery, isInSubmenu: currentView !== null, @@ -1818,6 +1818,21 @@ function OpenCommandPaletteDialog(props: { threadMessageItems: isLinkedThreadSearch ? [] : threadMessageItems, now: searchNow, }); + // Jump shortcuts number the project rows as shown, so a pin cannot move a number to another row. + const filteredGroups = searchedGroups.map((group) => { + if (group.value !== NEW_THREAD_IN_GROUP) return group; + const jumpItems = new Map( + enumerateCommandPaletteItems( + group.items.filter( + (item): item is CommandPaletteActionItem => + item.kind === "action" && + item.value !== NEW_THREAD_NO_PROJECT_VALUE && + item.value !== addProjectAction.value, + ), + ).map((item) => [item.value, item] as const), + ); + return { ...group, items: group.items.map((item) => jumpItems.get(item.value) ?? item) }; + }); const handleAddProjectForEnvironment = async (input: { readonly environmentId: EnvironmentId;