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 45d96e6ee6..1a0956560b 100644 --- a/apps/app/src/components/commands/CommandPalette.test.tsx +++ b/apps/app/src/components/commands/CommandPalette.test.tsx @@ -2,25 +2,35 @@ import { cleanup, + act, fireEvent, render, screen, waitFor, + within, } from "@testing-library/react"; import { MemoryRouter, useLocation } from "react-router-dom"; +import { createStore, Provider } from "jotai"; import { afterEach, describe, expect, it, vi } from "vitest"; import { defaultAppSettings, type AppCommandId, type AppDefaultKeybinding, type AppKeybinding, + type ThreadListEntry, } from "@bb/domain"; +import type { ThreadSearchResponse } from "@bb/server-contract"; +import { CHROME_SECTION_LABEL_CLASS } from "@bb/shared-ui/chrome-style-tokens"; import { CompactViewportOverrideProvider } from "@bb/shared-ui/hooks/use-compact-viewport"; import { AppCommandProvider, useAppCommandHandler } from "./AppCommandProvider"; import { removePluginSlotRegistrations, setPluginSlotRegistrations, } from "@/lib/plugin-slots"; +import { + resetPluginLogoStoreForTest, + setPluginLogoUrls, +} from "@/lib/plugin-logos"; import { CommandPalette } from "./CommandPalette"; import { makePluginRegistrationSet } from "@/test/fixtures/plugins"; @@ -42,30 +52,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"] }, }; @@ -82,6 +92,7 @@ function defaults(...commands: AppCommandId[]): AppDefaultKeybinding[] { const testState = vi.hoisted(() => ({ calls: [] as string[], filesAvailable: false, + showKeyboardHints: true, plugins: [] as Array<{ enabled: boolean; hasSettings: boolean; @@ -90,15 +101,61 @@ const testState = vi.hoisted(() => ({ name: string | null; }>, })); +const modeState = vi.hoisted(() => ({ + activeRecents: [] as ThreadListEntry[], + threadDraftIds: new Set(), + searchResponse: undefined as ThreadSearchResponse | undefined, + recentLoading: false, + recentError: false, + searchLoading: false, +})); +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: () => ({ data: { generalSettings: { ...defaultAppSettings, - showKeyboardHints: false, + showKeyboardHints: testState.showKeyboardHints, }, - keybindings: [PALETTE_BINDING, THREAD_SEARCH_BINDING, THREAD_NEW_BINDING], + keybindings: [PALETTE_BINDING, THREAD_NEW_BINDING, THREAD_SEARCH_BINDING], defaultKeybindings: [ PALETTE_BINDING, THREAD_SEARCH_BINDING, @@ -107,6 +164,8 @@ vi.mock("@/hooks/queries/system-queries", () => ({ "thread.next", "panel.toggle", "terminal.open", + "composer.focus", + "browser.reload", ), ], }, @@ -132,51 +191,49 @@ 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("@/components/ui/app-route-anchor", () => ({ + useRouteNavigate: () => routeNavigateMock, })); -function LocationProbe() { - const location = useLocation(); - return ( - - {JSON.stringify({ - pathname: location.pathname, - search: location.search, - state: location.state, - })} - - ); -} +vi.mock("@/hooks/usePromptDraftStorage", () => ({ + usePromptDraftHasInput: (scope: { kind: string; threadId?: string }) => + scope.kind === "thread" && + modeState.threadDraftIds.has(scope.threadId ?? ""), +})); + +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: modeState.recentLoading, + isError: modeState.recentError, + }), +})); + +vi.mock("@/hooks/queries/thread-queries", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + useThreadSearch: ({ query }: { query: string }) => ({ + data: modeState.searchResponse, + debouncedQuery: query.trim(), + hasSearchableQuery: query.trim().length >= 2, + isDebouncing: false, + isError: false, + isFetching: false, + isLoading: modeState.searchLoading, + }), + }; +}); function Handler({ command }: { command: AppCommandId }) { useAppCommandHandler(command, () => { @@ -186,31 +243,87 @@ function Handler({ command }: { command: AppCommandId }) { return null; } -function renderPalette(isCompactViewport = false) { - const result = render( - - - - - - - - - - - , - { - wrapper: ({ children }) => ( - - {children} - - ), +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, + environmentPath: null, + environmentProviderId: null, + environmentIsWorktree: null, + environmentName: null, + environmentBranchName: null, + environmentWorkspaceDisplayKind: "other", + runtime: { displayStatus: "idle", hostReconnectGraceExpiresAt: null }, + queuedWork: "none", + ...overrides, + }; +} + +function renderPalette({ compact = false }: { compact?: boolean } = {}) { + const store = createStore(); + const result = render( + + + + + + + + + + + + + + + + + + , ); screen.getByTestId("origin").focus(); - return result; + return { ...result, store }; } function openPalette(): KeyboardEvent { @@ -237,8 +350,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") @@ -248,9 +364,18 @@ afterEach(() => { cleanup(); removePluginSlotRegistrations("linear"); removePluginSlotRegistrations("automations"); + resetPluginLogoStoreForTest(); testState.calls.length = 0; testState.filesAvailable = false; + testState.showKeyboardHints = true; testState.plugins.length = 0; + modeState.activeRecents = []; + modeState.threadDraftIds.clear(); + modeState.searchResponse = undefined; + modeState.recentLoading = false; + modeState.recentError = false; + modeState.searchLoading = false; + routeNavigateMock.mockReset(); window.localStorage.clear(); }); @@ -260,10 +385,572 @@ 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(19); + 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", "Settings"]); + expect( + within(commandList()).queryByRole("group", { name: "Plugins" }), + ).toBeNull(); + for (const [index, label] of ["Threads", "Actions", "Settings"].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-2", "py-1"); + expectNoClasses(header, "bg-muted/30"); + } + expectClasses(commandList(), "p-1"); + expectClasses(commandList().parentElement, "overflow-hidden"); + expectClasses( + screen.getByTestId("command-palette"), + "max-w-[640px]", + "shadow-lg", + "sm:rounded-xl", + ); + expectClasses(searchField().closest("[data-palette-input-frame]"), "h-10"); + expectNoClasses( + searchField().closest("[data-palette-input-frame]"), + "border", + "bg-command-palette-search", + "rounded-md", + "shadow-xs", + "px-3", + ); + expectClasses( + searchField().closest("[data-palette-input-band]"), + "border-b", + "bg-background", + "px-3", + "py-1", + ); + 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]"); + expect(rootFooter).toBeNull(); + const rootDescriptionId = searchField().getAttribute("aria-describedby"); + expect(rootDescriptionId).not.toBeNull(); + expectText( + document.getElementById(rootDescriptionId ?? ""), + "Use Escape to close the command palette.", + ); + + 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).not.toContain("Window and layout"); + expect(actionRows[1]?.textContent).not.toContain("Workspace"); + expect(actionRows[2]?.textContent).not.toContain("Composer and models"); + expect(actionRows[3]?.textContent).toContain("Browser"); + for (const row of [...threadRows, ...actionRows]) { + expect(row.classList.contains("px-2")).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 () => { + modeState.activeRecents = [makeThread("selected")]; + 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, "bg-state-active"); + expectNoClasses(modeSelect.parentElement, "bg-background/70"); + expectAttribute( + screen.getByRole("button", { name: "Return to commands" }), + "data-tab-pill-close", + ); + expect(screen.queryByRole("button", { name: "Thread scope" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Open in split" })).toBeNull(); + expect(document.querySelector("[data-palette-footer]")).toBeNull(); + const threadInput = screen.getByRole("combobox", { + name: "Search threads", + }); + const threadDescriptionId = threadInput.getAttribute("aria-describedby"); + expect(threadDescriptionId).not.toBeNull(); + expectText( + document.getElementById(threadDescriptionId ?? ""), + "Use Escape to return to commands.", + ); + + 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 commandsAfterExit = optionTitles(); + expect(commandsAfterExit).toEqual( + expect.arrayContaining([ + expect.stringContaining("New thread"), + expect.stringContaining("General settings"), + ]), + ); + expect( + screen + .getByTestId("command-palette") + .querySelector("[data-palette-footer]"), + ).toBeNull(); + + 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([]); + fireEvent.keyDown(searchField(), { key: "Escape" }); + await waitFor(() => expect(screen.queryByRole("combobox")).toBeNull()); + openPalette(); + await waitFor(() => expect(searchField()).toBeTruthy()); + expect(optionTitles()).toEqual(commandsAfterExit); + }); + + it.each(["", "no match"])( + "shares the thread-list empty style for query '%s' without a create action", + async (query) => { + renderPalette(); + openThreadSearch(); + await screen.findByRole("combobox", { name: "Search threads" }); + fireEvent.change(searchField(), { target: { value: query } }); + const message = await screen.findByText( + query === "" ? "No threads" : "No matching threads", + ); + expectClasses(message.parentElement, "justify-center", "px-3", "py-4"); + expectClasses(message, "text-xs", "text-subtle-foreground/60"); + expect( + message.parentElement?.querySelector('[data-icon="MessageSquare"]'), + ).not.toBeNull(); + const palette = screen.getByTestId("command-palette"); + expect(within(palette).queryByText("New thread")).toBeNull(); + expect(screen.queryByRole("option")).toBeNull(); + expect(searchField().hasAttribute("aria-activedescendant")).toBe(false); + expect(palette.querySelector("[data-palette-footer]")).toBeNull(); + fireEvent.keyDown(searchField(), { key: "Enter" }); + fireEvent.keyDown(searchField(), { key: "Enter", metaKey: true }); + fireEvent.keyDown(searchField(), { key: "Enter", ctrlKey: true }); + expect( + screen.getByRole("combobox", { name: "Search threads" }), + ).toBeTruthy(); + expect(testState.calls).toEqual([]); + expect(routeNavigateMock).not.toHaveBeenCalled(); + }, + ); + + it.each([ + ["loading", "Loading threads"], + ["error", "Couldn’t load threads"], + ])( + "does not mistake %s for a genuinely empty account", + async (state, message) => { + modeState.recentLoading = state === "loading"; + modeState.recentError = state === "error"; + renderPalette(); + openThreadSearch(); + await screen.findByText(message); + expect(screen.queryByText("No threads")).toBeNull(); + expect(screen.queryByRole("option")).toBeNull(); + expect(searchField().hasAttribute("aria-activedescendant")).toBe(false); + expect( + screen + .getByTestId("command-palette") + .querySelector("[data-palette-footer]"), + ).toBeNull(); + }, + ); + + it("explains Escape at the mode exit control without adding a footer hint", async () => { + renderPalette(); + openThreadSearch(); + const close = await screen.findByRole("button", { + name: "Return to commands", + }); + act(() => close.focus()); + expectText(await screen.findByRole("tooltip"), "Return to commands (Esc)"); + expect(document.querySelector("[data-palette-footer]")).toBeNull(); + fireEvent.keyDown(close, { key: "Escape" }); + await screen.findByRole("combobox", { name: "Search commands" }); + }); + + 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("shows active recents in update order with project metadata and follow-up status", async () => { + modeState.activeRecents = [ + makeThread("older", { updatedAt: Date.now() - 100 }), + makeThread("newer", { updatedAt: Date.now(), lastReadAt: Date.now() }), + ]; + modeState.threadDraftIds.add("newer"); + modeState.searchResponse = { + active: { total: 0, results: [] }, + archived: { + total: 1, + results: [ + { + thread: makeThread("archived", { archivedAt: Date.now() }), + matches: [], + }, + ], + }, + }; + renderPalette(); + openThreadSearch(); + await screen.findByRole("combobox", { name: "Search threads" }); + const results = screen.getByRole("listbox", { name: "Threads" }); + const rows = within(results).getAllByRole("option"); + expect(rows.map((row) => row.textContent)).toEqual([ + expect.stringContaining("Title newer"), + expect.stringContaining("Title older"), + ]); + expect(screen.queryByRole("button", { name: "Thread scope" })).toBeNull(); + expect( + within(rows[0]).getByRole("img", { + name: "Thread has unsubmitted draft", + }), + ).toBeTruthy(); + expect(rows[0].querySelector('[data-icon="Edit"]')).not.toBeNull(); + expect(results.querySelector('[data-icon="Folder"]')).toBeNull(); + expectClasses(results, "p-1"); + expectClasses(within(results).getByText("Recent"), "px-2", "py-1"); + for (const row of rows) { + const metadata = row.querySelector("[data-palette-thread-metadata]"); + expectText(metadata, "Palette project"); + expectClasses( + metadata, + "block", + "truncate", + "text-subtle-foreground", + "opacity-70", + ); + expectClasses(row, "px-2", "py-1.5", "min-h-11"); + } + }); + + it("preserves title highlights and distinguishes archived matches in the status slot", async () => { + const active = makeThread("active", { title: "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: [] }] }, + }; + renderPalette(); + openThreadSearch(); + const input = await screen.findByRole("combobox", { + name: "Search threads", + }); + fireEvent.change(input, { target: { value: "match" } }); + const results = screen.getByRole("listbox", { name: "Threads" }); + const rows = within(results).getAllByRole("option"); + expect(rows).toHaveLength(2); + const match = rows[0].querySelector("mark"); + expectText(match, "Matching"); + expectClasses(match, "bg-[var(--sidebar-search-match)]", "text-foreground"); + expectClasses(match?.parentElement, "text-foreground"); + expect( + within(rows[1]).getByRole("img", { name: "Archived thread" }), + ).toBeTruthy(); + expect(rows[1].querySelector('[data-icon="Archive"]')).not.toBeNull(); + expect( + rows[1].querySelector("[data-palette-thread-metadata]")?.textContent, + ).toBe("Palette project · just now"); + expect(within(results).queryByText("Recent")).toBeNull(); + expect( + results.querySelectorAll("[data-palette-thread-status]"), + ).toHaveLength(2); + }); + + it("keeps active and archived search results and restores active recents on clear", async () => { + modeState.activeRecents = [makeThread("recent-active")]; + modeState.searchResponse = { + active: { + total: 1, + results: [{ thread: makeThread("active"), matches: [] }], + }, + archived: { + total: 1, + results: [ + { + thread: makeThread("archived", { archivedAt: Date.now() }), + matches: [], + }, + ], + }, + }; + renderPalette(); + openThreadSearch(); + const input = await screen.findByRole("combobox", { + name: "Search threads", + }); + fireEvent.change(input, { target: { value: "match" } }); + + const results = screen.getByRole("listbox", { name: "Threads" }); + const rows = within(results).getAllByRole("option"); + expect(rows).toHaveLength(2); + expect(rows[0]?.textContent).toContain("Title active"); + expect(rows[1]?.textContent).toContain("Title archived"); + expect( + within(rows[1]).getByRole("img", { name: "Archived thread" }), + ).toBeTruthy(); + + fireEvent.keyDown(input, { key: "End" }); + expect(rows[1]?.getAttribute("aria-selected")).toBe("true"); + fireEvent.change(input, { target: { value: "" } }); + expect(within(results).getAllByRole("option")).toHaveLength(1); + expect(within(results).getByRole("option").textContent).toContain( + "recent-active", + ); + expect( + within(results).getByRole("option").getAttribute("aria-selected"), + ).toBe("true"); + }); + + it.each(["", "match"])( + "shows live thread status in the trailing slot for query '%s'", + async (query) => { + const threads = [ + makeThread("idle", { lastReadAt: Date.now() }), + makeThread("working", { + lastReadAt: Date.now(), + runtime: { + displayStatus: "active", + hostReconnectGraceExpiresAt: null, + }, + }), + makeThread("draft", { lastReadAt: Date.now() }), + makeThread("waiting", { hasPendingInteraction: true }), + ]; + modeState.threadDraftIds.add("draft"); + modeState.activeRecents = threads; + modeState.searchResponse = { + active: { + total: threads.length, + results: threads.map((thread) => ({ thread, matches: [] })), + }, + archived: { total: 0, results: [] }, + }; + renderPalette(); + openThreadSearch(); + const input = await screen.findByRole("combobox", { + name: "Search threads", + }); + fireEvent.change(input, { target: { value: query } }); + const results = screen.getByRole("listbox", { name: "Threads" }); + await waitFor(() => + expect(within(results).getAllByRole("option")).toHaveLength(4), + ); + for (const [title, label, icon] of [ + ["Title idle", "Active thread", "MessageSquare"], + ["Title working", "Thread working", "Loading"], + ["Title draft", "Thread has unsubmitted draft", "Edit"], + ["Title waiting", "Thread needs user input", "CircleQuestion"], + ]) { + const row = within(results).getByRole("option", { + name: new RegExp(title), + }); + const status = within(row).getByRole("img", { name: label }); + expect(row.lastElementChild).toBe(status); + expectClasses(status, "size-4", "shrink-0"); + expect(status.querySelector(`[data-icon="${icon}"]`)).not.toBeNull(); + expect(status.hasAttribute("tabindex")).toBe(false); + expect(row.querySelector('[data-icon="Folder"]')).toBeNull(); + expect( + row.querySelector("[data-palette-thread-metadata]")?.textContent, + ).toContain("Palette project"); + } + expect(within(results).queryByText("Recent") !== null).toBe(query === ""); + }, + ); + + it("opens an archived message match with its anchor", async () => { + modeState.searchResponse = { + active: { total: 0, results: [] }, + archived: { + total: 1, + results: [ + { + thread: makeThread("archived-message", { + archivedAt: Date.now(), + }), + matches: [ + { + sourceKind: "user_message", + text: "matching archived message", + sourceSeq: 42, + highlightRanges: [{ start: 0, end: 8 }], + }, + ], + }, + ], + }, + }; + renderPalette(); + openThreadSearch(); + const input = await screen.findByRole("combobox", { + name: "Search threads", + }); + fireEvent.change(input, { target: { value: "matching" } }); + expect( + screen.getByRole("option").querySelector("mark")?.textContent, + ).toBe("matching"); + fireEvent.keyDown(input, { key: "Enter" }); + + const state = { + searchMessageSeq: 42, + searchThreadId: "archived-message", + }; + await waitFor(() => expect(screen.queryByRole("combobox")).toBeNull()); + expect(routeNavigateMock).toHaveBeenCalledWith( + "/projects/project-1/threads/archived-message", + { state }, + ); }); it("filters as the user types and keeps the selection on a live row", async () => { @@ -273,10 +960,35 @@ 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).not.toContain("Workspace"); + expect(within(commandList()).queryAllByRole("group")).toHaveLength(0); + }); + + it("keeps hidden categories searchable and browser targets visible", async () => { + renderPalette(); + openPalette(); + await waitFor(() => expect(searchField()).toBeTruthy()); + + for (const [query, title] of [ + ["window and layout", "Toggle panel"], + ["workspace", "Open terminal"], + ["composer and models", "Focus composer"], + ]) { + fireEvent.change(searchField(), { target: { value: query } }); + await waitFor(() => expect(optionTitles()).toHaveLength(1)); + expect(selectedOption()?.textContent).toContain(title); + expect(selectedOption()?.textContent?.toLowerCase()).not.toContain(query); + expect(within(commandList()).queryAllByRole("group")).toHaveLength(0); + } + + fireEvent.change(searchField(), { target: { value: "reload" } }); + await waitFor(() => expect(optionTitles()).toHaveLength(1)); + expect(selectedOption()?.textContent).toContain("Reload page"); + expect(selectedOption()?.textContent).toContain("Browser"); }); it("finds commands when the query starts with a space", async () => { @@ -294,13 +1006,13 @@ describe("CommandPalette", () => { 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.each(["Enter", "ArrowDown", "ArrowUp", "Home", "End"])( @@ -350,28 +1062,12 @@ describe("CommandPalette", () => { }, ); - it("runs the highlighted command, closes, and restores focus", async () => { - renderPalette(); - openPalette(); - await waitFor(() => expect(searchField()).toBeTruthy()); - - fireEvent.change(searchField(), { target: { value: ">toggle panel" } }); - await waitFor(() => - expect(selectedOption()?.textContent).toContain("Toggle panel"), - ); - fireEvent.keyDown(searchField(), { key: "Enter" }); - - await waitFor(() => expect(testState.calls).toEqual(["panel.toggle"])); - expect(screen.queryByRole("combobox")).toBeNull(); - expect(document.activeElement).toBe(screen.getByTestId("origin")); - }); - it("keeps composition confirmation separate from command activation", 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"), ); @@ -402,23 +1098,28 @@ describe("CommandPalette", () => { expect(screen.queryByRole("combobox")).toBeNull(); }); - it("runs a compact selection once after restoring focus", async () => { - renderPalette(true); + it("runs the highlighted command, closes, and restores focus", 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"), + ); fireEvent.keyDown(searchField(), { key: "Enter" }); await waitFor(() => expect(testState.calls).toEqual(["panel.toggle"])); + expect(screen.queryByRole("combobox")).toBeNull(); expect(document.activeElement).toBe(screen.getByTestId("origin")); }); - it("offers the last command run first the next time it opens", async () => { + it("keeps the default catalog unchanged after running a command", async () => { renderPalette(); openPalette(); await waitFor(() => expect(searchField()).toBeTruthy()); - fireEvent.change(searchField(), { target: { value: ">toggle panel" } }); + const initialTitles = optionTitles(); + fireEvent.change(searchField(), { target: { value: "toggle panel" } }); await waitFor(() => expect(selectedOption()?.textContent).toContain("Toggle panel"), ); @@ -427,7 +1128,7 @@ describe("CommandPalette", () => { openPalette(); await waitFor(() => expect(searchField()).toBeTruthy()); - expect(optionTitles()?.[0]).toContain("Toggle panel"); + expect(optionTitles()).toEqual(initialTitles); }); it("closes on Escape without running anything", async () => { @@ -488,91 +1189,14 @@ describe("CommandPalette", () => { scrollIntoView.mockRestore(); }); - it("lists a plugin's commandPaletteAction and runs it", async () => { - setPluginSlotRegistrations( - "linear", - makePluginRegistrationSet({ - 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 () => { - renderPalette(); - openPalette(); - await waitFor(() => expect(searchField()).toBeTruthy()); - - fireEvent.change(searchField(), { target: { value: ">zzzzz" } }); - - await waitFor(() => - expect(screen.getByText("No matching commands")).toBeTruthy(), - ); - 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", - ); - }); - - it("switches the open command palette to thread search", 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.each([false, true])( "opens Installed plugins in Settings (compact: %s)", - async (isCompactViewport) => { - renderPalette(isCompactViewport); + async (compact) => { + renderPalette({ compact }); openPalette(); await waitFor(() => expect(searchField()).toBeTruthy()); fireEvent.change(searchField(), { - target: { value: ">installed plugins" }, + target: { value: "installed plugins" }, }); await waitFor(() => expect(selectedOption()?.textContent).toContain("Installed plugins"), @@ -580,63 +1204,51 @@ describe("CommandPalette", () => { fireEvent.keyDown(searchField(), { key: "Enter" }); await waitFor(() => expect(screen.getByTestId("location").textContent).toBe( - JSON.stringify({ - pathname: "/settings/plugins", - search: "", - state: null, - }), + "/settings/plugins", ), ); }, ); - it("opens a specific settings page from Cmd-K", async () => { + it("opens a specific settings page", async () => { renderPalette(); - openThreadSearch(); + openPalette(); await waitFor(() => expect(searchField()).toBeTruthy()); fireEvent.change(searchField(), { - target: { value: ">keyboard settings" }, + target: { value: "keyboard settings" }, }); await waitFor(() => - expect(selectedOption()?.textContent).toContain("Keyboard"), + expect(selectedOption()?.textContent).toContain("Keyboard settings"), ); fireEvent.keyDown(searchField(), { key: "Enter" }); await waitFor(() => - expect(screen.getByTestId("location").textContent).toContain( + expect(screen.getByTestId("location").textContent).toBe( "/settings/keyboard", ), ); }); - 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" }, - }); + it("only includes Files settings when local helper access is available", async () => { + renderPalette(); + openPalette(); + await waitFor(() => expect(searchField()).toBeTruthy()); - await waitFor(() => - expect(optionTitles()).not.toContainEqual( - expect.stringContaining("Files settings"), - ), - ); - }, - ); + fireEvent.change(searchField(), { + target: { value: "files settings" }, + }); + await waitFor(() => + expect(screen.queryAllByRole("option")).toHaveLength(0), + ); - 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(() => @@ -644,7 +1256,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, @@ -653,25 +1265,32 @@ describe("CommandPalette", () => { name: "Linear", }); renderPalette(); - openThreadSearch(); + openPalette(); await waitFor(() => expect(searchField()).toBeTruthy()); + const pluginSettingsRow = await within(bucketGroup("Settings")).findByRole( + "option", + { name: "Linear settings" }, + ); + expect(pluginSettingsRow.textContent).not.toContain("Plugin settings"); + fireEvent.change(searchField(), { - target: { value: ">linear settings" }, + target: { value: "linear settings" }, }); await waitFor(() => expect(selectedOption()?.textContent).toContain("Linear settings"), ); + expect(selectedOption()?.textContent).not.toContain("Plugin settings"); 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", makePluginRegistrationSet({ @@ -690,47 +1309,86 @@ describe("CommandPalette", () => { }), ); renderPalette(); - openThreadSearch(); + openPalette(); await waitFor(() => expect(searchField()).toBeTruthy()); - fireEvent.change(searchField(), { - target: { value: ">automations" }, - }); + const pluginPageRow = within(bucketGroup("Plugins")).getByRole("option"); + expect(pluginPageRow.textContent).toBe("Automations"); + + fireEvent.change(searchField(), { target: { value: "automations" } }); await waitFor(() => expect(selectedOption()?.textContent).toContain("Automations"), ); + expect(selectedOption()?.textContent).not.toContain("Plugin pages"); 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", + makePluginRegistrationSet({ + 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.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.click(screen.getByRole("option", { name: /Matched thread/u })); + 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", - }); + expectClasses(screen.getByText("No matching commands"), "px-3", "py-4"); + 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 a403bffe1e..51c9811711 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,11 +48,21 @@ 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_INPUT_DESCRIPTION = "Use Escape to close the command palette."; +const PALETTE_PLACEHOLDER = "Search commands…"; +const MODE_ENTRY_HANDLER_PRIORITY = 100; +const MODE_BY_ACTION_ID = new Map( + PALETTE_MODES.map((mode) => [ + paletteActionIdForCommand(mode.entryCommand), + mode, + ]), +); function invocationTarget(invocation: { target: EventTarget | null; @@ -66,7 +78,10 @@ export interface CommandPaletteProps { projectId: string | null; } -export function CommandPalette({ threadId, projectId }: CommandPaletteProps) { +export function CommandPalette({ + threadId, + projectId, +}: CommandPaletteProps) { const navigate = useNavigate(); const runner = useAppCommandRunner(); const shortcuts = useAppCommandShortcuts(PALETTE_COMMAND_IDS); @@ -76,18 +91,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({ @@ -99,20 +112,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], @@ -120,12 +129,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({ @@ -143,56 +146,89 @@ export function CommandPalette({ threadId, projectId }: CommandPaletteProps) { ], [ projectId, - pluginSlots.commandPaletteActions, runner.dispatch, runner.isCommandAvailable, shortcuts, threadId, + 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], ); useAppCommandHandler("palette.open", (invocation) => { - openPalette("commands", invocationTarget(invocation)); + const target = invocationTarget(invocation); + prepareOpen(target); + setActiveModeId(null); + setOpen(true); return true; }); - useAppCommandHandler("thread.search", (invocation) => { - openPalette("threads", invocationTarget(invocation)); - return true; - }); + useIndexedAppCommandHandlers( + PALETTE_MODE_ENTRY_COMMANDS, + (index, invocation) => { + const mode = PALETTE_MODES[index]; + if (mode === undefined) return false; + const target = invocationTarget(invocation); + 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 commandQuery = query.startsWith(">") ? query.slice(1) : query; + const ranked = useMemo( () => rankPaletteActions({ - actions: commandActions, - query: modeQuery, + actions: availableActions, + query: commandQuery, recentIds: recents, }), - [commandActions, modeQuery, recents], + [availableActions, commandQuery, recents], + ); + const isGroupedRoot = commandQuery.trim() === ""; + const rootGroups = useMemo(() => { + const groups = PALETTE_ACTION_BUCKETS.map((bucket) => ({ + bucket, + entries: ranked.filter((entry) => entry.action.bucket === bucket), + })).filter((group) => 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); @@ -205,39 +241,19 @@ export function CommandPalette({ threadId, projectId }: CommandPaletteProps) { }, [activeIndex]); const chooseAction = useCallback((action: PaletteAction) => { - if (action.id === "app:thread.search") { - setQuery(""); - setHighlightedIndex(0); - if (listRef.current !== null) listRef.current.scrollTop = 0; + setRecents((current) => recordPaletteRecent(current, action.id)); + if (MODE_BY_ACTION_ID.has(action.id)) { + action.run(); 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, - }, - }, - ); - }; - setOpen(false); - }, - [navigate], - ); + const runAfterClose = useCallback((run: () => void) => { + pendingRunRef.current = run; + setOpen(false); + }, []); const handleAfterCloseAutoFocus = useCallback(() => { const pending = pendingRunRef.current; @@ -249,15 +265,24 @@ export function CommandPalette({ threadId, projectId }: CommandPaletteProps) { pending?.(); }, []); + const handleOpenChange = useCallback((nextOpen: boolean) => { + setOpen(nextOpen); + if (!nextOpen) { + setActiveModeId(null); + setQuery(""); + setHighlightedIndex(0); + } + }, []); + const handleKeyDown = useCallback( (event: ReactKeyboardEvent) => { if (event.nativeEvent.isComposing) return; - 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; } @@ -265,7 +290,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; } @@ -278,113 +303,127 @@ 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); + const exitMode = () => { + setActiveModeId(null); + setQuery(""); + setHighlightedIndex(0); + }; return ( - + { + if (activeMode !== undefined) { + event.preventDefault(); + exitMode(); + } + }} 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)} + /> + )) + )} +
+ ) : ( + -
-
- {mode === "commands" && rankedCommands.length === 0 ? ( -

- No matching commands -

- ) : mode === "commands" ? ( - rankedCommands.map((entry, index) => ( - setHighlightedIndex(index)} - onSelect={() => chooseAction(entry.action)} - /> - )) - ) : ( - - )} -
+ )}
); @@ -394,44 +433,55 @@ 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 === "Browser" || entry.action.id.startsWith("plugin:") + ? entry.action.group + : null; + 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/PaletteShell.tsx b/apps/app/src/components/commands/PaletteShell.tsx new file mode 100644 index 0000000000..156af0a7a2 --- /dev/null +++ b/apps/app/src/components/commands/PaletteShell.tsx @@ -0,0 +1,163 @@ +import { + useId, + type KeyboardEventHandler, + type ReactNode, + type Ref, +} from "react"; +import { useComposedRefs } from "@radix-ui/react-compose-refs"; +import { Icon } from "@bb/shared-ui/icon"; +import { TooltipProvider } from "@bb/shared-ui/tooltip"; +import { useScrollOverflowState } from "@/components/thread/timeline/useScrollOverflowState"; +import { TabPill } from "@/components/ui/tab-pill"; + +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; + children: ReactNode; + inputDescription: 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, + children, + inputDescription, + inputLabel, + inputRef, + listId, + listLabel, + listRef, + modeChip, + onInputChange, + onInputKeyDown, + placeholder, + value, +}: PaletteShellProps) { + const inputDescriptionId = useId(); + 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} + /> + + {inputDescription} + +
+
+
+
+
+ {children} +
+
+
+ + ); +} + +function PaletteModeChip({ + clearLabel, + icon, + label, + onClear, +}: PaletteModeChipProps) { + return ( + { + if (event.key !== "Escape") return; + event.preventDefault(); + event.stopPropagation(); + onClear(); + }} + > + undefined} + leadingVisual={} + closeAction={{ + onClose: onClear, + closeLabel: clearLabel, + tooltip: `${clearLabel} (Esc)`, + }} + /> + + ); +} diff --git a/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx b/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx new file mode 100644 index 0000000000..47ff5fdd9c --- /dev/null +++ b/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx @@ -0,0 +1,432 @@ +import { + useCallback, + useEffect, + useId, + useLayoutEffect, + useMemo, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type ReactNode, +} from "react"; +import { Icon } from "@bb/shared-ui/icon"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@bb/shared-ui/tooltip"; +import { cn } from "@bb/shared-ui/lib/utils"; +import { CHROME_SECTION_LABEL_CLASS } from "@bb/shared-ui/chrome-style-tokens"; +import { + getThreadListIndicatorLabel, + hasActiveBackgroundAgentActivity, + hasActiveBackgroundCommandActivity, + hasActiveGoalActivity, + hasActivePlanModeActivity, + hasActiveWorkflowActivity, + isRuntimeBusyThread, + isUnreadDoneThread, + resolveThreadListIndicator, + type ThreadListIndicatorState, +} from "@bb/client-core"; +import type { ThreadSearchMatch } from "@bb/server-contract"; +import { usePromptDraftHasInput } from "@/hooks/usePromptDraftStorage"; +import { ThreadStatusGlyph } from "@/components/sidebar/ThreadRow"; +import { useSidebarNavigation } from "@/hooks/queries/sidebar-navigation-query"; +import { + hasThreadSearchableQuery, + useThreadSearch, +} from "@/hooks/queries/thread-queries"; +import { useRouteNavigate } from "@/components/ui/app-route-anchor"; +import { + NO_THREADS_MESSAGE, + ThreadListEmptyState, +} from "@/components/thread/ThreadListEmptyState"; +import { getThreadRoutePath } from "@/lib/route-paths"; +import { + buildPaletteThreadSearchRows, + type PaletteThreadSearchRow, +} 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 navigate = useRouteNavigate(); + const [query, setQuery] = useState(""); + const [highlightedIndex, setHighlightedIndex] = useState(0); + const [now] = useState(() => Date.now()); + const navigation = useSidebarNavigation(); + 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 result = useMemo( + () => + buildPaletteThreadSearchRows({ + now, + projectNamesById, + query, + recentThreads, + searchResponse: threadSearch.data, + searchResultsAreCurrent, + }), + [ + now, + projectNamesById, + query, + recentThreads, + searchResultsAreCurrent, + threadSearch.data, + ], + ); + const activeIndex = + result.rows.length === 0 + ? -1 + : Math.min(highlightedIndex, result.rows.length - 1); + const isRecentLoading = result.isRecent && navigation.isLoading; + const hasLoadError = result.isRecent + ? navigation.isError + : searchResultsAreCurrent && threadSearch.isError; + const showThreadListEmptyState = + result.rows.length === 0 && + result.isRecent && + !isRecentLoading && + !hasLoadError; + 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) => { + runAfterClose(() => { + const state = + row.messageSeq === null + ? undefined + : { + searchMessageSeq: row.messageSeq, + searchThreadId: row.threadId, + }; + navigate( + getThreadRoutePath({ + projectId: row.projectId, + threadId: row.threadId, + }), + { state }, + ); + }); + }, + [navigate, runAfterClose], + ); + + const handleInputKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + if (event.nativeEvent.isComposing) return; + 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); + } + }, + [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 || isRecentLoading + ? result.isRecent + ? "Loading threads" + : "Searching threads" + : hasLoadError + ? "Couldn’t load threads" + : trimmedQuery.length === 1 + ? "Type at least 2 characters" + : result.isRecent + ? NO_THREADS_MESSAGE + : "No matching threads"; + } + + return ( + { + setQuery(value); + setHighlightedIndex(0); + if (listRef.current !== null) listRef.current.scrollTop = 0; + }} + onInputKeyDown={handleInputKeyDown} + placeholder={presentation.placeholder} + value={query} + > + {result.isRecent && result.rows.length > 0 ? ( +
+ Recent +
+ ) : null} + {emptyMessage === null ? ( + result.rows.map((row, index) => ( + setHighlightedIndex(index)} + onSelect={() => openRow(row)} + /> + )) + ) : showThreadListEmptyState || + (searchable && !isLoading && !hasLoadError) ? ( + + ) : ( +

+ {emptyMessage} +

+ )} +
+ ); +} + +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 metadata = row.metadataText; + return ( +
+ + + + + {metadata.length === 0 ? null : ( + + {metadata} + + )} + + +
+ ); +} + +function ThreadSearchPaletteStatus({ row }: { row: PaletteThreadSearchRow }) { + const hasUnsubmittedDraft = usePromptDraftHasInput({ + kind: "thread", + projectId: row.projectId, + threadId: row.threadId, + }); + const thread = row.thread; + const unread = isUnreadDoneThread(thread); + const state: ThreadListIndicatorState = { + hasPendingInteraction: thread.hasPendingInteraction, + hasUnsubmittedDraft: hasUnsubmittedDraft, + hasUnreadError: unread && thread.status === "error", + hasUnreadSuccess: unread && thread.status !== "error", + isBackgroundAgentActive: hasActiveBackgroundAgentActivity(thread), + isBackgroundCommandActive: hasActiveBackgroundCommandActivity(thread), + isGoalActive: hasActiveGoalActivity(thread), + isPlanModeActive: hasActivePlanModeActivity(thread), + isRuntimeActive: isRuntimeBusyThread(thread), + isWorkflowActive: hasActiveWorkflowActivity(thread), + queuedWork: thread.queuedWork, + }; + const kind = resolveThreadListIndicator(state); + const archived = row.lifecycle === "archived"; + const label = archived + ? "Archived thread" + : (getThreadListIndicatorLabel(kind) ?? "Active thread"); + return ( + + + + + + + {label} + + ); +} + +function HighlightedText({ + ranges, + text, +}: { + ranges: readonly ThreadSearchMatch["highlightRanges"][number][]; + 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 202feab197..e776a35a57 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/sidebar/ProjectRow.tsx b/apps/app/src/components/sidebar/ProjectRow.tsx index 1c4b605aad..c2e7376590 100644 --- a/apps/app/src/components/sidebar/ProjectRow.tsx +++ b/apps/app/src/components/sidebar/ProjectRow.tsx @@ -52,8 +52,8 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@bb/shared-ui/dropdown-menu"; -import { EmptyState } from "@bb/shared-ui/empty-state"; -import { Icon, type IconName } from "@bb/shared-ui/icon"; +import { Icon } from "@bb/shared-ui/icon"; +import { ThreadListEmptyState } from "@/components/thread/ThreadListEmptyState"; import { SidebarMenuSkeleton, SidebarStickyGroup, @@ -442,26 +442,12 @@ export function formatArchivedEnvironmentThreadsToastTitle({ return `Archived ${getThreadDisplayTitle(archivedThread)}`; } -function getProjectThreadTreeEmptyStateIcon( - variant: ProjectThreadTreeVariant, -): IconName | undefined { - if (variant === "section") { - return "MessageSquare"; - } - - return undefined; -} - function getProjectThreadTreeEmptyStateClassName( variant: ProjectThreadTreeVariant, ): string { return cn("py-0.5", variant === "section" ? "px-2" : "pl-8 pr-2"); } -function getProjectThreadTreeEmptyStateMessageClassName(): string { - return "text-xs leading-4 text-subtle-foreground/60"; -} - function getProjectThreadTreeGroupLineClassName( variant: ProjectThreadTreeVariant, ): string | undefined { @@ -1982,16 +1968,14 @@ export const ProjectThreadTree = memo(function ProjectThreadTree({ if (rootItems.length === 0) { const emptyState = ( - ); @@ -2152,16 +2136,13 @@ export const ChronologicalSectionThreadSections = memo( renderedSectionDnd?.dragOverParentKey === CHRONOLOGICAL_CONTAINER_ID && loosePreviewBeforeKey === null; const looseEmptyState = ( - ); const threadsListContent = diff --git a/apps/app/src/components/thread/ThreadListEmptyState.tsx b/apps/app/src/components/thread/ThreadListEmptyState.tsx new file mode 100644 index 0000000000..75ab7ac6f9 --- /dev/null +++ b/apps/app/src/components/thread/ThreadListEmptyState.tsx @@ -0,0 +1,23 @@ +import { EmptyState } from "@bb/shared-ui/empty-state"; + +export const NO_THREADS_MESSAGE = "No threads"; + +export function ThreadListEmptyState({ + message = NO_THREADS_MESSAGE, + showIcon = true, + className, +}: { + message?: string; + showIcon?: boolean; + className?: string; +}) { + return ( + + ); +} diff --git a/apps/app/src/components/ui/tab-pill.tsx b/apps/app/src/components/ui/tab-pill.tsx index 5eba8aed7f..e07ca8091b 100644 --- a/apps/app/src/components/ui/tab-pill.tsx +++ b/apps/app/src/components/ui/tab-pill.tsx @@ -4,6 +4,7 @@ import { cn } from "@bb/shared-ui/lib/utils"; import { LIST_HOVER_TRANSITION } from "@bb/shared-ui/motion"; import type { ReactNode } from "react"; import { CONTEXT_SELECTION_SURFACE_CLASS } from "./context-selection"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@bb/shared-ui/tooltip"; const TAB_PILL_DEFAULT_LABEL_MAX_WIDTH_CLASS = "max-w-[180px]"; const TAB_PILL_AFFORDANCE_BUTTON_BASE_CLASS = @@ -18,10 +19,12 @@ const TAB_PILL_LEADING_VISUAL_CLASS = interface TabPillCloseAction { onClose: () => void; closeLabel: string; + tooltip?: string; } interface TabPillProps { label: string; + className?: string; ariaLabel?: string; ariaKeyshortcuts?: string; iconOnly?: boolean; @@ -37,6 +40,7 @@ interface TabPillProps { export function TabPill({ label, + className, ariaLabel, ariaKeyshortcuts, iconOnly = false, @@ -65,6 +69,7 @@ export function TabPill({ isActive ? cn(CONTEXT_SELECTION_SURFACE_CLASS, "text-foreground") : "text-muted-foreground hover:bg-state-hover", + className, )} > + ) : null}
); } + +function TabPillCloseButton({ + closeAction, + enlargeCloseTargetOnCoarsePointer, +}: { + closeAction: TabPillCloseAction; + enlargeCloseTargetOnCoarsePointer: boolean; +}) { + const button = ( + + ); + return closeAction.tooltip === undefined ? ( + button + ) : ( + + {button} + {closeAction.tooltip} + + ); +} diff --git a/apps/app/src/lib/command-palette/palette-action.ts b/apps/app/src/lib/command-palette/palette-action.ts index 0212e93d2e..be819f9b61 100644 --- a/apps/app/src/lib/command-palette/palette-action.ts +++ b/apps/app/src/lib/command-palette/palette-action.ts @@ -1,7 +1,17 @@ import type { AppShortcutPresentation } from "@/lib/app-keybindings"; +export const PALETTE_ACTION_BUCKETS = [ + "Threads", + "Actions", + "Settings", + "Plugins", +] as const; + +export type PaletteActionBucket = (typeof PALETTE_ACTION_BUCKETS)[number]; + export interface PaletteAction { id: string; + bucket: PaletteActionBucket; group: string; title: string; shortcut: AppShortcutPresentation | null; diff --git a/apps/app/src/lib/command-palette/palette-app-commands.test.ts b/apps/app/src/lib/command-palette/palette-app-commands.test.ts index ca90f7d23c..495515276e 100644 --- a/apps/app/src/lib/command-palette/palette-app-commands.test.ts +++ b/apps/app/src/lib/command-palette/palette-app-commands.test.ts @@ -71,11 +71,21 @@ describe("buildAppCommandActions", () => { ); expect(actions[0]).toMatchObject({ id: "app:thread.new", + bucket: "Threads", group: "Threads", shortcut: SHORTCUT, }); }); + it("buckets non-thread commands as actions without replacing their metadata group", () => { + const { actions } = build(["panel.toggle"]); + expect(actions[0]).toMatchObject({ + id: "app:panel.toggle", + bucket: "Actions", + group: "Window and layout", + }); + }); + it("leaves the shortcut null for a command the user has not bound", () => { const { actions } = build(["thread.rename"]); expect(actions[0]?.shortcut).toBeNull(); diff --git a/apps/app/src/lib/command-palette/palette-app-commands.ts b/apps/app/src/lib/command-palette/palette-app-commands.ts index 79c6cccfe7..d3815e76d1 100644 --- a/apps/app/src/lib/command-palette/palette-app-commands.ts +++ b/apps/app/src/lib/command-palette/palette-app-commands.ts @@ -35,6 +35,7 @@ export function buildAppCommandActions( if (!args.isCommandAvailable(command, args.target)) continue; actions.push({ id: paletteActionIdForCommand(command), + bucket: group.label === "Threads" ? "Threads" : "Actions", group: group.label, title: metadata.label, shortcut: args.shortcuts.get(command) ?? null, diff --git a/apps/app/src/lib/command-palette/palette-mode.ts b/apps/app/src/lib/command-palette/palette-mode.ts new file mode 100644 index 0000000000..8e34d63c46 --- /dev/null +++ b/apps/app/src/lib/command-palette/palette-mode.ts @@ -0,0 +1,24 @@ +import type { ComponentType } from "react"; +import type { AppCommandId } from "@bb/domain"; +import type { IconName } from "@bb/shared-ui/icon"; + +export interface PaletteModePresentation { + chip: { + icon: IconName; + label: string; + }; + inputDescription: string; + placeholder: string; +} + +export interface PaletteModeViewProps { + onExit: () => void; + runAfterClose: (run: () => void) => void; + presentation: PaletteModePresentation; +} + +export interface PaletteModeRegistration extends PaletteModePresentation { + id: string; + entryCommand: AppCommandId; + View: ComponentType; +} diff --git a/apps/app/src/lib/command-palette/palette-modes.ts b/apps/app/src/lib/command-palette/palette-modes.ts new file mode 100644 index 0000000000..dea93ac3ea --- /dev/null +++ b/apps/app/src/lib/command-palette/palette-modes.ts @@ -0,0 +1,18 @@ +import type { PaletteModeRegistration } from "./palette-mode"; +import { ThreadSearchPaletteMode } from "@/components/commands/ThreadSearchPaletteMode"; + +export const PALETTE_MODES: readonly PaletteModeRegistration[] = [ + { + id: "thread-search", + entryCommand: "thread.search", + chip: { icon: "Search", label: "Threads" }, + placeholder: "Search title, project, or message…", + inputDescription: + "Use Escape to return to commands.", + View: ThreadSearchPaletteMode, + }, +]; + +export const PALETTE_MODE_ENTRY_COMMANDS = PALETTE_MODES.map( + (mode) => mode.entryCommand, +); diff --git a/apps/app/src/lib/command-palette/palette-plugin-actions.test.ts b/apps/app/src/lib/command-palette/palette-plugin-actions.test.ts index 407bdce73a..9efe95d8a9 100644 --- a/apps/app/src/lib/command-palette/palette-plugin-actions.test.ts +++ b/apps/app/src/lib/command-palette/palette-plugin-actions.test.ts @@ -1,6 +1,10 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { PluginThreadPanelOpenHandler } from "@/components/plugin/plugin-thread-panel-navigation"; import type { PluginCommandPaletteActionSlot } from "@/lib/plugin-slots"; +import { + resetPluginLogoStoreForTest, + setPluginLogoUrls, +} from "@/lib/plugin-logos"; import { buildPluginPaletteActions } from "./palette-plugin-actions"; function slot( @@ -27,7 +31,42 @@ function build( }); } +afterEach(() => { + resetPluginLogoStoreForTest(); + vi.restoreAllMocks(); +}); + describe("buildPluginPaletteActions", () => { + it("buckets plugin actions together and attributes them to the manifest name", () => { + setPluginLogoUrls( + new Map([ + [ + "linear", + { + displayName: "Linear", + icon: null, + compactIconUrl: null, + logoUrl: null, + logoDarkUrl: null, + icons: new Map(), + }, + ], + ]), + ); + + expect(build([slot({ id: "listed" })])[0]).toMatchObject({ + bucket: "Plugins", + group: "Linear", + }); + }); + + it("uses the stable plugin id when the manifest name is unavailable", () => { + expect(build([slot({ id: "listed" })])[0]).toMatchObject({ + bucket: "Plugins", + group: "linear", + }); + }); + it("drops a row whose isAvailable declines or throws", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const rows = build([ diff --git a/apps/app/src/lib/command-palette/palette-plugin-actions.ts b/apps/app/src/lib/command-palette/palette-plugin-actions.ts index a271871a2f..43af2fea64 100644 --- a/apps/app/src/lib/command-palette/palette-plugin-actions.ts +++ b/apps/app/src/lib/command-palette/palette-plugin-actions.ts @@ -1,6 +1,7 @@ import type { PluginCommandPaletteActionContext } from "@get-bb/plugin-sdk"; import type { PluginThreadPanelOpenHandler } from "@/components/plugin/plugin-thread-panel-navigation"; import type { PluginCommandPaletteActionSlot } from "@/lib/plugin-slots"; +import { getPluginDisplayName } from "@/lib/plugin-logos"; import type { PaletteAction } from "./palette-action"; export interface BuildPluginPaletteActionsArgs { @@ -53,7 +54,8 @@ export function buildPluginPaletteActions( } actions.push({ id: `plugin:${slot.pluginId}/${slot.id}`, - group: "Plugins", + bucket: "Plugins", + group: getPluginDisplayName(slot.pluginId), title: slot.title, shortcut: null, run: () => { diff --git a/apps/app/src/lib/command-palette/palette-plugin-page-actions.ts b/apps/app/src/lib/command-palette/palette-plugin-page-actions.ts index 64f869d980..e02eada2b1 100644 --- a/apps/app/src/lib/command-palette/palette-plugin-page-actions.ts +++ b/apps/app/src/lib/command-palette/palette-plugin-page-actions.ts @@ -12,6 +12,7 @@ export function buildPluginPagePaletteActions( ): PaletteAction[] { return args.panels.map((panel) => ({ id: `plugin-page:${panel.pluginId}/${panel.id}`, + bucket: "Plugins" as const, group: "Plugin pages", title: panel.title, shortcut: null, diff --git a/apps/app/src/lib/command-palette/palette-ranking.test.ts b/apps/app/src/lib/command-palette/palette-ranking.test.ts index b6ed514918..0146b94e40 100644 --- a/apps/app/src/lib/command-palette/palette-ranking.test.ts +++ b/apps/app/src/lib/command-palette/palette-ranking.test.ts @@ -3,7 +3,14 @@ import type { PaletteAction } from "./palette-action"; import { rankPaletteActions } from "./palette-ranking"; function action(id: string, title: string, group: string): PaletteAction { - return { id, title, group, shortcut: null, run: () => {} }; + return { + id, + bucket: group === "Threads" ? "Threads" : "Actions", + title, + group, + shortcut: null, + run: () => {}, + }; } const ACTIONS: readonly PaletteAction[] = [ @@ -25,7 +32,7 @@ describe("rankPaletteActions", () => { ).toEqual(["New thread", "Next thread", "Toggle panel", "Reload page"]); }); - it("floats recently run actions to the top, most recent first", () => { + it("keeps the default catalog stable even with usage history", () => { expect( titlesOf( rankPaletteActions({ @@ -34,7 +41,7 @@ describe("rankPaletteActions", () => { recentIds: ["app:browser.reload", "app:panel.toggle"], }), ), - ).toEqual(["Reload page", "Toggle panel", "New thread", "Next thread"]); + ).toEqual(["New thread", "Next thread", "Toggle panel", "Reload page"]); }); it("ignores history entries for actions that are not listed", () => { @@ -46,7 +53,19 @@ describe("rankPaletteActions", () => { recentIds: ["plugin:gone/vanished", "app:thread.next"], }), ), - ).toEqual(["Next thread", "New thread", "Toggle panel", "Reload page"]); + ).toEqual(["New thread", "Next thread", "Toggle panel", "Reload page"]); + }); + + it("keeps the full catalog browsable while limiting search results", () => { + const actions = Array.from({ length: 60 }, (_, index) => + action(`plugin:${index}`, `Plugin action ${index}`, "Plugins"), + ); + expect( + rankPaletteActions({ actions, query: " ", recentIds: [] }), + ).toHaveLength(60); + expect( + rankPaletteActions({ actions, query: "Plugin action", recentIds: [] }), + ).toHaveLength(50); }); it("matches the group so a query can name a section", () => { diff --git a/apps/app/src/lib/command-palette/palette-ranking.ts b/apps/app/src/lib/command-palette/palette-ranking.ts index 3802b751c6..93e272dd8b 100644 --- a/apps/app/src/lib/command-palette/palette-ranking.ts +++ b/apps/app/src/lib/command-palette/palette-ranking.ts @@ -32,6 +32,10 @@ function titleMatchPositions(title: string, query: string): number[] { export function rankPaletteActions( args: RankPaletteActionsArgs, ): RankedPaletteAction[] { + if (args.query.trim() === "") { + return args.actions.map((action) => ({ action, positions: [] })); + } + const buildOrder = new Map( args.actions.map((action, index) => [action.id, index]), ); @@ -42,16 +46,6 @@ export function rankPaletteActions( recentRank.get(action.id) ?? Number.MAX_SAFE_INTEGER; const orderOf = (action: PaletteAction) => buildOrder.get(action.id) ?? 0; - if (args.query.trim() === "") { - return [...args.actions] - .sort( - (left, right) => - rankOf(left) - rankOf(right) || orderOf(left) - orderOf(right), - ) - .slice(0, PALETTE_RESULT_LIMIT) - .map((action) => ({ action, positions: [] })); - } - const matches = fuzzyMatchText({ items: args.actions, query: args.query, diff --git a/apps/app/src/lib/command-palette/palette-settings-actions.ts b/apps/app/src/lib/command-palette/palette-settings-actions.ts index ec8f67ec09..71150bd385 100644 --- a/apps/app/src/lib/command-palette/palette-settings-actions.ts +++ b/apps/app/src/lib/command-palette/palette-settings-actions.ts @@ -18,6 +18,7 @@ export function buildSettingsPaletteActions( return [ ...args.sections.map((section) => ({ id: `settings:${section.id}`, + bucket: "Settings" as const, group: "Settings", title: `${section.label} settings`, shortcut: null, @@ -25,6 +26,7 @@ export function buildSettingsPaletteActions( })), ...args.pluginEntries.map((plugin) => ({ id: `settings:plugin:${plugin.id}`, + bucket: "Settings" as const, group: "Plugin settings", title: `${plugin.label} settings`, shortcut: null, diff --git a/apps/app/src/lib/command-palette/palette-thread-search-window.test.ts b/apps/app/src/lib/command-palette/palette-thread-search-window.test.ts new file mode 100644 index 0000000000..30826b15ea --- /dev/null +++ b/apps/app/src/lib/command-palette/palette-thread-search-window.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; +import { windowPaletteThreadSearchText } from "./palette-thread-search-window"; + +function highlightedText( + result: ReturnType, +): string[] { + return result.highlightRanges.map((range) => + result.text.slice(range.start, range.end), + ); +} + +describe("windowPaletteThreadSearchText", () => { + it("returns short text whole without ellipses", () => { + expect( + windowPaletteThreadSearchText({ + text: "A short matched message", + highlightRanges: [{ start: 8, end: 15 }], + }), + ).toEqual({ + text: "A short matched message", + highlightRanges: [{ start: 8, end: 15 }], + }); + }); + + it("marks both real cuts and preserves the first match", () => { + const text = `${"left".repeat(12)} needle ${"right".repeat(12)}`; + const matchStart = text.indexOf("needle"); + const result = windowPaletteThreadSearchText({ + text, + highlightRanges: [{ start: matchStart, end: matchStart + 6 }], + }); + + expect(result.text.startsWith("…")).toBe(true); + expect(result.text.endsWith("…")).toBe(true); + expect(highlightedText(result)).toEqual(["needle"]); + }); + + it("moves hard targets inward to whitespace word boundaries", () => { + const text = + "prefix alpha beta gamma delta MATCH one two three four five six seven trailing"; + const matchStart = text.indexOf("MATCH"); + const result = windowPaletteThreadSearchText({ + text, + highlightRanges: [{ start: matchStart, end: matchStart + 5 }], + }); + + expect(result.text).toBe( + "…gamma delta MATCH one two three four five six seven…", + ); + }); + + it("rebases every retained range and merges overlapping input", () => { + const text = `${"x".repeat(24)} first middle second ${"y".repeat(50)}`; + const firstStart = text.indexOf("first"); + const secondStart = text.indexOf("second"); + const result = windowPaletteThreadSearchText({ + text, + highlightRanges: [ + { start: secondStart + 3, end: secondStart + 6 }, + { start: firstStart + 2, end: firstStart + 5 }, + { start: firstStart, end: firstStart + 3 }, + { start: secondStart, end: secondStart + 4 }, + ], + }); + + expect(highlightedText(result)).toEqual(["first", "second"]); + expect(result.highlightRanges[0]?.start).toBe( + result.text.indexOf("first"), + ); + expect(result.highlightRanges[1]?.start).toBe( + result.text.indexOf("second"), + ); + }); + + it("clamps finite out-of-range input and drops malformed ranges", () => { + const result = windowPaletteThreadSearchText({ + text: "match remains", + highlightRanges: [ + { start: Number.NaN, end: 3 }, + { start: 9, end: 4 }, + { start: 100, end: 200 }, + { start: -20, end: 5.9 }, + ], + }); + + expect(result).toEqual({ + text: "match remains", + highlightRanges: [{ start: 0, end: 5 }], + }); + }); + + it("adds an ellipsis only on the side actually cut near either edge", () => { + const nearStartText = `match ${"tail".repeat(20)}`; + const nearStart = windowPaletteThreadSearchText({ + text: nearStartText, + highlightRanges: [{ start: 0, end: 5 }], + }); + expect(nearStart.text.startsWith("…")).toBe(false); + expect(nearStart.text.endsWith("…")).toBe(true); + + const nearEndText = `${"lead".repeat(20)} match`; + const matchStart = nearEndText.indexOf("match"); + const nearEnd = windowPaletteThreadSearchText({ + text: nearEndText, + highlightRanges: [{ start: matchStart, end: nearEndText.length }], + }); + expect(nearEnd.text.startsWith("…")).toBe(true); + expect(nearEnd.text.endsWith("…")).toBe(false); + }); + + it("never leaves either hard cut inside an emoji surrogate pair", () => { + const emoji = "\u{1f600}"; + const text = `${"a".repeat(15)}${emoji}${"b".repeat(15)}MATCH${"c".repeat( + 39, + )}${emoji}${"d".repeat(20)}`; + const matchStart = text.indexOf("MATCH"); + const result = windowPaletteThreadSearchText({ + text, + highlightRanges: [{ start: matchStart, end: matchStart + 5 }], + }); + + expect(result.text).not.toMatch(/[\uD800-\uDFFF]/u); + expect(highlightedText(result)).toEqual(["MATCH"]); + }); + + it("keeps a first match longer than the normal tail window in full", () => { + const match = "m".repeat(60); + const text = `${"lead".repeat(10)}${match}${"tail".repeat(20)}`; + const matchStart = text.indexOf(match); + const result = windowPaletteThreadSearchText({ + text, + highlightRanges: [ + { start: matchStart, end: matchStart + match.length }, + ], + }); + + expect(highlightedText(result)).toEqual([match]); + }); +}); diff --git a/apps/app/src/lib/command-palette/palette-thread-search-window.ts b/apps/app/src/lib/command-palette/palette-thread-search-window.ts new file mode 100644 index 0000000000..8016308217 --- /dev/null +++ b/apps/app/src/lib/command-palette/palette-thread-search-window.ts @@ -0,0 +1,165 @@ +import type { ThreadSearchMatch } from "@bb/server-contract"; + +const THREAD_SEARCH_WINDOW_LEAD_CHARS = 16; +const THREAD_SEARCH_WINDOW_TAIL_CHARS = 40; +const THREAD_SEARCH_WINDOW_ELLIPSIS = "…"; + +export interface WindowPaletteThreadSearchTextArgs { + text: string; + highlightRanges: readonly ThreadSearchMatch["highlightRanges"][number][]; +} + +export interface WindowedPaletteThreadSearchText { + text: string; + highlightRanges: ThreadSearchMatch["highlightRanges"][number][]; +} + +function isHighSurrogate(text: string, index: number): boolean { + const code = text.charCodeAt(index); + return code >= 0xd800 && code <= 0xdbff; +} + +function isLowSurrogate(text: string, index: number): boolean { + const code = text.charCodeAt(index); + return code >= 0xdc00 && code <= 0xdfff; +} + +function isInsideSurrogatePair(text: string, index: number): boolean { + return ( + index > 0 && + index < text.length && + isHighSurrogate(text, index - 1) && + isLowSurrogate(text, index) + ); +} + +function clampInteger(value: number, maximum: number): number | null { + if (!Number.isFinite(value)) { + return null; + } + return Math.max(0, Math.min(Math.trunc(value), maximum)); +} + +function normalizeHighlightRanges( + text: string, + ranges: readonly ThreadSearchMatch["highlightRanges"][number][], +): ThreadSearchMatch["highlightRanges"][number][] { + const normalized: ThreadSearchMatch["highlightRanges"][number][] = []; + + for (const range of ranges) { + let start = clampInteger(range.start, text.length); + let end = clampInteger(range.end, text.length); + if (start === null || end === null || end <= start) { + continue; + } + + if (isInsideSurrogatePair(text, start)) { + start -= 1; + } + if (isInsideSurrogatePair(text, end)) { + end += 1; + } + + normalized.push({ start, end }); + } + + normalized.sort( + (left, right) => left.start - right.start || left.end - right.end, + ); + + const merged: ThreadSearchMatch["highlightRanges"][number][] = []; + for (const range of normalized) { + const previous = merged.at(-1); + if (previous !== undefined && range.start < previous.end) { + previous.end = Math.max(previous.end, range.end); + continue; + } + merged.push({ ...range }); + } + return merged; +} + +function moveStartToWordBoundary( + text: string, + start: number, + firstMatchStart: number, +): number { + if (start === 0) { + return start; + } + const firstWhitespace = text.slice(start, firstMatchStart).search(/\s/u); + if (firstWhitespace === -1) { + return start; + } + + let boundary = start + firstWhitespace + 1; + while (boundary < firstMatchStart && /\s/u.test(text[boundary] ?? "")) { + boundary += 1; + } + return boundary; +} + +function moveEndToWordBoundary( + text: string, + firstMatchEnd: number, + end: number, +): number { + if (end === text.length) { + return end; + } + const lastWhitespace = text + .slice(firstMatchEnd, end) + .search(/\s\S*$/u); + return lastWhitespace > 0 ? firstMatchEnd + lastWhitespace : end; +} + +export function windowPaletteThreadSearchText({ + text, + highlightRanges, +}: WindowPaletteThreadSearchTextArgs): WindowedPaletteThreadSearchText { + const normalizedRanges = normalizeHighlightRanges(text, highlightRanges); + const firstMatch = normalizedRanges[0]; + if (firstMatch === undefined) { + return { text, highlightRanges: [] }; + } + + let start = Math.max( + 0, + firstMatch.start - THREAD_SEARCH_WINDOW_LEAD_CHARS, + ); + let end = Math.min( + text.length, + firstMatch.end + THREAD_SEARCH_WINDOW_TAIL_CHARS, + ); + + start = moveStartToWordBoundary(text, start, firstMatch.start); + end = moveEndToWordBoundary(text, firstMatch.end, end); + + if (isInsideSurrogatePair(text, start)) { + start += 1; + } + if (isInsideSurrogatePair(text, end)) { + end -= 1; + } + + const prefix = start > 0 ? THREAD_SEARCH_WINDOW_ELLIPSIS : ""; + const suffix = end < text.length ? THREAD_SEARCH_WINDOW_ELLIPSIS : ""; + const rebasedRanges: ThreadSearchMatch["highlightRanges"][number][] = []; + + for (const range of normalizedRanges) { + const rangeStart = Math.max(range.start, start); + const rangeEnd = Math.min(range.end, end); + if (rangeEnd <= rangeStart) { + continue; + } + rebasedRanges.push({ + start: rangeStart - start + prefix.length, + end: rangeEnd - start + prefix.length, + }); + } + + return { + text: `${prefix}${text.slice(start, end)}${suffix}`, + highlightRanges: rebasedRanges, + }; +} diff --git a/apps/app/src/lib/command-palette/palette-thread-search.test.ts b/apps/app/src/lib/command-palette/palette-thread-search.test.ts new file mode 100644 index 0000000000..83d6c77a2a --- /dev/null +++ b/apps/app/src/lib/command-palette/palette-thread-search.test.ts @@ -0,0 +1,194 @@ +import type { ThreadListEntry } from "@bb/domain"; +import type { ThreadSearchResponse } from "@bb/server-contract"; +import { describe, expect, it } from "vitest"; +import { buildPaletteThreadSearchRows } from "./palette-thread-search"; + +const NOW = 1_000_000; + +function makeThread( + id: string, + overrides: Partial = {}, +): ThreadListEntry { + return { + id, + projectId: "project-1", + environmentId: null, + providerId: "codex", + title: `Title ${id}`, + titleFallback: `Fallback ${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: NOW, + activity: { + activeWorkflowCount: 0, + activeBackgroundAgentCount: 0, + activeBackgroundCommandCount: 0, + activePlanModeCount: 0, + activeGoalCount: 0, + }, + hasPendingInteraction: false, + environmentHostId: null, + environmentPath: null, + environmentProviderId: null, + environmentIsWorktree: null, + environmentName: null, + environmentBranchName: null, + environmentWorkspaceDisplayKind: "other", + runtime: { displayStatus: "idle", hostReconnectGraceExpiresAt: null }, + queuedWork: "none", + ...overrides, + }; +} + +function build( + overrides: Partial[0]> = {}, +) { + return buildPaletteThreadSearchRows({ + now: NOW, + projectNamesById: new Map([["project-1", "Palette project"]]), + query: "match", + recentThreads: [], + searchResponse: { + active: { results: [], total: 0 }, + archived: { results: [], total: 0 }, + }, + searchResultsAreCurrent: true, + ...overrides, + }); +} + +describe("buildPaletteThreadSearchRows", () => { + it("preserves active and archived server matches in their ranked order", () => { + const active = makeThread("active"); + const archived = makeThread("archived", { archivedAt: NOW - 1 }); + const searchResponse: ThreadSearchResponse = { + active: { + total: 1, + results: [{ thread: active, matches: [] }], + }, + archived: { + total: 1, + results: [{ thread: archived, matches: [] }], + }, + }; + + const result = build({ + searchResponse, + }); + + expect(result.rows.map((row) => row.lifecycle)).toEqual([ + "active", + "archived", + ]); + expect(result.rows.map((row) => row.thread)).toEqual([active, archived]); + expect(result.rows.map((row) => row.metadataText)).toEqual([ + "Palette project · just now", + "Palette project · just now", + ]); + expect(result.rows.map((row) => row.threadId)).toEqual([ + "active", + "archived", + ]); + }); + + it("uses the matched message as primary while retaining title, project, and time metadata", () => { + const thread = makeThread("message", { title: "Original title" }); + const result = build({ + searchResponse: { + active: { + total: 1, + results: [ + { + thread, + matches: [ + { + sourceKind: "user_message", + text: "the matching message", + highlightRanges: [{ start: 4, end: 12 }], + sourceSeq: 42, + }, + ], + }, + ], + }, + archived: { results: [], total: 0 }, + }, + }); + + expect(result.rows[0]).toMatchObject({ + primaryText: "the matching message", + metadataText: "Original title · Palette project · just now", + messageSeq: 42, + highlightRanges: [{ start: 4, end: 12 }], + }); + }); + + it("uses active recents before typing and does not reuse them for a one-character query", () => { + const active = makeThread("recent-active"); + const archived = makeThread("recent-archived", { archivedAt: NOW - 1 }); + const recents = build({ + query: "", + searchResponse: { + active: { total: 0, results: [] }, + archived: { total: 1, results: [{ thread: archived, matches: [] }] }, + }, + recentThreads: [active], + }); + expect(recents).toMatchObject({ + isRecent: true, + rows: [{ id: "active:recent-active" }], + }); + expect(build({ query: "m", recentThreads: [active] })).toMatchObject({ + isRecent: false, + rows: [], + }); + }); + it("does not show stale server matches while a new query is debouncing", () => { + const thread = makeThread("stale"); + expect( + build({ + searchResultsAreCurrent: false, + searchResponse: { + active: { total: 1, results: [{ thread, matches: [] }] }, + archived: { total: 1, results: [{ thread, matches: [] }] }, + }, + }).rows, + ).toEqual([]); + }); + it("orders active recents by update time across projects without prioritizing pinned threads", () => { + const older = makeThread("older", { updatedAt: NOW - 100, pinnedAt: NOW }); + const newest = makeThread("newest", { + projectId: "project-2", + updatedAt: NOW, + }); + const tied = makeThread("tied", { updatedAt: NOW }); + expect( + build({ query: "", recentThreads: [older, newest, tied] }).rows.map( + (row) => row.id, + ), + ).toEqual(["active:newest", "active:tied", "active:older"]); + }); + + it("chooses the newest threads before applying the recent limit", () => { + const recentThreads = Array.from({ length: 21 }, (_, index) => + makeThread(String(index), { updatedAt: NOW + index }), + ); + const rows = build({ query: "", recentThreads }).rows; + expect(rows).toHaveLength(20); + expect(rows[0]?.threadId).toBe("20"); + expect(rows.at(-1)?.threadId).toBe("1"); + expect(recentThreads[0]?.id).toBe("0"); + }); +}); diff --git a/apps/app/src/lib/command-palette/palette-thread-search.ts b/apps/app/src/lib/command-palette/palette-thread-search.ts new file mode 100644 index 0000000000..11cae469b4 --- /dev/null +++ b/apps/app/src/lib/command-palette/palette-thread-search.ts @@ -0,0 +1,131 @@ +import { PERSONAL_PROJECT_ID, type ThreadListEntry } from "@bb/domain"; +import type { + ThreadSearchMatch, + ThreadSearchResponse, +} from "@bb/server-contract"; +import { formatRelativeTime } from "@/lib/relative-time"; +import { getThreadDisplayTitle } from "@/lib/thread-title"; + +export type PaletteThreadLifecycle = "active" | "archived"; + +export interface PaletteThreadSearchRow { + id: string; + lifecycle: PaletteThreadLifecycle; + primaryText: string; + highlightRanges: readonly ThreadSearchMatch["highlightRanges"][number][]; + metadataText: string; + projectId: string; + threadId: string; + thread: ThreadListEntry; + messageSeq: number | null; +} + +interface BuildPaletteThreadSearchRowsArgs { + now: number; + projectNamesById: ReadonlyMap; + query: string; + recentThreads: readonly ThreadListEntry[]; + searchResponse: ThreadSearchResponse | undefined; + searchResultsAreCurrent: boolean; +} + +export interface PaletteThreadSearchRowsResult { + isRecent: boolean; + rows: PaletteThreadSearchRow[]; +} + +const RECENT_THREAD_LIMIT = 20; + +function isTitleMatch(match: ThreadSearchMatch): boolean { + return match.sourceKind === "title" || match.sourceKind === "title_fallback"; +} + +function projectMetadata( + projectId: string, + projectNamesById: ReadonlyMap, +): string | null { + return projectId === PERSONAL_PROJECT_ID + ? null + : (projectNamesById.get(projectId) ?? null); +} + +function metadataText(parts: readonly (string | null)[]): string { + return parts.filter((part): part is string => Boolean(part)).join(" · "); +} + +function serverRow( + thread: ThreadListEntry, + matches: readonly ThreadSearchMatch[], + lifecycle: "active" | "archived", + projectNamesById: ReadonlyMap, + now: number, +): PaletteThreadSearchRow { + const title = getThreadDisplayTitle(thread); + const titleMatch = matches.find( + (match) => isTitleMatch(match) && match.text === title, + ); + const snippetMatch = matches.find((match) => !isTitleMatch(match)); + const primaryMatch = snippetMatch ?? titleMatch; + return { + id: `${lifecycle}:${thread.id}`, + lifecycle, + primaryText: primaryMatch?.text ?? title, + highlightRanges: primaryMatch?.highlightRanges ?? [], + metadataText: metadataText([ + snippetMatch === undefined ? null : title, + projectMetadata(thread.projectId, projectNamesById), + formatRelativeTime({ timestamp: thread.updatedAt, now }), + ]), + projectId: thread.projectId, + threadId: thread.id, + thread, + messageSeq: snippetMatch?.sourceSeq ?? null, + }; +} + +export function buildPaletteThreadSearchRows({ + now, + projectNamesById, + query, + recentThreads, + searchResponse, + searchResultsAreCurrent, +}: BuildPaletteThreadSearchRowsArgs): PaletteThreadSearchRowsResult { + const trimmedQuery = query.trim(); + const isRecent = trimmedQuery.length === 0; + const isSearchable = trimmedQuery.length >= 2; + const activeRows = isRecent + ? [...recentThreads] + .sort((left, right) => right.updatedAt - left.updatedAt) + .slice(0, RECENT_THREAD_LIMIT) + .map((thread) => serverRow(thread, [], "active", projectNamesById, now)) + : isSearchable && searchResultsAreCurrent + ? (searchResponse?.active.results ?? []).map((result) => + serverRow( + result.thread, + result.matches, + "active", + projectNamesById, + now, + ), + ) + : []; + + const archivedRows = + isSearchable && searchResultsAreCurrent + ? (searchResponse?.archived.results ?? []).map((result) => + serverRow( + result.thread, + result.matches, + "archived", + projectNamesById, + now, + ), + ) + : []; + + return { + isRecent, + rows: [...activeRows, ...archivedRows], + }; +} diff --git a/apps/app/src/lib/plugin-logos.ts b/apps/app/src/lib/plugin-logos.ts index cba055f43f..9ead7a00cb 100644 --- a/apps/app/src/lib/plugin-logos.ts +++ b/apps/app/src/lib/plugin-logos.ts @@ -31,6 +31,10 @@ function getPluginLogoUrls(): ReadonlyMap { return logoUrls; } +export function getPluginDisplayName(pluginId: string): string { + return getPluginLogoUrls().get(pluginId)?.displayName ?? pluginId; +} + export function usePluginCompactBranding( pluginId: string, ): Pick | null { diff --git a/packages/plugin-api-map/src/anatomy-manifest.json b/packages/plugin-api-map/src/anatomy-manifest.json index 6f738f355e..cbcd41db24 100644 --- a/packages/plugin-api-map/src/anatomy-manifest.json +++ b/packages/plugin-api-map/src/anatomy-manifest.json @@ -38,17 +38,19 @@ { "path": "apps/app/src/components/commands/CommandPalette.tsx", "anchors": [ - "top-[12%] max-w-xl translate-y-0 gap-0 p-0", - "aria-label={inputLabel}", - "role=\"listbox\"", + "top-[12%] max-w-[640px] translate-y-0 gap-0 p-0", "aria-selected={isActive}", - "LAUNCHER_ACTION_ROW_BASE_CLASS", + "flex min-h-9 w-full min-w-0 cursor-pointer items-center gap-3 rounded-md px-2 py-2 text-left text-sm outline-none", "bg-state-hover text-foreground" ] }, + { + "path": "apps/app/src/components/commands/PaletteShell.tsx", + "anchors": ["aria-label={inputLabel}", "role=\"listbox\""] + }, { "path": "apps/app/src/lib/command-palette/palette-plugin-actions.ts", - "anchors": ["group: \"Plugins\"", "run: () =>"] + "anchors": ["bucket: \"Plugins\"", "run: () =>"] }, { "path": "apps/app/src/components/commands/CommandPalette.test.tsx", diff --git a/packages/plugin-api-map/test/scaffold-surface-entry.test.ts b/packages/plugin-api-map/test/scaffold-surface-entry.test.ts index fa825b8534..caa5ff4832 100644 --- a/packages/plugin-api-map/test/scaffold-surface-entry.test.ts +++ b/packages/plugin-api-map/test/scaffold-surface-entry.test.ts @@ -100,6 +100,8 @@ describe("surface-entry scaffold", () => { "apps/app/src/components/commands/CommandPalette.test.tsx", "--source", "apps/app/src/components/commands/CommandPalette.tsx", + "--source", + "apps/app/src/components/commands/PaletteShell.tsx", "--api-symbol", "PluginCommandPaletteActionRegistration", "--api-symbol", @@ -115,6 +117,8 @@ describe("surface-entry scaffold", () => { "--source", "apps/app/src/components/commands/CommandPalette.tsx", "--source", + "apps/app/src/components/commands/PaletteShell.tsx", + "--source", "apps/app/src/components/commands/CommandPalette.test.tsx", "--group", "command-palette", @@ -157,6 +161,10 @@ describe("surface-entry scaffold", () => { path: "apps/app/src/components/commands/CommandPalette.tsx", anchors: ["TODO: Add a stable source anchor"], }, + { + path: "apps/app/src/components/commands/PaletteShell.tsx", + anchors: ["TODO: Add a stable source anchor"], + }, { path: "apps/app/src/lib/command-palette/palette-plugin-actions.ts", anchors: ["TODO: Add a stable source anchor"], diff --git a/plans/bb-mobile-research/ui-inventory.md b/plans/bb-mobile-research/ui-inventory.md index 52357cd074..684a52196a 100644 --- a/plans/bb-mobile-research/ui-inventory.md +++ b/plans/bb-mobile-research/ui-inventory.md @@ -21,7 +21,7 @@ Providers, outer→inner: `AppErrorBoundary` (main.tsx:60; class boundary, fallb `SidebarProvider` + `AppLayoutSidebar` (mode app/settings/tools, AppLayoutSidebar.tsx:47-77) + `SidebarInset` + `AppHeader` (hidden on thread/root/plugin-panel routes, AppLayout.tsx:576) + fixed `SidebarTriggerOverlay` (AppLayout.tsx:218) + global `ProjectPathDialog` (AppLayout.tsx:877). Handles commands `sidebar.toggle`, `thread.new`, `settings.open`, `settings.openServers` (AppLayout.tsx:174,481-495), `wsManager.onThreadOpen` navigation, favicon badge, `document.title`. Sidebar width/open persisted (`bb.sidebar.width|open`, AppLayout.tsx:96-144); resize handle desktop-only (`hidden md:block`, AppSidebar.tsx:404); mouse-only resize (AppLayout.tsx:742-812). Compact viewport = `(max-width: 767px)` (`useIsCompactViewport`, shared-ui hooks/use-compact-viewport.tsx:10); sidebar becomes swipe-open/drag-close overlay drawer (`SidebarMobilePanel`, components/ui/sidebar.tsx:20,732-754,1065). iOS keyboard viewport fixups: `useMobileVisualViewportHeight` (AppLayout.tsx:410). ## App sidebar (components/sidebar/AppSidebar.tsx) -Rows: history back/forward (top reserve), "New thread" (+ split mini-map desktop) + thread search (AppSidebar.tsx:314-327; `useSidebarThreadSearch`), `PluginNavSidebarItems` (Extensions row + plugin navPanels, reorder/hide via `bb.sidebar.pluginPanelOrder|hiddenPluginPanels`), `PluginThreadList` → `ProjectList` (org modes project/machine/manual + sort updated/created/alpha via `SidebarDisplayOptionsMenu`, ProjectList.tsx:621-739; section create/rename/delete dialogs ProjectList.tsx:1979-2012; Pinned section; dnd-kit drag reorder), footer: Settings link, plugin footer actions, Report bug (external), `SidebarUpdatesBadge`. ThreadRow kebab hidden on compact+coarse (`max-md:pointer-coarse:hidden`, ThreadRow.tsx:821); Radix ContextMenu (long-press) still wraps rows (ThreadRow.tsx:854). Thread menu items: Open in split, Mark read/unread, Pin/Unpin, Rename, Archive/Unarchive, Delete (ThreadActionsMenu.tsx:175-248). Project menu: Project settings, Rename, Add local path, Remove (ProjectActionsMenu.tsx:130-169). Section header actions: display options, New project, New section, New thread (ProjectList.tsx:566-617). Keyboard: `thread.search`, `thread.jump.N`, `thread.previous/next` handled here (AppSidebar.tsx:221-230). +Rows: history back/forward (top reserve), "New thread" (+ split mini-map desktop), `PluginNavSidebarItems` (Extensions row + plugin navPanels, reorder/hide via `bb.sidebar.pluginPanelOrder|hiddenPluginPanels`), `PluginThreadList` → `ProjectList` (org modes project/machine/manual + sort updated/created/alpha via `SidebarDisplayOptionsMenu`, ProjectList.tsx:621-739; section create/rename/delete dialogs ProjectList.tsx:1979-2012; Pinned section; dnd-kit drag reorder), footer: Settings link, plugin footer actions, Report bug (external), `SidebarUpdatesBadge`. Thread search is a command-palette mode entered by `thread.search`. ThreadRow kebab hidden on compact+coarse (`max-md:pointer-coarse:hidden`, ThreadRow.tsx:821); Radix ContextMenu (long-press) still wraps rows (ThreadRow.tsx:854). Thread menu items: Open in split, Mark read/unread, Pin/Unpin, Rename, Archive/Unarchive, Delete (ThreadActionsMenu.tsx:175-248). Project menu: Project settings, Rename, Add local path, Remove (ProjectActionsMenu.tsx:130-169). Section header actions: display options, New project, New section, New thread (ProjectList.tsx:566-617). Keyboard: `thread.jump.N` and `thread.previous/next` are handled here (AppSidebar.tsx:221-230). ## Root compose `/` (views/RootComposeView.tsx) `NewThreadComposer` (project/env/branch/worktree/machine/model/permission pickers, NewThreadPromptBox.tsx:418-573) with fork/handoff seeds from `location.state`; empty-projects welcome (RootComposeEmptyWelcome.tsx: New thread / Import projects / New project / Learn); `RootComposeMobileRecents` (`md:hidden`, 3 recent threads, RootComposeMobileRecents.tsx:181); `RootComposeSecondaryContent` = same right panel as threads (files, terminal, new-tab, browser[desktop], plugin tabs; no Info/Diff, RootComposeView.tsx:2345-2381) rendered as bottom drawer on compact (SecondaryPanelLayout.tsx:105); pinned panel toggle; `ProjectMachineSetupDialog`; `PluginHomepageSections`. Commands: panel.newTab, file.quickOpen, terminal.open, workspace.openPreferred, panel.toggle/close (RootComposeView.tsx:1471-1908, RootComposePanelCommandHandlers.tsx). @@ -109,7 +109,7 @@ Path params via `useRouteState` (hooks/useRouteState.ts). Query: `?view=browse|i - apps/app/src/components/commands/AppCommandProvider.tsx: **headless-logic-only** — Registers `window.addEventListener('keydown')`, queries `document.querySelector` for open modals (AppCommandProvider.tsx:93-99,179,330), uses `navigator.platform`, `HTMLElement.closest`. The handler registry/dispatch/priority pattern is portable; the key-event plumbing is web-only (RN has no global keydown; only hardware-keyboard events). - apps/app/src/components/layout/AppLayout.tsx: **not-reusable** — Radix-based SidebarProvider, CSS variables (`--sidebar-width`), mouse resize on document.body, `document.title`, `window.requestAnimationFrame`, MutationObserver, Electron chrome classes, env(safe-area-inset) Tailwind classes. - apps/app/src/components/ui/sidebar.tsx: **not-reusable** — DOM touch/pointer swipe implementation writing `panel.style.translate`, `inert`, `aria-modal`, Tailwind group-data variants; must be re-implemented with a native drawer (e.g. react-native-reanimated/gesture-handler). -- apps/app/src/components/sidebar/ProjectList.tsx + ThreadRow.tsx + ProjectRow.tsx: **headless-logic-only** — Heavy on @dnd-kit, Radix DropdownMenu/ContextMenu/Tooltip, CSS hover-action classes (theme.css:259-333). Reusable pieces: projectThreadGroups.ts, machineThreadGroups.ts, sortComparator.ts, threadReadState.ts, pinnedSidebarThreads.ts, sidebarThreadSearch.ts, sidebarSectionOrder.ts (pure TS). +- apps/app/src/components/sidebar/ProjectList.tsx + ThreadRow.tsx + ProjectRow.tsx: **headless-logic-only** — Heavy on @dnd-kit, Radix DropdownMenu/ContextMenu/Tooltip, CSS hover-action classes (theme.css:259-333). Reusable pieces: projectThreadGroups.ts, machineThreadGroups.ts, sortComparator.ts, threadReadState.ts, pinnedSidebarThreads.ts, sidebarSectionOrder.ts (pure TS). - apps/app/src/views/RootComposeView.tsx: **headless-logic-only** — 2400-line DOM view: react-router location.state, react-resizable-panels, `window`, Tiptap composer, xterm terminal, @pierre diffs. Exported pure helpers (readSectionIdFromLocationState, shouldNavigateAfterThreadCreate, buildMobileRecentThreads, canCreateRootComposeTerminal, root-compose-branch-selection.ts, root-compose-environment-selection.ts) are portable. - apps/app/src/views/RootComposeMobileRecents.tsx: **headless-logic-only** — getMobileRecentThreads sort/limit is pure; rendering uses react-router Link + Tailwind + ThreadStatusGlyph (SVG icons). - apps/app/src/views/thread-detail/ThreadDetailView.tsx: **headless-logic-only** — ~3000 lines wiring DOM-only panels (xterm terminal, @pierre/diffs with Web Workers, Tiptap, react-resizable-panels, iframe/BrowserView). Data hooks (thread-queries, timeline controller, useThreadGitActions, threadQueuedMessages.ts, threadDetailPromptSubmission.ts, splitThreadNavigation.ts) are largely portable.