diff --git a/apps/app/src/components/commands/AppCommandShortcutHint.tsx b/apps/app/src/components/commands/AppCommandShortcutHint.tsx index e82b45a84e..0efa771011 100644 --- a/apps/app/src/components/commands/AppCommandShortcutHint.tsx +++ b/apps/app/src/components/commands/AppCommandShortcutHint.tsx @@ -13,7 +13,7 @@ interface AppCommandShortcutPillProps { className?: string; } -const APP_COMMAND_SHORTCUT_HINT_CLASS = +export const APP_COMMAND_ACCESSORY_PILL_CLASS = "pointer-events-none inline-flex shrink-0 items-center justify-center whitespace-nowrap rounded-sm bg-state-hover px-1.5 py-1 font-sans text-xs font-normal leading-none tabular-nums text-subtle-foreground opacity-60"; export function AppCommandShortcutPill({ @@ -24,7 +24,7 @@ export function AppCommandShortcutPill({ return ( {shortcut.label} diff --git a/apps/app/src/components/commands/CommandPalette.test.tsx b/apps/app/src/components/commands/CommandPalette.test.tsx index f0419f7aa1..996a7c46ea 100644 --- a/apps/app/src/components/commands/CommandPalette.test.tsx +++ b/apps/app/src/components/commands/CommandPalette.test.tsx @@ -6,6 +6,7 @@ import { render, screen, waitFor, + within, } from "@testing-library/react"; import { MemoryRouter, useLocation } from "react-router-dom"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -14,14 +15,22 @@ import { type AppCommandId, type AppDefaultKeybinding, type AppKeybinding, + type ThreadListEntry, } from "@bb/domain"; -import { CompactViewportOverrideProvider } from "@bb/shared-ui/hooks/use-compact-viewport"; +import { emptyPromptDraftState } from "@bb/client-core"; +import type { ThreadSearchResponse } from "@bb/server-contract"; +import { CHROME_SECTION_LABEL_CLASS } from "@bb/shared-ui/chrome-style-tokens"; import { AppCommandProvider, useAppCommandHandler } from "./AppCommandProvider"; import { removePluginSlotRegistrations, setPluginSlotRegistrations, } from "@/lib/plugin-slots"; +import { + resetPluginLogoStoreForTest, + setPluginLogoUrls, +} from "@/lib/plugin-logos"; import { CommandPalette } from "./CommandPalette"; +import type { PaletteNewThreadDraft } from "@/lib/command-palette/palette-thread-search"; const PALETTE_SHORTCUT = { key: "p", @@ -41,30 +50,30 @@ const PALETTE_BINDING: AppKeybinding = { when: { all: ["mainSurface"], none: ["modalOpen"] }, }; -const THREAD_SEARCH_BINDING: AppKeybinding = { - command: "thread.search", +const THREAD_NEW_BINDING: AppKeybinding = { + command: "thread.new", desktopOnly: false, shortcut: { - key: "k", + key: "o", mod: true, meta: false, control: false, alt: false, - shift: false, + shift: true, }, when: { all: ["mainSurface"], none: ["modalOpen"] }, }; -const THREAD_NEW_BINDING: AppKeybinding = { - command: "thread.new", +const THREAD_SEARCH_BINDING: AppKeybinding = { + command: "thread.search", desktopOnly: false, shortcut: { - key: "o", + key: "k", mod: true, meta: false, control: false, alt: false, - shift: true, + shift: false, }, when: { all: ["mainSurface"], none: ["modalOpen"] }, }; @@ -89,6 +98,55 @@ const testState = vi.hoisted(() => ({ name: string | null; }>, })); +const modeState = vi.hoisted(() => ({ + activeRecents: [] as ThreadListEntry[], + archivedRecents: [] as ThreadListEntry[], + drafts: [] as PaletteNewThreadDraft[], + searchResponse: undefined as ThreadSearchResponse | undefined, +})); +const openPaneContentInSplitMock = vi.hoisted(() => vi.fn()); +const openThreadInSplitMock = vi.hoisted(() => vi.fn()); +const routeNavigateMock = vi.hoisted(() => vi.fn()); + +function expectClasses( + element: Element | null | undefined, + ...classNames: string[] +): void { + expect(element).toBeTruthy(); + for (const className of classNames) { + expect(element?.classList.contains(className)).toBe(true); + } +} + +function expectNoClasses( + element: Element | null | undefined, + ...classNames: string[] +): void { + expect(element).toBeTruthy(); + for (const className of classNames) { + expect(element?.classList.contains(className)).toBe(false); + } +} + +function expectText( + element: Element | null | undefined, + text: string, +): void { + expect(element?.textContent).toContain(text); +} + +function expectAttribute( + element: Element | null | undefined, + name: string, + value?: string, +): void { + expect(element).toBeTruthy(); + if (value === undefined) { + expect(element?.hasAttribute(name)).toBe(true); + } else { + expect(element?.getAttribute(name)).toBe(value); + } +} vi.mock("@/hooks/queries/system-queries", () => ({ useSystemConfig: () => ({ @@ -97,7 +155,7 @@ vi.mock("@/hooks/queries/system-queries", () => ({ ...defaultAppSettings, showKeyboardHints: false, }, - keybindings: [PALETTE_BINDING, THREAD_SEARCH_BINDING, THREAD_NEW_BINDING], + keybindings: [PALETTE_BINDING, THREAD_NEW_BINDING, THREAD_SEARCH_BINDING], defaultKeybindings: [ PALETTE_BINDING, THREAD_SEARCH_BINDING, @@ -131,47 +189,73 @@ vi.mock("@/lib/app-query-client", () => ({ }, })); -vi.mock("./ThreadPaletteResults", () => ({ - ThreadPaletteResults: ({ - onSelect, - query, - }: { - onSelect: (item: { - id: string; - optionId: string; - projectId: string; - threadId: string; - messageSeq: number | null; - }) => void; - query: string; - }) => ( - - ), +vi.mock("@bb/shared-ui/hooks/use-compact-viewport", () => ({ + useIsCompactViewport: () => false, })); -function LocationProbe() { - const location = useLocation(); - return ( - - {JSON.stringify({ pathname: location.pathname, state: location.state })} - - ); -} +vi.mock("@/lib/split-layout/openThreadInSplit", () => ({ + openThreadInSplit: openThreadInSplitMock, +})); + +vi.mock("@/lib/split-layout/openPaneContentInSplit", () => ({ + openPaneContentInSplit: openPaneContentInSplitMock, +})); + +vi.mock("@/components/ui/app-route-anchor", () => ({ + useRouteNavigate: () => routeNavigateMock, +})); + +vi.mock("@/hooks/usePromptDraftStorage", () => ({ + usePromptDraftStorage: () => { + const draft = modeState.drafts[0]?.draft ?? emptyPromptDraftState(); + return { + text: draft.text, + mentions: draft.mentions, + attachments: draft.attachments, + }; + }, +})); + +vi.mock("@/lib/root-compose-selection", () => ({ + useRootComposeProjectId: () => ["project-1", vi.fn()], +})); + +vi.mock("@/hooks/queries/sidebar-navigation-query", () => ({ + useSidebarNavigation: () => ({ + data: { + projects: [ + { + id: "project-1", + name: "Palette project", + threads: modeState.activeRecents, + }, + ], + personalProject: { id: "proj_personal", name: "Personal", threads: [] }, + }, + isLoading: false, + }), +})); + +vi.mock("@/hooks/queries/thread-queries", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + useArchivedThreads: () => ({ + data: { pages: [modeState.archivedRecents] }, + isLoading: false, + }), + useThreadSearch: ({ query }: { query: string }) => ({ + data: modeState.searchResponse, + debouncedQuery: query.trim(), + hasSearchableQuery: query.trim().length >= 2, + isDebouncing: false, + isError: false, + isFetching: false, + isLoading: false, + }), + }; +}); function Handler({ command }: { command: AppCommandId }) { useAppCommandHandler(command, () => { @@ -181,7 +265,56 @@ function Handler({ command }: { command: AppCommandId }) { return null; } -function renderPalette(isCompactViewport = false) { +function LocationProbe() { + const location = useLocation(); + return {location.pathname}; +} + +function makeThread( + id: string, + overrides: Partial = {}, +): ThreadListEntry { + return { + id, + projectId: "project-1", + environmentId: null, + providerId: "codex", + title: `Title ${id}`, + titleFallback: `Title ${id}`, + sectionId: null, + status: "idle", + parentThreadId: null, + sourceThreadId: null, + originKind: null, + originPluginId: null, + visibility: "visible", + archivedAt: null, + pinnedAt: null, + pinSortKey: null, + deletedAt: null, + lastReadAt: null, + latestAttentionAt: 1, + createdAt: 1, + updatedAt: Date.now(), + activity: { + activeWorkflowCount: 0, + activeBackgroundAgentCount: 0, + activeBackgroundCommandCount: 0, + activePlanModeCount: 0, + activeGoalCount: 0, + }, + hasPendingInteraction: false, + environmentHostId: null, + environmentName: null, + environmentBranchName: null, + environmentWorkspaceDisplayKind: "other", + runtime: { displayStatus: "idle", hostReconnectGraceExpiresAt: null }, + queuedWork: "none", + ...overrides, + }; +} + +function renderPalette({ onSplit }: { onSplit?: () => void } = {}) { const result = render( @@ -189,20 +322,14 @@ function renderPalette(isCompactViewport = false) { origin + - + , - { - wrapper: ({ children }) => ( - - {children} - - ), - }, ); screen.getByTestId("origin").focus(); return result; @@ -232,8 +359,11 @@ function openThreadSearch(): KeyboardEvent { } const searchField = () => screen.getByRole("combobox"); +const commandList = () => screen.getByRole("listbox", { name: "Commands" }); +const bucketGroup = (name: string) => + within(commandList()).getByRole("group", { name }); const optionTitles = () => - screen.queryAllByRole("option").map((option) => option.textContent); + screen.getAllByRole("option").map((option) => option.textContent); const selectedOption = () => screen .getAllByRole("option") @@ -243,9 +373,17 @@ afterEach(() => { cleanup(); removePluginSlotRegistrations("linear"); removePluginSlotRegistrations("automations"); + resetPluginLogoStoreForTest(); testState.calls.length = 0; testState.filesAvailable = false; testState.plugins.length = 0; + modeState.activeRecents = []; + modeState.archivedRecents = []; + modeState.drafts = []; + modeState.searchResponse = undefined; + openPaneContentInSplitMock.mockReset(); + openThreadInSplitMock.mockReset(); + routeNavigateMock.mockReset(); window.localStorage.clear(); }); @@ -255,10 +393,651 @@ describe("CommandPalette", () => { const event = openPalette(); await waitFor(() => expect(searchField()).toBeTruthy()); expect(event.defaultPrevented).toBe(true); - expect((searchField() as HTMLInputElement).value).toBe(">"); const titles = optionTitles(); expect(titles?.[0]).toContain("New thread"); - expect(titles).toHaveLength(16); + expect(titles).toEqual( + expect.arrayContaining([ + expect.stringContaining("Search threads"), + expect.stringContaining("General settings"), + expect.stringContaining("Open terminal"), + ]), + ); + expect(titles.length).toBeGreaterThan(5); + }); + + it("groups resting commands, hides empty plugins, and distinguishes drill-in rows", async () => { + renderPalette(); + openPalette(); + await waitFor(() => expect(searchField()).toBeTruthy()); + + const groups = within(commandList()).getAllByRole("group"); + expect( + groups.map((group) => group.getAttribute("data-palette-bucket")), + ).toEqual(["Threads", "Actions"]); + expect( + within(commandList()).queryByRole("group", { name: "Plugins" }), + ).toBeNull(); + for (const [index, label] of ["Threads", "Actions"].entries()) { + const header = within(groups[index] as HTMLElement).getByText(label, { + selector: "div", + }); + for (const className of CHROME_SECTION_LABEL_CLASS.split(" ")) { + expect(header.classList.contains(className)).toBe(true); + } + expectClasses(header, "px-3", "pb-1", "pt-3"); + expectNoClasses(header, "bg-muted/30"); + } + expectClasses(commandList(), "p-2"); + expectClasses(commandList().parentElement, "overflow-hidden"); + expectClasses( + screen.getByTestId("command-palette"), + "max-w-[640px]", + "overflow-hidden", + "shadow-lg", + "sm:rounded-xl", + ); + expectClasses( + searchField().closest("[data-palette-input-frame]"), + "h-10", + "px-3", + ); + expectNoClasses( + searchField().closest("[data-palette-input-frame]"), + "border", + "bg-command-palette-search", + "rounded-md", + "shadow-xs", + ); + expectClasses( + searchField().closest("[data-palette-input-band]"), + "border-b", + "bg-background", + "px-3", + "py-2", + ); + expectClasses( + searchField(), + "placeholder:text-subtle-foreground", + "placeholder:font-light", + "placeholder:opacity-70", + ); + expectClasses(commandList().parentElement, "bg-background"); + expect( + commandList().querySelectorAll("[data-palette-scroll-sentinel]"), + ).toHaveLength(2); + + const rootFooter = screen + .getByTestId("command-palette") + .querySelector("[data-palette-footer]"); + expectText(rootFooter, "Select"); + expectText(rootFooter, "Run"); + expect(rootFooter?.textContent).not.toContain("Open"); + + const threadRows = within(bucketGroup("Threads")).getAllByRole("option"); + expect(threadRows.map((row) => row.textContent)).toEqual([ + expect.stringContaining("New thread"), + expect.stringContaining("Search threads"), + expect.stringContaining("Next thread"), + ]); + for (const row of threadRows) { + expect(within(row).queryByText("Threads")).toBeNull(); + } + const searchThreadsRow = threadRows[1] as HTMLElement; + expect(searchThreadsRow.querySelector("kbd")).not.toBeNull(); + expectAttribute( + searchThreadsRow, + "data-palette-action-kind", + "drill-in", + ); + expectText(searchThreadsRow, "Search threads…"); + expect( + searchThreadsRow.querySelector('[data-icon="ChevronRight"]'), + ).toBeNull(); + expect(searchThreadsRow.textContent).toContain("Opens a search view"); + + const actionRows = within(bucketGroup("Actions")).getAllByRole("option"); + expect(actionRows[0]?.textContent).toContain("Window and layout"); + expect(actionRows[1]?.textContent).toContain("Workspace"); + for (const row of [...threadRows, ...actionRows]) { + expect(row.classList.contains("px-3")).toBe(true); + } + expect(commandList().querySelector("[data-icon]")).toBeNull(); + expectClasses(threadRows[0], "bg-state-hover", "text-foreground"); + expectAttribute( + actionRows[0], + "data-palette-action-kind", + "terminal", + ); + expect( + actionRows[0]?.querySelector('[data-icon="ChevronRight"]'), + ).toBeNull(); + + fireEvent.keyDown(searchField(), { key: "ArrowDown" }); + expectClasses(searchThreadsRow, "bg-state-hover", "text-foreground"); + }); + + it("enters the registered thread mode from its existing command and pops one level per Escape", async () => { + renderPalette(); + const event = openThreadSearch(); + await waitFor(() => + expect( + screen.getByRole("combobox", { name: "Search threads" }), + ).toBeTruthy(), + ); + expect(event.defaultPrevented).toBe(true); + const modeSelect = screen.getByRole("button", { name: "Threads search" }); + expectAttribute(modeSelect, "aria-pressed", "true"); + expect(modeSelect.querySelector('[data-icon="Search"]')).not.toBeNull(); + expectClasses(modeSelect.parentElement, "border-border/70", "bg-background/70"); + expectAttribute( + screen.getByRole("button", { name: "Return to commands" }), + "data-tab-pill-close", + ); + const scope = screen.getByRole("button", { name: "Thread scope" }); + expect(scope.textContent).toContain("All"); + expectClasses(scope, "text-subtle-foreground", "opacity-70"); + expectText(screen.getByTestId("command-palette"), "Split"); + const footer = screen + .getByTestId("command-palette") + .querySelector("[data-palette-footer]"); + expectClasses( + footer, + "flex-wrap", + "bg-surface-recessed-soft-solid", + "px-4", + "py-2", + ); + for (const keycap of footer?.querySelectorAll("kbd") ?? []) { + expectClasses( + keycap, + "rounded", + "border-border/70", + "bg-background/70", + "font-mono", + "text-muted-foreground", + "shadow-xs", + ); + } + for (const label of + footer?.querySelectorAll("[data-palette-footer-label]") ?? []) { + expectClasses(label, "opacity-70"); + expectClasses( + label.closest("[data-palette-footer]"), + "text-subtle-foreground", + ); + } + expectText(footer, "Backspace"); + expectText(footer, "Esc"); + + fireEvent.keyDown(screen.getByRole("combobox"), { key: "Escape" }); + await waitFor(() => + expect( + screen.getByRole("combobox", { name: "Search commands" }), + ).toBeTruthy(), + ); + expect(screen.queryByRole("button", { name: "Thread scope" })).toBeNull(); + + fireEvent.keyDown(screen.getByRole("combobox"), { key: "Escape" }); + await waitFor(() => expect(screen.queryByRole("combobox")).toBeNull()); + }); + + it("uses the shared tab-pill clear affordance without running the mode command", async () => { + renderPalette(); + openThreadSearch(); + await waitFor(() => + expect( + screen.getByRole("combobox", { name: "Search threads" }), + ).toBeTruthy(), + ); + + const clearMode = screen.getByRole("button", { + name: "Return to commands", + }); + expect(clearMode.querySelector('[data-icon="X"]')).not.toBeNull(); + expectClasses( + clearMode, + "opacity-0", + "group-hover/tab-pill:opacity-100", + "focus-visible:opacity-100", + ); + fireEvent.click(clearMode); + await waitFor(() => + expect( + screen.getByRole("combobox", { name: "Search commands" }), + ).toBeTruthy(), + ); + expect(testState.calls).toEqual([]); + + const searchCommand = within(bucketGroup("Threads")) + .getAllByRole("option") + .find((row) => row.textContent?.includes("Search threads")); + fireEvent.click(searchCommand as HTMLElement); + const clearAfterCommand = await screen.findByRole("button", { + name: "Return to commands", + }); + fireEvent.click(clearAfterCommand); + await waitFor(() => + expect( + screen.getByRole("combobox", { name: "Search commands" }), + ).toBeTruthy(), + ); + expect(testState.calls).toEqual([]); + }); + + it("enters the same registered mode by running Search threads from the root", async () => { + renderPalette(); + openPalette(); + await waitFor(() => expect(searchField()).toBeTruthy()); + + const searchCommand = within(bucketGroup("Threads")) + .getAllByRole("option") + .find((row) => row.textContent?.includes("Search threads")); + expect(searchCommand).toBeDefined(); + fireEvent.click(searchCommand as HTMLElement); + + await waitFor(() => + expect( + screen.getByRole("combobox", { name: "Search threads" }), + ).toBeTruthy(), + ); + expect(testState.calls).toEqual([]); + }); + + it("returns from an empty thread query with Backspace", async () => { + renderPalette(); + openThreadSearch(); + const input = await screen.findByRole("combobox", { + name: "Search threads", + }); + + fireEvent.keyDown(input, { key: "Backspace" }); + + await waitFor(() => + expect( + screen.getByRole("combobox", { name: "Search commands" }), + ).toBeTruthy(), + ); + expect(testState.calls).toEqual([]); + }); + + it("cycles the thread scope and resets it after leaving the mode", async () => { + renderPalette(); + openThreadSearch(); + await waitFor(() => + expect(screen.getByRole("button", { name: "Thread scope" })).toBeTruthy(), + ); + const scope = screen.getByRole("button", { name: "Thread scope" }); + scope.focus(); + fireEvent.keyDown(scope, { key: "ArrowDown" }); + expect(scope.textContent).toContain("Active"); + expect( + screen.getByRole("listbox", { name: "Thread scope options" }), + ).toBeTruthy(); + fireEvent.keyDown(scope, { key: "Escape" }); + expect(document.activeElement).toBe(screen.getByRole("combobox")); + + fireEvent.keyDown(screen.getByRole("combobox"), { key: "Escape" }); + await waitFor(() => + expect( + screen.getByRole("combobox", { name: "Search commands" }), + ).toBeTruthy(), + ); + const searchCommand = within(bucketGroup("Threads")) + .getAllByRole("option") + .find((row) => row.textContent?.includes("Search threads")); + fireEvent.click(searchCommand as HTMLElement); + await waitFor(() => + expect( + screen.getByRole("button", { name: "Thread scope" }).textContent, + ).toContain("All"), + ); + }); + + it("opens the closed thread scope with Enter and returns to the input on the next Enter", async () => { + renderPalette(); + openThreadSearch(); + const input = await screen.findByRole("combobox", { + name: "Search threads", + }); + const scope = screen.getByRole("button", { name: "Thread scope" }); + + scope.focus(); + fireEvent.keyDown(scope, { key: "Enter" }); + + expect(scope.getAttribute("aria-expanded")).toBe("true"); + expect( + screen.getByRole("listbox", { name: "Thread scope options" }), + ).toBeTruthy(); + expect(document.activeElement).toBe(scope); + + fireEvent.keyDown(scope, { key: "Enter" }); + + expect(scope.getAttribute("aria-expanded")).toBe("false"); + expect( + screen.queryByRole("listbox", { name: "Thread scope options" }), + ).toBeNull(); + expect(document.activeElement).toBe(input); + }); + + it("keeps the mode clear, input, and scope in a predictable tab sequence and applies every keyboard choice immediately", async () => { + modeState.searchResponse = { + active: { + total: 1, + results: [{ thread: makeThread("matching-active"), matches: [] }], + }, + archived: { + total: 1, + results: [ + { + thread: makeThread("matching-archived", { + archivedAt: Date.now(), + }), + matches: [], + }, + ], + }, + }; + modeState.drafts = [ + { + id: "matching-draft", + title: "matching draft", + draft: { ...emptyPromptDraftState(), text: "matching draft" }, + lastEditedAt: Date.now(), + destination: { projectId: "project-1", sectionId: null }, + }, + ]; + renderPalette(); + openThreadSearch(); + await waitFor(() => + expect( + screen.getByRole("combobox", { name: "Search threads" }), + ).toBeTruthy(), + ); + const input = screen.getByRole("combobox", { name: "Search threads" }); + const scope = screen.getByRole("button", { name: "Thread scope" }); + expect(scope.querySelector("[data-icon]")).toBeNull(); + const modeSelect = screen.getByRole("button", { name: "Threads search" }); + const clearMode = screen.getByRole("button", { + name: "Return to commands", + }); + const palette = screen.getByTestId("command-palette"); + expect( + Array.from( + palette.querySelectorAll( + 'input:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])', + ), + ), + ).toEqual([modeSelect, clearMode, input, scope]); + + fireEvent.change(input, { target: { value: "match" } }); + const results = screen.getByRole("listbox", { name: "Threads" }); + await waitFor(() => + expect(within(results).getAllByRole("option")).toHaveLength(3), + ); + + scope.focus(); + fireEvent.keyDown(scope, { key: "ArrowDown" }); + expect(scope.textContent).toContain("Active"); + const scopeOptions = screen.getByRole("listbox", { + name: "Thread scope options", + }); + expect( + within(scopeOptions) + .getAllByRole("option") + .map((option) => option.textContent), + ).toEqual(["All", "Active", "Drafts", "Archived"]); + expect(scopeOptions.querySelector("[data-icon]")).toBeNull(); + expect(within(results).getAllByRole("option")).toHaveLength(1); + expect(within(results).getByRole("option").textContent).toContain( + "matching-active", + ); + fireEvent.keyDown(scope, { key: "Enter" }); + expect(document.activeElement).toBe(input); + expect( + screen.queryByRole("listbox", { name: "Thread scope options" }), + ).toBeNull(); + + scope.focus(); + fireEvent.keyDown(scope, { key: "ArrowDown" }); + expect(scope.textContent).toContain("Drafts"); + expect(within(results).getAllByRole("option")).toHaveLength(1); + expect(within(results).getByRole("option").textContent).toContain( + "matching draft", + ); + fireEvent.keyDown(scope, { key: "Escape" }); + expect(document.activeElement).toBe(input); + + fireEvent.click(within(results).getByRole("option")); + await waitFor(() => expect(screen.queryByRole("combobox")).toBeNull()); + openThreadSearch(); + await waitFor(() => + expect( + screen.getByRole("button", { name: "Thread scope" }).textContent, + ).toContain("All"), + ); + }); + + it("renders the resting thread mode as one unlabelled active, draft, archived list", async () => { + modeState.activeRecents = [makeThread("recent-active")]; + modeState.archivedRecents = [ + makeThread("recent-archived", { archivedAt: Date.now() }), + ]; + modeState.drafts = [ + { + id: "recent-draft", + title: "recent draft", + draft: { ...emptyPromptDraftState(), text: "recent draft" }, + lastEditedAt: Date.now(), + destination: { projectId: "project-1", sectionId: null }, + }, + ]; + renderPalette(); + openThreadSearch(); + await waitFor(() => + expect( + screen.getByRole("combobox", { name: "Search threads" }), + ).toBeTruthy(), + ); + + const results = screen.getByRole("listbox", { name: "Threads" }); + await waitFor(() => + expect(within(results).getAllByRole("option")).toHaveLength(3), + ); + const rows = within(results).getAllByRole("option"); + expect(rows[0]?.textContent).toContain("Title recent-active"); + expect(rows[0]?.textContent).not.toContain("Active"); + expect(rows[1]?.textContent).toContain("recent draft"); + expect(rows[1]?.textContent).toContain("Draft"); + expect(rows[2]?.textContent).toContain("Title recent-archived"); + expect(rows[2]?.textContent).toContain("Archived"); + expect(results.querySelector("[data-palette-thread-state]")).toBeNull(); + expect(results.querySelector("[data-icon]")).toBeNull(); + for (const row of rows) { + expectClasses( + row.querySelector("[data-palette-thread-metadata]"), + "text-subtle-foreground", + "opacity-70", + ); + } + expectClasses( + within(rows[1] as HTMLElement).getByText("Draft"), + "text-subtle-foreground", + ); + expectClasses( + within(rows[2] as HTMLElement).getByText("Archived"), + "text-subtle-foreground", + ); + expect(within(results).queryAllByRole("group")).toHaveLength(0); + expect(within(results).queryByText("Recent")).toBeNull(); + }); + + it("renders search matches as one unlabelled active, draft, archived list", async () => { + const active = makeThread("active", { + title: "Matching active thread", + titleFallback: "Matching active thread", + }); + const archived = makeThread("archived", { archivedAt: Date.now() }); + modeState.searchResponse = { + active: { + total: 1, + results: [ + { + thread: active, + matches: [ + { + sourceKind: "title", + text: "Matching active thread", + highlightRanges: [{ start: 0, end: 8 }], + sourceSeq: null, + }, + ], + }, + ], + }, + archived: { total: 1, results: [{ thread: archived, matches: [] }] }, + }; + modeState.drafts = [ + { + id: "draft-1", + title: "matching draft", + draft: { ...emptyPromptDraftState(), text: "matching draft" }, + lastEditedAt: Date.now(), + destination: { projectId: "project-1", sectionId: null }, + }, + ]; + renderPalette(); + openThreadSearch(); + await waitFor(() => + expect( + screen.getByRole("combobox", { name: "Search threads" }), + ).toBeTruthy(), + ); + fireEvent.change(screen.getByRole("combobox"), { + target: { value: "match" }, + }); + + const results = screen.getByRole("listbox", { name: "Threads" }); + await waitFor(() => + expect(within(results).getAllByRole("option")).toHaveLength(3), + ); + const rows = within(results).getAllByRole("option"); + expect(rows[0]?.textContent).toContain("Matching active thread"); + expect(rows[1]?.textContent).toContain("matching draft"); + expect(rows[1]?.textContent).toContain("Draft"); + expect(rows[2]?.textContent).toContain("Title archived"); + expect(rows[2]?.textContent).toContain("Archived"); + expect(rows[0]?.textContent).not.toContain("Active"); + const activeMatch = rows[0]?.querySelector("mark"); + expectText(activeMatch, "Matching"); + expectClasses( + activeMatch, + "bg-[var(--sidebar-search-match)]", + "text-foreground", + ); + expectClasses(activeMatch?.parentElement, "text-foreground"); + for (const row of rows) { + expectClasses( + row.querySelector("[data-palette-thread-metadata]"), + "text-subtle-foreground", + "opacity-70", + ); + } + expect(results.querySelector("[data-palette-thread-state]")).toBeNull(); + expect(within(results).queryAllByRole("group")).toHaveLength(0); + expect(within(results).queryByText("Recent")).toBeNull(); + expect(results.textContent).not.toContain("1/1"); + expect(results.querySelector("svg")).toBeNull(); + }); + + it("opens a persisted thread result in a split with Command-Enter", async () => { + modeState.searchResponse = { + active: { + total: 1, + results: [{ thread: makeThread("matching-split"), matches: [] }], + }, + archived: { total: 0, results: [] }, + }; + renderPalette(); + openThreadSearch(); + const input = await screen.findByRole("combobox", { + name: "Search threads", + }); + fireEvent.change(input, { target: { value: "match" } }); + await waitFor(() => + expect(screen.getByRole("option").textContent).toContain( + "matching-split", + ), + ); + + fireEvent.keyDown(input, { key: "Enter", metaKey: true }); + + await waitFor(() => expect(openThreadInSplitMock).toHaveBeenCalledTimes(1)); + expect(openThreadInSplitMock).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: "project-1", + threadId: "matching-split", + }), + ); + }); + + it("opens the current new-thread draft in a split", async () => { + modeState.drafts = [ + { + id: "draft-slot-exact", + title: "split this draft", + draft: { ...emptyPromptDraftState(), text: "split this draft" }, + lastEditedAt: Date.now(), + destination: { projectId: "project-1", sectionId: null }, + }, + ]; + renderPalette(); + openThreadSearch(); + const input = await screen.findByRole("combobox", { + name: "Search threads", + }); + await screen.findByRole("option", { name: /split this draft/i }); + + fireEvent.keyDown(input, { key: "Enter", metaKey: true }); + + await waitFor(() => + expect(openPaneContentInSplitMock).toHaveBeenCalledTimes(1), + ); + expect(openPaneContentInSplitMock).toHaveBeenCalledWith( + expect.objectContaining({ + content: { kind: "new-thread" }, + enabled: true, + }), + ); + expect(routeNavigateMock).not.toHaveBeenCalled(); + }); + + it("keeps ordinary Enter on the current new-thread draft as normal navigation", async () => { + modeState.drafts = [ + { + id: "draft-slot-normal", + title: "open this draft", + draft: { ...emptyPromptDraftState(), text: "open this draft" }, + lastEditedAt: Date.now(), + destination: { projectId: "project-1", sectionId: null }, + }, + ]; + renderPalette(); + openThreadSearch(); + const input = await screen.findByRole("combobox", { + name: "Search threads", + }); + await screen.findByRole("option", { name: /open this draft/i }); + + fireEvent.keyDown(input, { key: "Enter" }); + + await waitFor(() => expect(routeNavigateMock).toHaveBeenCalledTimes(1)); + expect(openPaneContentInSplitMock).not.toHaveBeenCalled(); + expect(routeNavigateMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + state: expect.objectContaining({ focusPrompt: true }), + }), + ); }); it("filters as the user types and keeps the selection on a live row", async () => { @@ -268,23 +1047,25 @@ describe("CommandPalette", () => { fireEvent.keyDown(searchField(), { key: "ArrowDown" }); fireEvent.keyDown(searchField(), { key: "ArrowDown" }); - fireEvent.change(searchField(), { target: { value: ">terminal" } }); + fireEvent.change(searchField(), { target: { value: "terminal" } }); await waitFor(() => expect(optionTitles()).toHaveLength(1)); expect(selectedOption()?.textContent).toContain("Open terminal"); + expect(selectedOption()?.textContent).toContain("Workspace"); + expect(within(commandList()).queryAllByRole("group")).toHaveLength(0); }); it("wraps at both ends of the list", async () => { renderPalette(); openPalette(); await waitFor(() => expect(searchField()).toBeTruthy()); - const titles = optionTitles(); + const lastTitle = optionTitles().at(-1); fireEvent.keyDown(searchField(), { key: "ArrowUp" }); - expect(selectedOption()?.textContent).toBe(titles.at(-1)); + expect(selectedOption()?.textContent).toBe(lastTitle); fireEvent.keyDown(searchField(), { key: "ArrowDown" }); - expect(selectedOption()?.textContent).toBe(titles[0]); + expect(selectedOption()?.textContent).toContain("New thread"); }); it("runs the highlighted command, closes, and restores focus", async () => { @@ -292,7 +1073,7 @@ describe("CommandPalette", () => { openPalette(); await waitFor(() => expect(searchField()).toBeTruthy()); - fireEvent.change(searchField(), { target: { value: ">toggle panel" } }); + fireEvent.change(searchField(), { target: { value: "toggle panel" } }); await waitFor(() => expect(selectedOption()?.textContent).toContain("Toggle panel"), ); @@ -303,23 +1084,32 @@ describe("CommandPalette", () => { expect(document.activeElement).toBe(screen.getByTestId("origin")); }); - it("runs a compact selection once after restoring focus", async () => { - renderPalette(true); + it("runs Split as an internal palette action without an app command", async () => { + const onSplit = vi.fn(); + renderPalette({ onSplit }); openPalette(); await waitFor(() => expect(searchField()).toBeTruthy()); - fireEvent.change(searchField(), { target: { value: ">toggle panel" } }); + const splitRow = within(bucketGroup("Actions")) + .getAllByRole("option") + .find((row) => row.textContent?.includes("Split")); + expect(splitRow?.textContent).toContain("Window and layout"); + + fireEvent.change(searchField(), { target: { value: "split" } }); + await waitFor(() => + expect(selectedOption()?.textContent).toContain("Split"), + ); fireEvent.keyDown(searchField(), { key: "Enter" }); - await waitFor(() => expect(testState.calls).toEqual(["panel.toggle"])); - expect(document.activeElement).toBe(screen.getByTestId("origin")); + await waitFor(() => expect(onSplit).toHaveBeenCalledOnce()); + expect(testState.calls).toEqual([]); }); - it("offers the last command run first the next time it opens", async () => { + it("offers the last command run first within its resting bucket", async () => { renderPalette(); openPalette(); await waitFor(() => expect(searchField()).toBeTruthy()); - fireEvent.change(searchField(), { target: { value: ">toggle panel" } }); + fireEvent.change(searchField(), { target: { value: "toggle panel" } }); await waitFor(() => expect(selectedOption()?.textContent).toContain("Toggle panel"), ); @@ -328,7 +1118,8 @@ describe("CommandPalette", () => { openPalette(); await waitFor(() => expect(searchField()).toBeTruthy()); - expect(optionTitles()?.[0]).toContain("Toggle panel"); + const actionRows = within(bucketGroup("Actions")).getAllByRole("option"); + expect(actionRows[0]?.textContent).toContain("Toggle panel"); }); it("closes on Escape without running anything", async () => { @@ -389,134 +1180,43 @@ describe("CommandPalette", () => { scrollIntoView.mockRestore(); }); - it("lists a plugin's commandPaletteAction and runs it", async () => { - setPluginSlotRegistrations("linear", { - homepageSections: [], - settingsSections: [], - navPanels: [], - threadPanelActions: [], - sidebarFooterActions: [], - fileOpeners: [], - messageDirectives: [], - commandPaletteActions: [ - { - id: "open-issue", - title: "Linear: open issue", - run: () => { - testState.calls.push("plugin-ran"); - }, - }, - ], - }); - renderPalette(); - openPalette(); - await waitFor(() => expect(searchField()).toBeTruthy()); - - fireEvent.change(searchField(), { target: { value: ">linear" } }); - await waitFor(() => expect(optionTitles()).toHaveLength(1)); - expect(optionTitles()?.[0]).toContain("Linear: open issue"); - fireEvent.keyDown(searchField(), { key: "Enter" }); - - await waitFor(() => expect(testState.calls).toEqual(["plugin-ran"])); - }); - - it("says so when nothing matches", async () => { + it("opens a specific settings page", async () => { renderPalette(); openPalette(); await waitFor(() => expect(searchField()).toBeTruthy()); - fireEvent.change(searchField(), { target: { value: ">zzzzz" } }); - + fireEvent.change(searchField(), { + target: { value: "keyboard settings" }, + }); await waitFor(() => - expect(screen.getByText("No matching commands")).toBeTruthy(), + expect(selectedOption()?.textContent).toContain("Keyboard settings"), ); fireEvent.keyDown(searchField(), { key: "Enter" }); - expect(testState.calls).toEqual([]); - }); - - it("opens thread search without a prefix", async () => { - renderPalette(); - const event = openThreadSearch(); await waitFor(() => - expect( - screen.getByRole("combobox", { name: "Search threads" }), - ).toBeTruthy(), - ); - expect(event.defaultPrevented).toBe(true); - expect((searchField() as HTMLInputElement).value).toBe(""); - expect(screen.getByRole("listbox").getAttribute("aria-label")).toBe( - "Thread search results", + expect(screen.getByTestId("location").textContent).toBe( + "/settings/keyboard", + ), ); }); - it("switches the open command palette to thread search", async () => { + it("only includes Files settings when local helper access is available", async () => { renderPalette(); openPalette(); await waitFor(() => expect(searchField()).toBeTruthy()); - fireEvent.change(searchField(), { target: { value: ">search threads" } }); - await waitFor(() => - expect(selectedOption()?.textContent).toContain("Search threads"), - ); - fireEvent.keyDown(searchField(), { key: "Enter" }); - - expect( - ( - screen.getByRole("combobox", { - name: "Search threads", - }) as HTMLInputElement - ).value, - ).toBe(""); - }); - - it("opens a specific settings page from Cmd-K", async () => { - renderPalette(); - openThreadSearch(); - await waitFor(() => expect(searchField()).toBeTruthy()); - fireEvent.change(searchField(), { - target: { value: ">keyboard settings" }, + target: { value: "files settings" }, }); - await waitFor(() => - expect(selectedOption()?.textContent).toContain("Keyboard"), - ); - fireEvent.keyDown(searchField(), { key: "Enter" }); - - await waitFor(() => - expect(screen.getByTestId("location").textContent).toContain( - "/settings/keyboard", - ), - ); - }); + await waitFor(() => expect(screen.queryAllByRole("option")).toHaveLength(0)); - it.each([false, true])( - "excludes Files settings when Settings has no local opener (compact: %s)", - async (compact) => { - renderPalette(compact); - openThreadSearch(); - await waitFor(() => expect(searchField()).toBeTruthy()); - - fireEvent.change(searchField(), { - target: { value: ">files settings" }, - }); - - await waitFor(() => - expect(optionTitles()).not.toContainEqual( - expect.stringContaining("Files settings"), - ), - ); - }, - ); - - it("keeps Files settings when local helper access can be enabled", async () => { + fireEvent.keyDown(searchField(), { key: "Escape" }); + await waitFor(() => expect(screen.queryByRole("combobox")).toBeNull()); testState.filesAvailable = true; - renderPalette(); - openThreadSearch(); + openPalette(); await waitFor(() => expect(searchField()).toBeTruthy()); - fireEvent.change(searchField(), { - target: { value: ">files settings" }, + target: { value: "files settings" }, }); await waitFor(() => @@ -524,7 +1224,7 @@ describe("CommandPalette", () => { ); }); - it("opens a plugin settings page from Cmd-K", async () => { + it("opens an installed plugin's settings page", async () => { testState.plugins.push({ enabled: true, hasSettings: true, @@ -533,11 +1233,11 @@ describe("CommandPalette", () => { name: "Linear", }); renderPalette(); - openThreadSearch(); + openPalette(); await waitFor(() => expect(searchField()).toBeTruthy()); fireEvent.change(searchField(), { - target: { value: ">linear settings" }, + target: { value: "linear settings" }, }); await waitFor(() => expect(selectedOption()?.textContent).toContain("Linear settings"), @@ -545,13 +1245,13 @@ describe("CommandPalette", () => { fireEvent.keyDown(searchField(), { key: "Enter" }); await waitFor(() => - expect(screen.getByTestId("location").textContent).toContain( + expect(screen.getByTestId("location").textContent).toBe( "/settings/plugins/linear", ), ); }); - it("opens a plugin page from Cmd-K", async () => { + it("opens a plugin page", async () => { setPluginSlotRegistrations("automations", { homepageSections: [], settingsSections: [], @@ -568,49 +1268,88 @@ describe("CommandPalette", () => { sidebarFooterActions: [], fileOpeners: [], messageDirectives: [], + commandPaletteActions: [], }); renderPalette(); - openThreadSearch(); + openPalette(); await waitFor(() => expect(searchField()).toBeTruthy()); - fireEvent.change(searchField(), { - target: { value: ">automations" }, - }); + fireEvent.change(searchField(), { target: { value: "automations" } }); await waitFor(() => expect(selectedOption()?.textContent).toContain("Automations"), ); fireEvent.keyDown(searchField(), { key: "Enter" }); await waitFor(() => - expect(screen.getByTestId("location").textContent).toContain( + expect(screen.getByTestId("location").textContent).toBe( "/plugins/automations/automations", ), ); }); - it("opens a matched thread at its matched message", async () => { - renderPalette(); - openThreadSearch(); - await waitFor(() => - expect( - screen.getByRole("option", { name: /Matched thread/u }), - ).toBeTruthy(), + it("lists a plugin's commandPaletteAction and runs it", async () => { + setPluginLogoUrls( + new Map([ + [ + "linear", + { + displayName: "Linear", + icon: null, + compactIconUrl: null, + logoUrl: null, + logoDarkUrl: null, + icons: new Map(), + }, + ], + ]), ); + setPluginSlotRegistrations("linear", { + homepageSections: [], + settingsSections: [], + navPanels: [], + threadPanelActions: [], + sidebarFooterActions: [], + fileOpeners: [], + messageDirectives: [], + commandPaletteActions: [ + { + id: "open-issue", + title: "Open issue", + run: () => { + testState.calls.push("plugin-ran"); + }, + }, + ], + }); + renderPalette(); + openPalette(); + await waitFor(() => expect(searchField()).toBeTruthy()); + + const pluginRow = within(bucketGroup("Plugins")).getByRole("option"); + expect(pluginRow.textContent).toContain("Open issue"); + expect(pluginRow.textContent).toContain("Linear"); - fireEvent.click(screen.getByRole("option", { name: /Matched thread/u })); + fireEvent.change(searchField(), { target: { value: "linear" } }); + await waitFor(() => expect(optionTitles()).toHaveLength(1)); + expect(optionTitles()?.[0]).toContain("Open issue"); + expect(optionTitles()?.[0]).toContain("Linear"); + expect(within(commandList()).queryAllByRole("group")).toHaveLength(0); + fireEvent.keyDown(searchField(), { key: "Enter" }); + + await waitFor(() => expect(testState.calls).toEqual(["plugin-ran"])); + }); + + it("says so when nothing matches", async () => { + renderPalette(); + openPalette(); + await waitFor(() => expect(searchField()).toBeTruthy()); + + fireEvent.change(searchField(), { target: { value: "zzzzz" } }); await waitFor(() => - expect(screen.getByTestId("location").textContent).toContain( - "thr_message", - ), + expect(screen.getByText("No matching commands")).toBeTruthy(), ); - const location = JSON.parse( - screen.getByTestId("location").textContent ?? "{}", - ) as { pathname: string; state: Record }; - expect(location.pathname).toContain("thr_message"); - expect(location.state).toEqual({ - searchMessageSeq: 7, - searchThreadId: "thr_message", - }); + fireEvent.keyDown(searchField(), { key: "Enter" }); + expect(testState.calls).toEqual([]); }); }); diff --git a/apps/app/src/components/commands/CommandPalette.tsx b/apps/app/src/components/commands/CommandPalette.tsx index 6bcd03d081..9c5d82aae5 100644 --- a/apps/app/src/components/commands/CommandPalette.tsx +++ b/apps/app/src/components/commands/CommandPalette.tsx @@ -9,20 +9,23 @@ import { import type { KeyboardEvent as ReactKeyboardEvent } from "react"; import { useNavigate } from "react-router-dom"; import { Dialog, DialogContent, DialogTitle } from "@bb/shared-ui/dialog"; -import { Icon } from "@bb/shared-ui/icon"; import { cn } from "@bb/shared-ui/lib/utils"; -import { COARSE_POINTER_TEXT_SM_CLASS } from "@bb/shared-ui/coarse-pointer-sizing"; -import { LAUNCHER_ACTION_ROW_BASE_CLASS } from "@/components/secondary-panel/launcherRow"; +import { CHROME_SECTION_LABEL_CLASS } from "@bb/shared-ui/chrome-style-tokens"; import { useAppCommandHandler, useAppCommandRunner, useAppCommandShortcuts, + useIndexedAppCommandHandlers, } from "./AppCommandProvider"; import { AppCommandShortcutPill } from "./AppCommandShortcutHint"; -import type { PaletteAction } from "@/lib/command-palette/palette-action"; +import { + PALETTE_ACTION_BUCKETS, + type PaletteAction, +} from "@/lib/command-palette/palette-action"; import { buildAppCommandActions, PALETTE_COMMAND_IDS, + paletteActionIdForCommand, } from "@/lib/command-palette/palette-app-commands"; import { rankPaletteActions, @@ -33,11 +36,10 @@ import { recordPaletteRecent, } from "@/lib/command-palette/palette-recents"; import { buildPluginPaletteActions } from "@/lib/command-palette/palette-plugin-actions"; -import { buildSettingsPaletteActions } from "@/lib/command-palette/palette-settings-actions"; -import { buildPluginPagePaletteActions } from "@/lib/command-palette/palette-plugin-page-actions"; import { usePluginSlots } from "@/lib/plugin-slots"; import { getActiveThreadPanelOpener } from "@/components/plugin/plugin-thread-panel-navigation"; -import { getThreadRoutePath } from "@/lib/route-paths"; +import { buildSettingsPaletteActions } from "@/lib/command-palette/palette-settings-actions"; +import { buildPluginPagePaletteActions } from "@/lib/command-palette/palette-plugin-page-actions"; import { pluginListQueryOptions } from "@/hooks/queries/plugin-settings-queries"; import { buildPluginSettingsEntries, @@ -46,18 +48,36 @@ import { import { useSettingsNavSections } from "@/components/settings/settings-nav"; import { appQueryClient } from "@/lib/app-query-client"; import { - ThreadPaletteResults, - type ThreadPaletteNavigationItem, -} from "./ThreadPaletteResults"; + PALETTE_MODE_ENTRY_COMMANDS, + PALETTE_MODES, +} from "@/lib/command-palette/palette-modes"; +import { PaletteShell } from "./PaletteShell"; -type PaletteMode = "commands" | "threads"; +const PALETTE_INPUT_LABEL = "Search commands"; +const PALETTE_PLACEHOLDER = "Search commands…"; +const ROOT_FOOTER_KEYS = [ + { keys: ["↑↓"], label: "Select" }, + { keys: ["↵"], label: "Run" }, +] as const; +const MODE_ENTRY_HANDLER_PRIORITY = 100; +const MODE_BY_ACTION_ID = new Map( + PALETTE_MODES.map((mode) => [ + paletteActionIdForCommand(mode.entryCommand), + mode, + ]), +); export interface CommandPaletteProps { threadId: string | null; projectId: string | null; + onSplit?: () => void; } -export function CommandPalette({ threadId, projectId }: CommandPaletteProps) { +export function CommandPalette({ + threadId, + projectId, + onSplit, +}: CommandPaletteProps) { const navigate = useNavigate(); const runner = useAppCommandRunner(); const shortcuts = useAppCommandShortcuts(PALETTE_COMMAND_IDS); @@ -67,18 +87,16 @@ export function CommandPalette({ threadId, projectId }: CommandPaletteProps) { const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [actions, setActions] = useState([]); - const [threadItems, setThreadItems] = useState< - readonly ThreadPaletteNavigationItem[] - >([]); const [highlightedIndex, setHighlightedIndex] = useState(0); - const [recents, setRecents] = useState(() => - readPaletteRecents(), - ); + const [activeModeId, setActiveModeId] = useState(null); const [installedPlugins, setInstalledPlugins] = useState< readonly PluginSettingsCandidate[] >([]); + const [recents, setRecents] = useState(() => + readPaletteRecents(), + ); const pluginSlots = usePluginSlots(); - const sections = useSettingsNavSections(pluginSlots.fileOpeners); + const settingsSections = useSettingsNavSections(pluginSlots.fileOpeners); const pluginSettingsEntries = useMemo( () => buildPluginSettingsEntries({ @@ -90,20 +108,16 @@ export function CommandPalette({ threadId, projectId }: CommandPaletteProps) { const settingsActions = useMemo( () => buildSettingsPaletteActions({ - navigate: (path) => { - void navigate(path); - }, + navigate: (path) => void navigate(path), pluginEntries: pluginSettingsEntries, - sections, + sections: settingsSections, }), - [navigate, pluginSettingsEntries, sections], + [navigate, pluginSettingsEntries, settingsSections], ); const pluginPageActions = useMemo( () => buildPluginPagePaletteActions({ - navigate: (path) => { - void navigate(path); - }, + navigate: (path) => void navigate(path), panels: pluginSlots.navPanels, }), [navigate, pluginSlots.navPanels], @@ -111,12 +125,6 @@ export function CommandPalette({ threadId, projectId }: CommandPaletteProps) { const openTargetRef = useRef(null); const pendingRunRef = useRef<(() => void) | null>(null); - const loadInstalledPlugins = useCallback(() => { - void appQueryClient - .fetchQuery(pluginListQueryOptions({ enabled: true })) - .then(setInstalledPlugins, () => {}); - }, []); - const buildActions = useCallback( (target: EventTarget | null) => [ ...buildAppCommandActions({ @@ -125,6 +133,18 @@ export function CommandPalette({ threadId, projectId }: CommandPaletteProps) { dispatch: runner.dispatch, shortcuts, }), + ...(onSplit === undefined + ? [] + : [ + { + id: "internal:thread.split", + bucket: "Actions", + group: "Window and layout", + title: "Split", + shortcut: null, + run: onSplit, + } satisfies PaletteAction, + ]), ...buildPluginPaletteActions({ slots: pluginSlots.commandPaletteActions, threadId, @@ -134,22 +154,27 @@ export function CommandPalette({ threadId, projectId }: CommandPaletteProps) { ], [ projectId, - pluginSlots.commandPaletteActions, runner.dispatch, runner.isCommandAvailable, shortcuts, threadId, + onSplit, + pluginSlots.commandPaletteActions, ], ); - const openPalette = useCallback( - (mode: PaletteMode, target: EventTarget | null) => { + const loadInstalledPlugins = useCallback(() => { + void appQueryClient + .fetchQuery(pluginListQueryOptions({ enabled: true })) + .then(setInstalledPlugins, () => {}); + }, []); + + const prepareOpen = useCallback( + (target: EventTarget | null) => { openTargetRef.current = target; setActions(buildActions(target)); - setThreadItems([]); - setQuery(mode === "commands" ? ">" : ""); + setQuery(""); setHighlightedIndex(0); - setOpen(true); loadInstalledPlugins(); }, [buildActions, loadInstalledPlugins], @@ -159,37 +184,65 @@ export function CommandPalette({ threadId, projectId }: CommandPaletteProps) { const target = invocation.target ?? (typeof document === "undefined" ? null : document.activeElement); - openPalette("commands", target); + prepareOpen(target); + setActiveModeId(null); + setOpen(true); return true; }); - useAppCommandHandler("thread.search", (invocation) => { - const target = - invocation.target ?? - (typeof document === "undefined" ? null : document.activeElement); - openPalette("threads", target); - return true; - }); + useIndexedAppCommandHandlers( + PALETTE_MODE_ENTRY_COMMANDS, + (index, invocation) => { + const mode = PALETTE_MODES[index]; + if (mode === undefined) return false; + const target = + invocation.target ?? + (typeof document === "undefined" ? null : document.activeElement); + prepareOpen(target); + setActiveModeId(mode.id); + setOpen(true); + return true; + }, + MODE_ENTRY_HANDLER_PRIORITY, + ); - const mode: PaletteMode = query.startsWith(">") ? "commands" : "threads"; - const modeQuery = mode === "commands" ? query.slice(1) : query; - const commandActions = useMemo( + const availableActions = useMemo( () => [...actions, ...settingsActions, ...pluginPageActions], [actions, pluginPageActions, settingsActions], ); - const rankedCommands = useMemo( + const ranked = useMemo( () => rankPaletteActions({ - actions: commandActions, - query: modeQuery, + actions: availableActions, + query, recentIds: recents, }), - [commandActions, modeQuery, recents], + [availableActions, query, recents], + ); + const isGroupedRoot = query.trim() === ""; + const rootGroups = useMemo(() => { + const groups = PALETTE_ACTION_BUCKETS.map((bucket) => ({ + bucket, + entries: ranked.filter((entry) => entry.action.bucket === bucket), + })).filter( + (group) => group.bucket !== "Plugins" || group.entries.length > 0, + ); + return groups.map((group, index) => ({ + ...group, + startIndex: groups + .slice(0, index) + .reduce((total, prior) => total + prior.entries.length, 0), + })); + }, [ranked]); + const visibleEntries = useMemo( + () => + isGroupedRoot ? rootGroups.flatMap((group) => group.entries) : ranked, + [isGroupedRoot, ranked, rootGroups], ); - const resultCount = - mode === "commands" ? rankedCommands.length : threadItems.length; const activeIndex = - resultCount === 0 ? -1 : Math.min(highlightedIndex, resultCount - 1); + visibleEntries.length === 0 + ? -1 + : Math.min(highlightedIndex, visibleEntries.length - 1); const listRef = useRef(null); const scrollOnNextHighlightRef = useRef(false); @@ -201,59 +254,52 @@ export function CommandPalette({ threadId, projectId }: CommandPaletteProps) { ?.scrollIntoView({ block: "nearest" }); }, [activeIndex]); - const chooseAction = useCallback((action: PaletteAction) => { - if (action.id === "app:thread.search") { - setQuery(""); - setHighlightedIndex(0); - if (listRef.current !== null) listRef.current.scrollTop = 0; - return; - } - pendingRunRef.current = action.run; - setRecents((current) => recordPaletteRecent(current, action.id)); - setOpen(false); - }, []); - - const chooseThread = useCallback( - (item: ThreadPaletteNavigationItem) => { - pendingRunRef.current = () => { - void navigate( - getThreadRoutePath({ - projectId: item.projectId, - threadId: item.threadId, - }), - item.messageSeq === null - ? undefined - : { - state: { - searchMessageSeq: item.messageSeq, - searchThreadId: item.threadId, - }, - }, - ); - }; + const chooseAction = useCallback( + (action: PaletteAction) => { + setRecents((current) => recordPaletteRecent(current, action.id)); + if (MODE_BY_ACTION_ID.has(action.id)) { + action.run(); + return; + } + pendingRunRef.current = action.run; setOpen(false); }, - [navigate], + [], ); - const handleAfterCloseAutoFocus = useCallback(() => { + const runAfterClose = useCallback((run: () => void) => { + pendingRunRef.current = run; + setOpen(false); + }, []); + + const handleCloseAutoFocus = useCallback((event: Event) => { const pending = pendingRunRef.current; pendingRunRef.current = null; const target = openTargetRef.current; if (target instanceof HTMLElement && target.isConnected) { + event.preventDefault(); target.focus({ preventScroll: true }); } pending?.(); }, []); + const handleOpenChange = useCallback((nextOpen: boolean) => { + setOpen(nextOpen); + if (!nextOpen) { + setActiveModeId(null); + setQuery(""); + setHighlightedIndex(0); + } + }, []); + const handleKeyDown = useCallback( (event: ReactKeyboardEvent) => { - if (resultCount === 0) return; + if (visibleEntries.length === 0) return; if (event.key === "ArrowDown") { event.preventDefault(); scrollOnNextHighlightRef.current = true; setHighlightedIndex((current) => - current + 1 >= resultCount ? 0 : current + 1, + current + 1 >= visibleEntries.length ? 0 : current + 1, ); return; } @@ -261,7 +307,7 @@ export function CommandPalette({ threadId, projectId }: CommandPaletteProps) { event.preventDefault(); scrollOnNextHighlightRef.current = true; setHighlightedIndex((current) => - current <= 0 ? resultCount - 1 : current - 1, + current <= 0 ? visibleEntries.length - 1 : current - 1, ); return; } @@ -274,113 +320,126 @@ export function CommandPalette({ threadId, projectId }: CommandPaletteProps) { if (event.key === "End") { event.preventDefault(); scrollOnNextHighlightRef.current = true; - setHighlightedIndex(resultCount - 1); + setHighlightedIndex(visibleEntries.length - 1); return; } if (event.key === "Enter") { + const choice = visibleEntries[activeIndex]; + if (choice === undefined) return; event.preventDefault(); - if (mode === "commands") { - const choice = rankedCommands[activeIndex]; - if (choice !== undefined) chooseAction(choice.action); - return; - } - const choice = threadItems[activeIndex]; - if (choice !== undefined) chooseThread(choice); + chooseAction(choice.action); } }, - [ - activeIndex, - chooseAction, - chooseThread, - mode, - rankedCommands, - resultCount, - threadItems, - ], + [activeIndex, chooseAction, visibleEntries], ); - - const activeDescendant = - activeIndex === -1 + const activeMode = + activeModeId === null ? undefined - : mode === "commands" - ? `${optionIdPrefix}-${activeIndex}` - : threadItems[activeIndex]?.optionId; - const inputLabel = mode === "commands" ? "Search commands" : "Search threads"; + : PALETTE_MODES.find((mode) => mode.id === activeModeId); return ( - + { + if (activeMode !== undefined) event.preventDefault(); + }} data-testid="command-palette" > - - {mode === "commands" ? "Quick palette" : "Search threads"} - -
- - { - const nextQuery = event.target.value; - setQuery(nextQuery); - if (!nextQuery.startsWith(">")) setThreadItems([]); + Quick palette + {activeMode === undefined ? ( + { + setQuery(value); setHighlightedIndex(0); if (listRef.current !== null) listRef.current.scrollTop = 0; }} - onKeyDown={handleKeyDown} + onInputKeyDown={handleKeyDown} + placeholder={PALETTE_PLACEHOLDER} + value={query} + > + {!isGroupedRoot && visibleEntries.length === 0 ? ( +

+ No matching commands +

+ ) : isGroupedRoot ? ( + rootGroups.map((group) => { + const labelId = `${optionIdPrefix}-${group.bucket.toLowerCase()}-label`; + return ( +
+
+ {group.bucket} +
+ {group.entries.map((entry, index) => { + const visibleIndex = group.startIndex + index; + return ( + { + setHighlightedIndex(visibleIndex); + }} + onSelect={() => chooseAction(entry.action)} + /> + ); + })} +
+ ); + }) + ) : ( + visibleEntries.map((entry, index) => ( + { + setHighlightedIndex(index); + }} + onSelect={() => chooseAction(entry.action)} + /> + )) + )} +
+ ) : ( + { + setActiveModeId(null); + setQuery(""); + setHighlightedIndex(0); + }} + runAfterClose={runAfterClose} /> -
-
- {mode === "commands" && rankedCommands.length === 0 ? ( -

- No matching commands -

- ) : mode === "commands" ? ( - rankedCommands.map((entry, index) => ( - setHighlightedIndex(index)} - onSelect={() => chooseAction(entry.action)} - /> - )) - ) : ( - - )} -
+ )}
); @@ -390,23 +449,30 @@ function PaletteRow({ entry, id, isActive, + isDrillIn, onActivate, onSelect, }: { entry: RankedPaletteAction; id: string; isActive: boolean; + isDrillIn: boolean; onActivate: () => void; onSelect: () => void; }) { + const metadataGroup = + entry.action.group === entry.action.bucket ? null : entry.action.group; + const title = isDrillIn ? `${entry.action.title}…` : entry.action.title; + const hasTrailing = + metadataGroup !== null || entry.action.shortcut !== null || isDrillIn; return (
- - - {entry.action.group} + {hasTrailing ? ( + + {metadataGroup === null ? null : ( + + {metadataGroup} + + )} + {entry.action.shortcut === null ? null : ( + + )} + {isDrillIn ? ( + Opens a search view + ) : null} - {entry.action.shortcut === null ? null : ( - - )} - + ) : null}
); } diff --git a/apps/app/src/components/commands/CommandPalettePrototype.stories.tsx b/apps/app/src/components/commands/CommandPalettePrototype.stories.tsx new file mode 100644 index 0000000000..76946b1b4b --- /dev/null +++ b/apps/app/src/components/commands/CommandPalettePrototype.stories.tsx @@ -0,0 +1,741 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import type { KeyboardEvent as ReactKeyboardEvent } from "react"; +import { useComposedRefs } from "@radix-ui/react-compose-refs"; +import { CHROME_SECTION_LABEL_CLASS } from "@bb/shared-ui/chrome-style-tokens"; +import { Icon } from "@bb/shared-ui/icon"; +import { cn } from "@bb/shared-ui/lib/utils"; +import { useScrollOverflowState } from "@/components/thread/timeline/useScrollOverflowState"; +import { TabPill } from "@/components/ui/tab-pill"; + +export default { + title: "commands/Command palette prototype", +}; + +type Mode = "commands" | "threads"; +type ThreadScope = "all" | "active" | "draft" | "archived"; +type ThreadState = Exclude; + +interface CommandRow { + bucket: "Threads" | "Actions" | "Plugins"; + group: string; + label: string; + shortcut?: string; + drillIn?: boolean; +} + +interface ThreadRow { + id: string; + title: string; + metadata: string; + state: ThreadState; +} + +const COMMANDS = [ + { + bucket: "Threads", + group: "Threads", + label: "New thread", + shortcut: "⇧ ⌘ O", + }, + { + bucket: "Threads", + group: "Threads", + label: "Search threads…", + shortcut: "⌘ K", + drillIn: true, + }, + { + bucket: "Threads", + group: "Threads", + label: "Rename thread", + }, + { + bucket: "Threads", + group: "Threads", + label: "Archive thread", + }, + { + bucket: "Threads", + group: "Threads", + label: "Previous thread", + shortcut: "⇧ ⌘ [", + }, + { + bucket: "Threads", + group: "Threads", + label: "Next thread", + shortcut: "⇧ ⌘ ]", + }, + { + bucket: "Actions", + group: "Window and layout", + label: "New window", + shortcut: "⇧ ⌘ N", + }, + { + bucket: "Actions", + group: "Window and layout", + label: "Open settings", + shortcut: "⌘ ,", + }, + { + bucket: "Actions", + group: "Window and layout", + label: "Open server settings", + }, + { + bucket: "Actions", + group: "Window and layout", + label: "Toggle sidebar", + shortcut: "⌘ \\", + }, + { + bucket: "Actions", + group: "Window and layout", + label: "New panel tab", + shortcut: "⌘ T", + }, + { + bucket: "Actions", + group: "Window and layout", + label: "Close panel tab", + shortcut: "⌘ W", + }, + { + bucket: "Actions", + group: "Window and layout", + label: "Toggle panel", + shortcut: "⌘ J", + }, + { + bucket: "Actions", + group: "Window and layout", + label: "Focus previous chat pane", + }, + { + bucket: "Actions", + group: "Window and layout", + label: "Focus next chat pane", + }, + { + bucket: "Actions", + group: "Window and layout", + label: "Toggle focused chat pane size", + shortcut: "⇧ ⌘ E", + }, + { + bucket: "Actions", + group: "Window and layout", + label: "Close focused chat pane", + shortcut: "⇧ ⌘ X", + }, + { + bucket: "Actions", + group: "Window and layout", + label: "Open server and daemon logs", + }, + { + bucket: "Actions", + group: "Workspace", + label: "Quick open file", + shortcut: "⌘ P", + }, + { + bucket: "Actions", + group: "Workspace", + label: "Toggle diff", + shortcut: "⌘ D", + }, + { + bucket: "Actions", + group: "Workspace", + label: "Open terminal", + shortcut: "⇧ ⌘ ↵", + }, + { + bucket: "Actions", + group: "Workspace", + label: "Open in preferred app", + shortcut: "⌘ O", + }, + { + bucket: "Actions", + group: "Composer and models", + label: "Focus composer", + shortcut: "⇧ ⌘ C", + }, + { + bucket: "Actions", + group: "Composer and models", + label: "Toggle model picker", + shortcut: "⇧ ⌘ M", + }, + { + bucket: "Actions", + group: "Browser", + label: "Focus location", + shortcut: "⌘ L", + }, + { + bucket: "Actions", + group: "Browser", + label: "Reload page", + shortcut: "⌘ R", + }, + { + bucket: "Actions", + group: "Browser", + label: "Find in page", + shortcut: "⌘ F", + }, + { + bucket: "Actions", + group: "Window and layout", + label: "Split", + }, + { + bucket: "Plugins", + group: "Design Doctrine", + label: "Open Design Doctrine", + }, + { + bucket: "Plugins", + group: "GitHub Activity", + label: "Open GitHub Activity", + }, +] as const satisfies readonly CommandRow[]; + +const THREADS = [ + { + id: "palette-redesign", + title: "Redesign the command palette", + metadata: "bb · just now", + state: "active", + }, + { + id: "sidebar-evidence", + title: "Replace sidebar stack screenshot evidence", + metadata: "bb · 12m ago", + state: "active", + }, + { + id: "filter-states", + title: "Review thread filter states", + metadata: "bb · Yesterday", + state: "draft", + }, + { + id: "mobile-drawer", + title: "Investigate mobile drawer performance", + metadata: "Mobile · Yesterday", + state: "active", + }, + { + id: "project-grouping", + title: "Confirm project grouping hierarchy", + metadata: "Sidebar polish · 3d ago", + state: "active", + }, + { + id: "legacy-navigation", + title: "Legacy navigation audit", + metadata: "1w ago", + state: "archived", + }, +] as const satisfies readonly ThreadRow[]; + +const THREAD_STATE: Record = { + active: { label: "Active" }, + draft: { label: "Draft" }, + archived: { label: "Archived" }, +}; + +const SCOPES = [ + { id: "all", label: "All" }, + { id: "active", label: "Active" }, + { id: "draft", label: "Drafts" }, + { id: "archived", label: "Archived" }, +] as const satisfies readonly { id: ThreadScope; label: string }[]; + +function Shortcut({ children }: { children: string }) { + return ( + + {children} + + ); +} + +function PaletteFrame({ children }: { children: React.ReactNode }) { + return ( +
+
+ {children} +
+
+ ); +} + +function CommandPalettePrototype() { + const [mode, setMode] = useState("commands"); + const [query, setQuery] = useState(""); + const [selectedIndex, setSelectedIndex] = useState(0); + const [scope, setScope] = useState("all"); + const [scopeOpen, setScopeOpen] = useState(false); + const inputRef = useRef(null); + const resultsRef = useRef(null); + const scrollOnNextSelectionRef = useRef(false); + const overflow = useScrollOverflowState({ + measureOverflow: true, + }); + const composedResultsRef = useComposedRefs(resultsRef, overflow.scrollRef); + const resultsMask = + overflow.aboveOverflow && overflow.belowOverflow + ? "linear-gradient(to bottom, transparent 0, black 1.5rem, black calc(100% - 1.5rem), transparent 100%)" + : overflow.aboveOverflow + ? "linear-gradient(to bottom, transparent 0, black 1.5rem, black 100%)" + : overflow.belowOverflow + ? "linear-gradient(to bottom, black 0, black calc(100% - 1.5rem), transparent 100%)" + : undefined; + + const commandRows = useMemo(() => { + const normalized = query.trim().toLocaleLowerCase(); + if (normalized.length === 0) return COMMANDS; + return COMMANDS.filter((command) => + [command.label, command.group, command.bucket] + .join(" ") + .toLocaleLowerCase() + .includes(normalized), + ); + }, [query]); + + const threadRows = useMemo(() => { + const normalized = query.trim().toLocaleLowerCase(); + return THREADS.filter( + (thread) => + (scope === "all" || thread.state === scope) && + (normalized.length === 0 || + `${thread.title} ${thread.metadata}` + .toLocaleLowerCase() + .includes(normalized)), + ); + }, [query, scope]); + + const visibleCount = + mode === "commands" ? commandRows.length : threadRows.length; + + useEffect(() => { + setSelectedIndex(0); + }, [mode, query, scope]); + + useEffect(() => { + if (!scrollOnNextSelectionRef.current) return; + scrollOnNextSelectionRef.current = false; + resultsRef.current + ?.querySelector('[aria-selected="true"]') + ?.scrollIntoView({ block: "nearest" }); + }, [selectedIndex]); + + const enterThreads = () => { + setMode("threads"); + setQuery(""); + setScope("all"); + setScopeOpen(false); + requestAnimationFrame(() => inputRef.current?.focus()); + }; + + const exitThreads = () => { + setMode("commands"); + setQuery(""); + setScopeOpen(false); + requestAnimationFrame(() => inputRef.current?.focus()); + }; + + const activateSelected = () => { + if (mode !== "commands") return; + if (commandRows[selectedIndex]?.drillIn) enterThreads(); + }; + + const handleKeyDown = (event: ReactKeyboardEvent) => { + if (event.key === "Backspace" && mode === "threads" && query.length === 0) { + event.preventDefault(); + exitThreads(); + return; + } + if (event.key === "Escape") { + if (mode === "threads") { + event.preventDefault(); + exitThreads(); + } + return; + } + if (visibleCount === 0) return; + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + scrollOnNextSelectionRef.current = true; + setSelectedIndex((current) => { + if (event.key === "ArrowDown") { + return current + 1 >= visibleCount ? 0 : current + 1; + } + return current === 0 ? visibleCount - 1 : current - 1; + }); + return; + } + if (event.key === "Home" || event.key === "End") { + event.preventDefault(); + scrollOnNextSelectionRef.current = true; + setSelectedIndex(event.key === "Home" ? 0 : visibleCount - 1); + return; + } + if (event.key === "Enter") { + event.preventDefault(); + activateSelected(); + } + }; + + return ( + +
+
+ {mode === "threads" ? ( + + ) : null} + setQuery(event.target.value)} + onKeyDown={handleKeyDown} + /> + {mode === "threads" ? ( + { + setScope(nextScope); + setScopeOpen(false); + requestAnimationFrame(() => inputRef.current?.focus()); + }} + /> + ) : null} +
+
+ +
+
+
+ {mode === "commands" ? ( + + ) : ( + + )} +
+
+
+ + + + ); +} + +function ModeChip({ onClear }: { onClear: () => void }) { + return ( + undefined} + leadingVisual={} + closeAction={{ + onClose: onClear, + closeLabel: "Return to commands", + }} + /> + ); +} + +function ScopePicker({ + onOpenChange, + onScopeChange, + open, + scope, +}: { + onOpenChange: (open: boolean) => void; + onScopeChange: (scope: ThreadScope) => void; + open: boolean; + scope: ThreadScope; +}) { + const current = SCOPES.find((candidate) => candidate.id === scope); + return ( +
+ + {open ? ( +
+ {SCOPES.map((option) => ( + + ))} +
+ ) : null} +
+ ); +} + +function CommandResults({ + grouped, + onActivate, + onEnterThreads, + rows, + selectedIndex, +}: { + grouped: boolean; + onActivate: (index: number) => void; + onEnterThreads: () => void; + rows: readonly CommandRow[]; + selectedIndex: number; +}) { + if (rows.length === 0) return No matching commands; + + if (!grouped) { + return rows.map((row, index) => ( + onActivate(index)} + onSelect={row.drillIn ? onEnterThreads : undefined} + /> + )); + } + + let visibleIndex = 0; + return (["Threads", "Actions", "Plugins"] as const).map((bucket) => { + const bucketRows = rows.filter((row) => row.bucket === bucket); + if (bucketRows.length === 0) return null; + const startIndex = visibleIndex; + visibleIndex += bucketRows.length; + return ( +
+
+ {bucket} +
+ {bucketRows.map((row, index) => { + const indexInList = startIndex + index; + return ( + onActivate(indexInList)} + onSelect={row.drillIn ? onEnterThreads : undefined} + /> + ); + })} +
+ ); + }); +} + +function CommandOption({ + id, + onActivate, + onSelect, + row, + selected, +}: { + id: string; + onActivate: () => void; + onSelect?: () => void; + row: CommandRow; + selected: boolean; +}) { + const metadata = row.group === row.bucket ? null : row.group; + return ( +
+ {row.label} + + {metadata === null ? null : ( + {metadata} + )} + {row.shortcut === undefined ? null : ( + {row.shortcut} + )} + + {row.drillIn ? ( + Opens a search view + ) : null} +
+ ); +} + +function ThreadResults({ + onActivate, + rows, + selectedIndex, +}: { + onActivate: (index: number) => void; + rows: readonly ThreadRow[]; + selectedIndex: number; +}) { + if (rows.length === 0) return No matching threads; + return rows.map((row, index) => { + const presentation = THREAD_STATE[row.state]; + return ( +
onActivate(index)} + > + + {row.title} + + {row.metadata} + + + {row.state === "active" ? null : ( + + {presentation.label} + + )} +
+ ); + }); +} + +function EmptyMessage({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} + +function PaletteFooter({ mode }: { mode: Mode }) { + const hints = + mode === "threads" + ? ([ + { keys: ["↑↓"], label: "Select" }, + { keys: ["↵"], label: "Open" }, + { keys: ["⌘↵"], label: "Split" }, + { keys: ["Backspace", "Esc"], label: "Back" }, + ] as const) + : ([ + { keys: ["↑↓"], label: "Select" }, + { keys: ["↵"], label: "Run" }, + ] as const); + return ( +
+ {hints.map((hint) => ( + + + {hint.keys.map((keys, index) => ( + + {index === 0 ? null : ( + + )} + + {keys} + + + ))} + + {hint.label} + + ))} +
+ ); +} + +export function Review() { + return ; +} diff --git a/apps/app/src/components/commands/PaletteShell.tsx b/apps/app/src/components/commands/PaletteShell.tsx new file mode 100644 index 0000000000..73d6e25048 --- /dev/null +++ b/apps/app/src/components/commands/PaletteShell.tsx @@ -0,0 +1,180 @@ +import type { KeyboardEventHandler, ReactNode, Ref } from "react"; +import { useComposedRefs } from "@radix-ui/react-compose-refs"; +import { Icon } from "@bb/shared-ui/icon"; +import { useScrollOverflowState } from "@/components/thread/timeline/useScrollOverflowState"; +import { TabPill } from "@/components/ui/tab-pill"; + +export const PALETTE_FOOTER_CONTROL_SURFACE_CLASS = + "border border-border/70 bg-background/70 shadow-xs"; +export const PALETTE_FOOTER_KEYCAP_CLASS = `inline-flex min-w-5 items-center justify-center rounded px-1.5 py-0.5 font-mono text-xs leading-none text-muted-foreground ${PALETTE_FOOTER_CONTROL_SURFACE_CLASS}`; +export const PALETTE_FOOTER_LABEL_CLASS = "text-subtle-foreground opacity-70"; + +interface PaletteModeChipProps { + clearLabel: string; + icon: Parameters[0]["name"]; + label: string; + onClear: () => void; +} + +interface PaletteShellProps { + activeDescendantId?: string; + accessory?: ReactNode; + children: ReactNode; + footerKeys: readonly { keys: readonly string[]; label: string }[]; + inputLabel: string; + inputRef?: Ref; + listId: string; + listLabel: string; + listRef?: Ref; + modeChip?: PaletteModeChipProps; + onInputChange: (value: string) => void; + onInputKeyDown: KeyboardEventHandler; + placeholder: string; + value: string; +} + +export function PaletteShell({ + activeDescendantId, + accessory, + children, + footerKeys, + inputLabel, + inputRef, + listId, + listLabel, + listRef, + modeChip, + onInputChange, + onInputKeyDown, + placeholder, + value, +}: PaletteShellProps) { + const overflow = useScrollOverflowState({ + measureOverflow: true, + }); + const composedListRef = useComposedRefs(listRef, overflow.scrollRef); + const resultsMask = + overflow.aboveOverflow && overflow.belowOverflow + ? "linear-gradient(to bottom, transparent 0, black 1.5rem, black calc(100% - 1.5rem), transparent 100%)" + : overflow.aboveOverflow + ? "linear-gradient(to bottom, transparent 0, black 1.5rem, black 100%)" + : overflow.belowOverflow + ? "linear-gradient(to bottom, black 0, black calc(100% - 1.5rem), transparent 100%)" + : undefined; + + return ( + <> +
+
+ {modeChip === undefined ? null : } + onInputChange(event.target.value)} + onKeyDown={onInputKeyDown} + /> + {accessory} +
+
+
+
+
+ {children} +
+
+
+
+ {footerKeys.map((hint) => ( + + + {hint.keys.map((keys, index) => ( + + {index === 0 ? null : ( + + / + + )} + {keys} + + ))} + + + {hint.label} + + + ))} +
+ + ); +} + +function PaletteModeChip({ + clearLabel, + icon, + label, + onClear, +}: PaletteModeChipProps) { + return ( + + undefined} + leadingVisual={} + closeAction={{ onClose: onClear, closeLabel: clearLabel }} + /> + + ); +} diff --git a/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx b/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx new file mode 100644 index 0000000000..ada14a68c4 --- /dev/null +++ b/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx @@ -0,0 +1,534 @@ +import { + useCallback, + useEffect, + useId, + useLayoutEffect, + useMemo, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type ReactNode, +} from "react"; +import { useStore } from "jotai"; +import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport"; +import { cn } from "@bb/shared-ui/lib/utils"; +import { COARSE_POINTER_TEXT_SM_CLASS } from "@bb/shared-ui/coarse-pointer-sizing"; +import { isPromptDraftEmpty } from "@bb/client-core"; +import type { ThreadSearchHighlightRange } from "@bb/server-contract"; +import { usePromptDraftStorage } from "@/hooks/usePromptDraftStorage"; +import { useSidebarNavigation } from "@/hooks/queries/sidebar-navigation-query"; +import { + hasThreadSearchableQuery, + useArchivedThreads, + useThreadSearch, +} from "@/hooks/queries/thread-queries"; +import { useRouteNavigate } from "@/components/ui/app-route-anchor"; +import { getRootComposeRoutePath, getThreadRoutePath } from "@/lib/route-paths"; +import { useRootComposeProjectId } from "@/lib/root-compose-selection"; +import { openPaneContentInSplit } from "@/lib/split-layout/openPaneContentInSplit"; +import { openThreadInSplit } from "@/lib/split-layout/openThreadInSplit"; +import { + buildPaletteThreadSearchRows, + PALETTE_THREAD_SEARCH_SCOPES, + type PaletteThreadSearchRow, + type PaletteThreadSearchScope, +} from "@/lib/command-palette/palette-thread-search"; +import { windowPaletteThreadSearchText } from "@/lib/command-palette/palette-thread-search-window"; +import type { PaletteModeViewProps } from "@/lib/command-palette/palette-mode"; +import { PALETTE_FOOTER_LABEL_CLASS, PaletteShell } from "./PaletteShell"; + +export function ThreadSearchPaletteMode({ + onExit, + presentation, + runAfterClose, +}: PaletteModeViewProps) { + const listId = useId(); + const optionIdPrefix = useId(); + const inputRef = useRef(null); + const listRef = useRef(null); + const store = useStore(); + const navigate = useRouteNavigate(); + const isCompact = useIsCompactViewport(); + const [query, setQuery] = useState(""); + const [scope, setScope] = useState("all"); + const [highlightedIndex, setHighlightedIndex] = useState(0); + const [now] = useState(() => Date.now()); + const rootComposeDraft = usePromptDraftStorage({ kind: "new-thread" }); + const [rootComposeProjectId] = useRootComposeProjectId(); + const drafts = useMemo(() => { + const draft = { + text: rootComposeDraft.text, + mentions: rootComposeDraft.mentions, + attachments: rootComposeDraft.attachments, + }; + if (isPromptDraftEmpty(draft)) return []; + const title = draft.text.replace(/\s+/gu, " ").trim(); + return [ + { + id: "root-compose", + draft, + title: title.length > 0 ? title : "New thread", + lastEditedAt: null, + destination: { + projectId: rootComposeProjectId, + sectionId: null, + }, + }, + ]; + }, [ + rootComposeDraft.attachments, + rootComposeDraft.mentions, + rootComposeDraft.text, + rootComposeProjectId, + ]); + const navigation = useSidebarNavigation(); + const archivedThreads = useArchivedThreads({}); + const threadSearch = useThreadSearch({ active: true, query }); + const trimmedQuery = query.trim(); + const searchable = hasThreadSearchableQuery(trimmedQuery); + const searchResultsAreCurrent = + !searchable || threadSearch.debouncedQuery === trimmedQuery; + + const projectNamesById = useMemo(() => { + const entries = [ + ...(navigation.data?.projects ?? []), + ...(navigation.data === undefined + ? [] + : [navigation.data.personalProject]), + ].map((project) => [project.id, project.name] as const); + return new Map(entries); + }, [navigation.data]); + const recentThreads = useMemo( + () => [ + ...(navigation.data?.projects.flatMap((project) => project.threads) ?? + []), + ...(navigation.data?.personalProject.threads ?? []), + ], + [navigation.data], + ); + const recentArchivedThreads = useMemo( + () => archivedThreads.data?.pages.flatMap((page) => page) ?? [], + [archivedThreads.data], + ); + const result = useMemo( + () => + buildPaletteThreadSearchRows({ + drafts, + now, + projectNamesById, + query, + recentArchivedThreads, + recentThreads, + scope, + searchResponse: threadSearch.data, + searchResultsAreCurrent, + }), + [ + drafts, + now, + projectNamesById, + query, + recentArchivedThreads, + recentThreads, + scope, + searchResultsAreCurrent, + threadSearch.data, + ], + ); + const activeIndex = + result.rows.length === 0 + ? -1 + : Math.min(highlightedIndex, result.rows.length - 1); + const activeDescendantId = + activeIndex < 0 ? undefined : `${optionIdPrefix}-${activeIndex}`; + + const scrollOnNextHighlightRef = useRef(false); + useEffect(() => { + if (!scrollOnNextHighlightRef.current) return; + scrollOnNextHighlightRef.current = false; + listRef.current + ?.querySelector('[aria-selected="true"]') + ?.scrollIntoView({ block: "nearest" }); + }, [activeIndex]); + + const openRow = useCallback( + (row: PaletteThreadSearchRow, split: boolean) => { + runAfterClose(() => { + if (row.threadId !== null) { + const state = + row.messageSeq === null + ? undefined + : { + searchMessageSeq: row.messageSeq, + searchThreadId: row.threadId, + }; + if (split) { + openThreadInSplit({ + store, + navigate, + projectId: row.projectId, + threadId: row.threadId, + isCompact, + state, + }); + return; + } + navigate( + getThreadRoutePath({ + projectId: row.projectId, + threadId: row.threadId, + }), + { state }, + ); + return; + } + if (row.draftSlotId !== null) { + if (split) { + openPaneContentInSplit({ + store, + navigate, + content: { kind: "new-thread" }, + route: getRootComposeRoutePath(), + enabled: !isCompact, + }); + return; + } + navigate(getRootComposeRoutePath(), { state: { focusPrompt: true } }); + } + }); + }, + [isCompact, navigate, runAfterClose, store], + ); + + const handleInputKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + if (event.key === "Backspace" && query.length === 0) { + event.preventDefault(); + event.stopPropagation(); + onExit(); + return; + } + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + onExit(); + return; + } + if (result.rows.length === 0) return; + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + scrollOnNextHighlightRef.current = true; + setHighlightedIndex((current) => { + if (event.key === "ArrowDown") { + return current + 1 >= result.rows.length ? 0 : current + 1; + } + return current <= 0 ? result.rows.length - 1 : current - 1; + }); + return; + } + if (event.key === "Home" || event.key === "End") { + event.preventDefault(); + scrollOnNextHighlightRef.current = true; + setHighlightedIndex(event.key === "Home" ? 0 : result.rows.length - 1); + return; + } + if (event.key === "Enter") { + const row = result.rows[activeIndex]; + if (row === undefined) return; + event.preventDefault(); + openRow(row, event.metaKey || event.ctrlKey); + } + }, + [activeIndex, onExit, openRow, query.length, result.rows], + ); + + const isLoading = + searchable && + (!searchResultsAreCurrent || + threadSearch.isDebouncing || + threadSearch.isLoading); + let emptyMessage: string | null = null; + if (result.rows.length === 0) { + emptyMessage = isLoading + ? "Searching threads" + : trimmedQuery.length === 1 + ? "Type at least 2 characters" + : (navigation.isLoading || archivedThreads.isLoading) && result.isRecent + ? "Loading recent threads" + : result.isRecent + ? "No recent threads" + : "No matching threads"; + } + + return ( + { + setScope(nextScope); + setHighlightedIndex(0); + if (listRef.current !== null) listRef.current.scrollTop = 0; + }} + /> + } + footerKeys={presentation.footerKeys} + inputLabel="Search threads" + inputRef={inputRef} + listId={listId} + listLabel="Threads" + listRef={listRef} + modeChip={{ + ...presentation.chip, + clearLabel: "Return to commands", + onClear: onExit, + }} + onInputChange={(value) => { + setQuery(value); + setHighlightedIndex(0); + if (listRef.current !== null) listRef.current.scrollTop = 0; + }} + onInputKeyDown={handleInputKeyDown} + placeholder={presentation.placeholder} + value={query} + > + {emptyMessage === null ? ( + result.rows.map((row, index) => ( + setHighlightedIndex(index)} + onSelect={() => openRow(row, false)} + /> + )) + ) : ( +

+ {emptyMessage} +

+ )} +
+ ); +} + +function ThreadSearchScopeFilter({ + inputRef, + onScopeChange, + scope, +}: { + inputRef: React.RefObject; + onScopeChange: (scope: PaletteThreadSearchScope) => void; + scope: PaletteThreadSearchScope; +}) { + const [open, setOpen] = useState(false); + const currentIndex = PALETTE_THREAD_SEARCH_SCOPES.findIndex( + (candidate) => candidate.id === scope, + ); + const current = PALETTE_THREAD_SEARCH_SCOPES[currentIndex]; + const returnToInput = () => { + setOpen(false); + inputRef.current?.focus({ preventScroll: true }); + }; + const cycle = (direction: 1 | -1) => { + const nextIndex = + (currentIndex + direction + PALETTE_THREAD_SEARCH_SCOPES.length) % + PALETTE_THREAD_SEARCH_SCOPES.length; + const next = PALETTE_THREAD_SEARCH_SCOPES[nextIndex]; + if (next !== undefined) onScopeChange(next.id); + setOpen(true); + }; + + return ( +
+ + {open ? ( +
+ {PALETTE_THREAD_SEARCH_SCOPES.map((option) => ( +
event.preventDefault()} + onClick={() => { + onScopeChange(option.id); + returnToInput(); + }} + > + {option.label} +
+ ))} +
+ ) : null} +
+ ); +} + +function ThreadSearchPaletteRow({ + id, + isActive, + onActivate, + onSelect, + row, +}: { + id: string; + isActive: boolean; + onActivate: () => void; + onSelect: () => void; + row: PaletteThreadSearchRow; +}) { + const primaryRef = useRef(null); + const matchKey = `${row.primaryText}\u0000${row.highlightRanges + .map((range) => `${range.start}:${range.end}`) + .join(",")}`; + const [windowedMatchKey, setWindowedMatchKey] = useState(null); + const shouldWindowMatch = windowedMatchKey === matchKey; + const primary = shouldWindowMatch + ? windowPaletteThreadSearchText({ + text: row.primaryText, + highlightRanges: row.highlightRanges, + }) + : { text: row.primaryText, highlightRanges: row.highlightRanges }; + + useLayoutEffect(() => { + if (shouldWindowMatch || row.highlightRanges.length === 0) return; + const container = primaryRef.current; + if (container === null) return; + const firstMatch = container.querySelector("mark"); + if (firstMatch === null) return; + const containerRect = container.getBoundingClientRect(); + const matchRect = firstMatch.getBoundingClientRect(); + if ( + matchRect.left < containerRect.left || + matchRect.right > containerRect.right + ) { + setWindowedMatchKey(matchKey); + } + }, [matchKey, row.highlightRanges.length, shouldWindowMatch]); + + const stateLabel = + row.lifecycle === "active" + ? null + : row.lifecycle === "draft" + ? "Draft" + : "Archived"; + return ( +
+ + + + + + {row.metadataText} + + + {stateLabel === null ? null : ( + + {stateLabel} + + )} +
+ ); +} + +function HighlightedText({ + ranges, + text, +}: { + ranges: readonly ThreadSearchHighlightRange[]; + text: string; +}) { + if (ranges.length === 0) return <>{text}; + const nodes: ReactNode[] = []; + let cursor = 0; + for (const range of ranges) { + const start = Math.max(cursor, Math.min(range.start, text.length)); + const end = Math.max(start, Math.min(range.end, text.length)); + if (end <= start) continue; + if (start > cursor) nodes.push(text.slice(cursor, start)); + nodes.push( + + {text.slice(start, end)} + , + ); + cursor = end; + } + if (cursor < text.length) nodes.push(text.slice(cursor)); + return nodes; +} diff --git a/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx b/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx index 1839a45e95..87f131a54e 100644 --- a/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx +++ b/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx @@ -16,6 +16,7 @@ vi.mock("@/components/commands/AppCommandProvider", () => ({ useAppCommandHandler: (command: string, handler: () => boolean) => { commandHandlers.set(command, handler); }, + useIndexedAppCommandHandlers: () => {}, useAppCommandShortcut: () => null, useAppCommandShortcuts: () => new Map(), useAppCommandRunner: () => ({ diff --git a/apps/app/src/components/layout/AppLayout.sidebar-resize.test.tsx b/apps/app/src/components/layout/AppLayout.sidebar-resize.test.tsx index a9fe9b2272..f2f2fc6249 100644 --- a/apps/app/src/components/layout/AppLayout.sidebar-resize.test.tsx +++ b/apps/app/src/components/layout/AppLayout.sidebar-resize.test.tsx @@ -10,6 +10,7 @@ const SIDEBAR_WIDTH_STORAGE_KEY = "bb.sidebar.width"; vi.mock("@/components/commands/AppCommandProvider", () => ({ useAppCommandHandler: () => {}, + useIndexedAppCommandHandlers: () => {}, useAppCommandShortcut: () => null, useAppCommandShortcuts: () => new Map(), useAppCommandRunner: () => ({ diff --git a/apps/app/src/components/ui/tab-pill.tsx b/apps/app/src/components/ui/tab-pill.tsx index 99ffdb98d5..7bdc0c4a94 100644 --- a/apps/app/src/components/ui/tab-pill.tsx +++ b/apps/app/src/components/ui/tab-pill.tsx @@ -23,6 +23,7 @@ interface TabPillCloseAction { interface TabPillProps { label: string; + className?: string; ariaLabel?: string; ariaKeyshortcuts?: string; iconOnly?: boolean; @@ -39,6 +40,7 @@ interface TabPillProps { export function TabPill({ label, + className, ariaLabel, ariaKeyshortcuts, iconOnly = false, @@ -72,6 +74,7 @@ export function TabPill({ isActive ? cn(CONTEXT_SELECTION_SURFACE_CLASS, "text-foreground") : "text-muted-foreground hover:bg-state-hover", + className, )} >