From 86965e5a1dff70dceb7df152942f69c8e0a6cae1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 4 Nov 2026 21:48:38 +0000 Subject: [PATCH 1/4] test(web): reproduce #31153 jump shortcuts in filtered New thread in... These tests reproduce #31153: after typing in the New thread in... picker, the shown rows keep their unfiltered jump numbers, so the first shown row is not labelled or reached by jump 1. Co-Authored-By: Claude Opus 5.5 --- .../CommandPalette.browser.test.tsx | 70 ++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/CommandPalette.browser.test.tsx b/apps/web/src/components/CommandPalette.browser.test.tsx index 9faeab83c6ffc..c1500de123cbc 100644 --- a/apps/web/src/components/CommandPalette.browser.test.tsx +++ b/apps/web/src/components/CommandPalette.browser.test.tsx @@ -1,6 +1,6 @@ import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; -import { page } from "vite-plus/test/browser"; +import { page, userEvent } from "vite-plus/test/browser"; import { render } from "../../test/render"; import { openCommandPalette } from "../commandPaletteBus"; @@ -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", () => { @@ -91,4 +131,32 @@ describe("CommandPalette open intents", () => { await vi.waitFor(() => expect(screen.router?.state.location.pathname).toBe("/local/parent")); }); + + it("numbers the projects New thread in... shows after filtering from 1", async () => { + 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/ })).toHaveTextContent("Ctrl+1"); + }); + + it("starts a thread in the first project New thread in... shows on jump 1", async () => { + 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 userEvent.keyboard("{Control>}1{/Control}"); + + await vi.waitFor(() => + expect(entities.handleNewThread).toHaveBeenCalledWith({ + environmentId: EnvironmentId.make("local"), + projectId: ProjectId.make("lima"), + }), + ); + }); }); From 3399eb5ff07c0d263d99527b92394a2935fdb4e6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 4 Nov 2026 21:55:14 +0000 Subject: [PATCH 2/4] fix(web): project picker jump shortcuts follow the filtered rows The New thread in... picker numbered its project rows in unfiltered opening order, so after typing, the first shown row kept its old number (or none) and jump 1 ran nothing. The rows are now numbered after filtering, in the order shown, still skipping No project and Add project. Root search rows built from the submenu no longer carry jump numbers. The reproduction test asserted the label with toHaveTextContent, which is an exact match in vitest 5; it now uses toMatchTextContent. Co-Authored-By: Claude Opus 5.5 --- .../CommandPalette.browser.test.tsx | 2 +- apps/web/src/components/CommandPalette.tsx | 28 +++++++++++-------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/CommandPalette.browser.test.tsx b/apps/web/src/components/CommandPalette.browser.test.tsx index c1500de123cbc..63e812dcbac2b 100644 --- a/apps/web/src/components/CommandPalette.browser.test.tsx +++ b/apps/web/src/components/CommandPalette.browser.test.tsx @@ -140,7 +140,7 @@ describe("CommandPalette open intents", () => { 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/ })).toHaveTextContent("Ctrl+1"); + await expect.element(page.getByRole("option", { name: /lima/ })).toMatchTextContent("Ctrl+1"); }); it("starts a thread in the first project New thread in... shows on jump 1", async () => { diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index f14a483d5332a..52ad2fdaea99d 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -814,7 +814,7 @@ function OpenCommandPaletteDialog(props: { true, ); - const openingOrderThreadItems = useOrderWhileOpen( + const projectThreadItems = useOrderWhileOpen( (() => { const isScratch = (project: CommandPaletteProject) => isScratchProject(project, scratchWorkspaceRootFor(project.environmentId)); @@ -868,15 +868,6 @@ 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. - const projectJumpItems = new Map( - enumerateCommandPaletteItems( - openingOrderThreadItems.filter((item) => item.value !== NEW_THREAD_NO_PROJECT_VALUE), - ).map((item) => [item.value, item] as const), - ); - const projectThreadItems = openingOrderThreadItems.map( - (item) => projectJumpItems.get(item.value) ?? item, - ); const { threadItems: allThreadItems, @@ -1717,7 +1708,7 @@ function OpenCommandPaletteDialog(props: { const isLinkedThreadSearch = linkedThreadSearch?.linkedThreads !== undefined && deferredQuery === linkedThreadSearch.query; - const filteredGroups = filterCommandPaletteGroups({ + const searchedGroups = filterCommandPaletteGroups({ activeGroups, query: deferredQuery, isInSubmenu: currentView !== null, @@ -1755,6 +1746,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; From eb36b4d30b28b884e4229b8a54321a328dcb850f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 4 Nov 2026 22:05:51 +0000 Subject: [PATCH 3/4] fix(web): keep root-search project suggestions' jump shortcuts Root search "New thread in X" suggestions keep the jump numbers they had on main; only the picker's filtered rows are renumbered. The new tests pin the platform so their Ctrl labels and keys hold on macOS too. Co-Authored-By: Claude Opus 5.5 --- .../src/components/CommandPalette.browser.test.tsx | 6 ++++-- apps/web/src/components/CommandPalette.tsx | 11 ++++++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/CommandPalette.browser.test.tsx b/apps/web/src/components/CommandPalette.browser.test.tsx index 63e812dcbac2b..6ba65400c94ef 100644 --- a/apps/web/src/components/CommandPalette.browser.test.tsx +++ b/apps/web/src/components/CommandPalette.browser.test.tsx @@ -132,7 +132,8 @@ describe("CommandPalette open intents", () => { await vi.waitFor(() => expect(screen.router?.state.location.pathname).toBe("/local/parent")); }); - it("numbers the projects New thread in... shows after filtering from 1", async () => { + it("numbers filtered New thread in... rows from 1", async () => { + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux x86_64"); setup({ projects: [makeProject("server"), makeProject("lima")] }); await using _screen = await render({null}); @@ -143,7 +144,8 @@ describe("CommandPalette open intents", () => { await expect.element(page.getByRole("option", { name: /lima/ })).toMatchTextContent("Ctrl+1"); }); - it("starts a thread in the first project New thread in... shows on jump 1", async () => { + it("jump 1 starts a thread in the first filtered New thread in... row", async () => { + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux x86_64"); setup({ projects: [makeProject("server"), makeProject("lima")] }); await using _screen = await render({null}); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 52ad2fdaea99d..b3cf470177df3 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -814,7 +814,7 @@ function OpenCommandPaletteDialog(props: { true, ); - const projectThreadItems = useOrderWhileOpen( + const openingOrderThreadItems = useOrderWhileOpen( (() => { const isScratch = (project: CommandPaletteProject) => isScratchProject(project, scratchWorkspaceRootFor(project.environmentId)); @@ -868,6 +868,15 @@ function OpenCommandPaletteDialog(props: { rowOrderKey("new-thread-in"), true, ); + // 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), + ).map((item) => [item.value, item] as const), + ); + const projectThreadItems = openingOrderThreadItems.map( + (item) => projectJumpItems.get(item.value) ?? item, + ); const { threadItems: allThreadItems, From 1e35f8ea7ba9dc54fa50bca8e09f6fdb9ba05031 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 23:03:56 +0000 Subject: [PATCH 4/4] test(web): cover the filtered project picker's label and jump in one case AI-assisted; verified with the tests and tools listed above. --- .../src/components/CommandPalette.browser.test.tsx | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/apps/web/src/components/CommandPalette.browser.test.tsx b/apps/web/src/components/CommandPalette.browser.test.tsx index 5410ca1d64b64..9d44d16e366d8 100644 --- a/apps/web/src/components/CommandPalette.browser.test.tsx +++ b/apps/web/src/components/CommandPalette.browser.test.tsx @@ -215,26 +215,15 @@ 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", async () => { + 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"); - }); - - it("jump 1 starts a thread in the first filtered New thread in... row", 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 userEvent.keyboard("{Control>}1{/Control}"); await vi.waitFor(() =>