diff --git a/apps/app/bundle-budget.json b/apps/app/bundle-budget.json index b667739ad9..0790f42af2 100644 --- a/apps/app/bundle-budget.json +++ b/apps/app/bundle-budget.json @@ -46,7 +46,7 @@ "`node scripts/why-eager.mjs --from=views/SplitWorkspaceRoute.tsx `", "to print the static chain that pulled a package into the closure." ], - "maxBootBytes": 1723617, + "maxBootBytes": 1725665, "maxBootBrotliBytes": 429072, "forbiddenBootPackages": [ "@pierre/diffs", 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 0b9e74d362..7283166f87 100644 --- a/apps/app/src/components/commands/CommandPalette.test.tsx +++ b/apps/app/src/components/commands/CommandPalette.test.tsx @@ -109,6 +109,46 @@ const openPaneContentInSplitMock = vi.hoisted(() => vi.fn()); const openThreadInSplitMock = vi.hoisted(() => vi.fn()); const routeNavigateMock = vi.hoisted(() => vi.fn()); +function expectClasses( + element: Element | null | undefined, + ...classNames: string[] +): void { + expect(element).toBeTruthy(); + for (const className of classNames) { + expect(element?.classList.contains(className)).toBe(true); + } +} + +function expectNoClasses( + element: Element | null | undefined, + ...classNames: string[] +): void { + expect(element).toBeTruthy(); + for (const className of classNames) { + expect(element?.classList.contains(className)).toBe(false); + } +} + +function expectText( + element: Element | null | undefined, + text: string, +): void { + expect(element?.textContent).toContain(text); +} + +function expectAttribute( + element: Element | null | undefined, + name: string, + value?: string, +): void { + expect(element).toBeTruthy(); + if (value === undefined) { + expect(element?.hasAttribute(name)).toBe(true); + } else { + expect(element?.getAttribute(name)).toBe(value); + } +} + vi.mock("@/hooks/queries/system-queries", () => ({ useSystemConfig: () => ({ data: { @@ -369,7 +409,7 @@ describe("CommandPalette", () => { expect(titles.length).toBeGreaterThan(5); }); - it("groups the resting root into three text buckets with producer metadata", async () => { + it("groups resting commands, hides empty plugins, and distinguishes drill-in rows", async () => { renderPalette(); openPalette(); await waitFor(() => expect(searchField()).toBeTruthy()); @@ -377,16 +417,64 @@ describe("CommandPalette", () => { const groups = within(commandList()).getAllByRole("group"); expect( groups.map((group) => group.getAttribute("data-palette-bucket")), - ).toEqual(["Threads", "Actions", "Plugins"]); - for (const [index, label] of ["Threads", "Actions", "Plugins"].entries()) { + ).toEqual(["Threads", "Actions"]); + expect( + within(commandList()).queryByRole("group", { name: "Plugins" }), + ).toBeNull(); + for (const [index, label] of ["Threads", "Actions"].entries()) { const header = within(groups[index] as HTMLElement).getByText(label, { selector: "div", }); for (const className of CHROME_SECTION_LABEL_CLASS.split(" ")) { expect(header.classList.contains(className)).toBe(true); } - expect(header.classList.contains("px-2")).toBe(true); + expectClasses(header, "px-3", "pb-1", "pt-3"); + expectNoClasses(header, "bg-muted/30"); } + expectClasses(commandList(), "p-2"); + expectClasses(commandList().parentElement, "overflow-hidden"); + expectClasses( + screen.getByTestId("command-palette"), + "max-w-[640px]", + "shadow-lg", + "sm:rounded-xl", + ); + expectClasses( + searchField().closest("[data-palette-input-frame]"), + "h-10", + "px-3", + ); + expectNoClasses( + searchField().closest("[data-palette-input-frame]"), + "border", + "bg-command-palette-search", + "rounded-md", + "shadow-xs", + ); + expectClasses( + searchField().closest("[data-palette-input-band]"), + "border-b", + "bg-background", + "px-3", + "py-2", + ); + expectClasses( + searchField(), + "placeholder:text-subtle-foreground", + "placeholder:font-light", + "placeholder:opacity-70", + ); + expectClasses(commandList().parentElement, "bg-background"); + expect( + commandList().querySelectorAll("[data-palette-scroll-sentinel]"), + ).toHaveLength(2); + + const rootFooter = screen + .getByTestId("command-palette") + .querySelector("[data-palette-footer]"); + expectText(rootFooter, "Select"); + expectText(rootFooter, "Run"); + expect(rootFooter?.textContent).not.toContain("Open"); const threadRows = within(bucketGroup("Threads")).getAllByRole("option"); expect(threadRows.map((row) => row.textContent)).toEqual([ @@ -397,18 +485,38 @@ describe("CommandPalette", () => { for (const row of threadRows) { expect(within(row).queryByText("Threads")).toBeNull(); } - expect(threadRows[1]?.querySelector("kbd")).not.toBeNull(); + const searchThreadsRow = threadRows[1] as HTMLElement; + expect(searchThreadsRow.querySelector("kbd")).not.toBeNull(); + expectAttribute( + searchThreadsRow, + "data-palette-action-kind", + "drill-in", + ); + expectText(searchThreadsRow, "Search threads…"); + expect( + searchThreadsRow.querySelector('[data-icon="ChevronRight"]'), + ).toBeNull(); + expect(searchThreadsRow.textContent).toContain("Opens a search view"); const actionRows = within(bucketGroup("Actions")).getAllByRole("option"); expect(actionRows[0]?.textContent).toContain("Window and layout"); expect(actionRows[1]?.textContent).toContain("Workspace"); for (const row of [...threadRows, ...actionRows]) { - expect(row.classList.contains("px-2")).toBe(true); + expect(row.classList.contains("px-3")).toBe(true); } expect(commandList().querySelector("[data-icon]")).toBeNull(); + expectClasses(threadRows[0], "bg-state-hover", "text-foreground"); + expectAttribute( + actionRows[0], + "data-palette-action-kind", + "terminal", + ); expect( - screen.getByTestId("command-palette").querySelector("svg"), + 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 () => { @@ -420,12 +528,48 @@ describe("CommandPalette", () => { ).toBeTruthy(), ); expect(event.defaultPrevented).toBe(true); - expect( - screen.getByText("Threads").closest("[data-palette-mode-chip]"), - ).not.toBeNull(); + const modeSelect = screen.getByRole("button", { name: "Threads search" }); + expectAttribute(modeSelect, "aria-pressed", "true"); + expect(modeSelect.querySelector('[data-icon="Search"]')).not.toBeNull(); + expectAttribute( + screen.getByRole("button", { name: "Return to commands" }), + "data-tab-pill-close", + ); expect( screen.getByRole("button", { name: "Thread scope" }).textContent, ).toContain("All"); + expectText(screen.getByTestId("command-palette"), "Split"); + const footer = screen + .getByTestId("command-palette") + .querySelector("[data-palette-footer]"); + expectClasses( + footer, + "flex-wrap", + "bg-surface-recessed-soft-solid", + "px-4", + "py-2", + ); + for (const keycap of footer?.querySelectorAll("kbd") ?? []) { + expectClasses( + keycap, + "rounded", + "border-border/70", + "bg-background/70", + "font-mono", + "text-muted-foreground", + "shadow-xs", + ); + } + for (const label of + footer?.querySelectorAll("[data-palette-footer-label]") ?? []) { + expectClasses(label, "opacity-70"); + expectClasses( + label.closest("[data-palette-footer]"), + "text-subtle-foreground", + ); + } + expectText(footer, "Backspace"); + expectText(footer, "Esc"); fireEvent.keyDown(screen.getByRole("combobox"), { key: "Escape" }); await waitFor(() => @@ -439,6 +583,49 @@ describe("CommandPalette", () => { await waitFor(() => expect(screen.queryByRole("combobox")).toBeNull()); }); + it("uses the shared tab-pill clear affordance without running the mode command", async () => { + renderPalette(); + openThreadSearch(); + await waitFor(() => + expect( + screen.getByRole("combobox", { name: "Search threads" }), + ).toBeTruthy(), + ); + + const clearMode = screen.getByRole("button", { + name: "Return to commands", + }); + expect(clearMode.querySelector('[data-icon="X"]')).not.toBeNull(); + expectClasses( + clearMode, + "opacity-0", + "group-hover/tab-pill:opacity-100", + "focus-visible:opacity-100", + ); + fireEvent.click(clearMode); + await waitFor(() => + expect( + screen.getByRole("combobox", { name: "Search commands" }), + ).toBeTruthy(), + ); + expect(testState.calls).toEqual([]); + + const searchCommand = within(bucketGroup("Threads")) + .getAllByRole("option") + .find((row) => row.textContent?.includes("Search threads")); + fireEvent.click(searchCommand as HTMLElement); + const clearAfterCommand = await screen.findByRole("button", { + name: "Return to commands", + }); + fireEvent.click(clearAfterCommand); + await waitFor(() => + expect( + screen.getByRole("combobox", { name: "Search commands" }), + ).toBeTruthy(), + ); + expect(testState.calls).toEqual([]); + }); + it("enters the same registered mode by running Search threads from the root", async () => { renderPalette(); openPalette(); @@ -458,6 +645,23 @@ describe("CommandPalette", () => { expect(testState.calls).toEqual([]); }); + it("returns from an empty thread query with Backspace", async () => { + renderPalette(); + openThreadSearch(); + const input = await screen.findByRole("combobox", { + name: "Search threads", + }); + + fireEvent.keyDown(input, { key: "Backspace" }); + + await waitFor(() => + expect( + screen.getByRole("combobox", { name: "Search commands" }), + ).toBeTruthy(), + ); + expect(testState.calls).toEqual([]); + }); + it("cycles the thread scope and resets it after leaving the mode", async () => { renderPalette(); openThreadSearch(); @@ -491,7 +695,78 @@ describe("CommandPalette", () => { ); }); - it("makes scope the input's only sibling tab stop and applies every keyboard choice immediately", async () => { + it("keeps scope options outside results clipping and selects Archived without closing the palette", async () => { + renderPalette(); + openThreadSearch(); + const input = await screen.findByRole("combobox", { + name: "Search threads", + }); + const scope = screen.getByRole("button", { name: "Thread scope" }); + fireEvent.click(scope); + const options = screen.getByRole("listbox", { + name: "Thread scope options", + }); + for ( + let ancestor = options.parentElement; + ancestor; + ancestor = ancestor.parentElement + ) { + expectNoClasses( + ancestor, + "overflow-hidden", + "overflow-clip", + "overflow-auto", + ); + } + expect(options.closest("[data-palette-results-clip]")).toBeNull(); + expectClasses( + input.closest("[data-palette-input-band]"), + "rounded-t-[inherit]", + ); + expectClasses( + screen.getByTestId("command-palette").querySelector("[data-palette-footer]"), + "rounded-b-[inherit]", + ); + + const archived = within(options).getByRole("option", { name: "Archived" }); + fireEvent.pointerDown(archived); + fireEvent.click(archived); + + expect(scope.textContent).toContain("Archived"); + expect(screen.getByTestId("command-palette")).toBeTruthy(); + expect( + screen.queryByRole("listbox", { name: "Thread scope options" }), + ).toBeNull(); + expect(document.activeElement).toBe(input); + }); + + it("opens the closed thread scope with Enter and returns to the input on the next Enter", async () => { + renderPalette(); + openThreadSearch(); + const input = await screen.findByRole("combobox", { + name: "Search threads", + }); + const scope = screen.getByRole("button", { name: "Thread scope" }); + + scope.focus(); + fireEvent.keyDown(scope, { key: "Enter" }); + + expect(scope.getAttribute("aria-expanded")).toBe("true"); + expect( + screen.getByRole("listbox", { name: "Thread scope options" }), + ).toBeTruthy(); + expect(document.activeElement).toBe(scope); + + fireEvent.keyDown(scope, { key: "Enter" }); + + expect(scope.getAttribute("aria-expanded")).toBe("false"); + expect( + screen.queryByRole("listbox", { name: "Thread scope options" }), + ).toBeNull(); + expect(document.activeElement).toBe(input); + }); + + it("keeps the mode clear, input, and scope in a predictable tab sequence and applies every keyboard choice immediately", async () => { modeState.searchResponse = { active: { total: 1, @@ -527,6 +802,11 @@ describe("CommandPalette", () => { ); const input = screen.getByRole("combobox", { name: "Search threads" }); const scope = screen.getByRole("button", { name: "Thread scope" }); + expect(scope.querySelector("[data-icon]")).toBeNull(); + const modeSelect = screen.getByRole("button", { name: "Threads search" }); + const clearMode = screen.getByRole("button", { + name: "Return to commands", + }); const palette = screen.getByTestId("command-palette"); expect( Array.from( @@ -534,7 +814,7 @@ describe("CommandPalette", () => { 'input:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])', ), ), - ).toEqual([input, scope]); + ).toEqual([modeSelect, clearMode, input, scope]); fireEvent.change(input, { target: { value: "match" } }); const results = screen.getByRole("listbox", { name: "Threads" }); @@ -553,6 +833,7 @@ describe("CommandPalette", () => { .getAllByRole("option") .map((option) => option.textContent), ).toEqual(["All", "Active", "Drafts", "Archived"]); + expect(scopeOptions.querySelector("[data-icon]")).toBeNull(); expect(within(results).getAllByRole("option")).toHaveLength(1); expect(within(results).getByRole("option").textContent).toContain( "matching-active", @@ -616,15 +897,49 @@ describe("CommandPalette", () => { expect(rows[1]?.textContent).toContain("Draft"); expect(rows[2]?.textContent).toContain("Title recent-archived"); expect(rows[2]?.textContent).toContain("Archived"); + expect(results.querySelector("[data-palette-thread-state]")).toBeNull(); + expect(results.querySelector("[data-icon]")).toBeNull(); + for (const row of rows) { + expectClasses( + row.querySelector("[data-palette-thread-metadata]"), + "text-subtle-foreground", + ); + } + expectClasses( + within(rows[1] as HTMLElement).getByText("Draft"), + "text-subtle-foreground", + ); + expectClasses( + within(rows[2] as HTMLElement).getByText("Archived"), + "text-subtle-foreground", + ); expect(within(results).queryAllByRole("group")).toHaveLength(0); expect(within(results).queryByText("Recent")).toBeNull(); }); it("renders search matches as one unlabelled active, draft, archived list", async () => { - const active = makeThread("active"); + const active = makeThread("active", { + title: "Matching active thread", + titleFallback: "Matching active thread", + }); const archived = makeThread("archived", { archivedAt: Date.now() }); modeState.searchResponse = { - active: { total: 1, results: [{ thread: active, matches: [] }] }, + active: { + total: 1, + results: [ + { + thread: active, + matches: [ + { + sourceKind: "title", + text: "Matching active thread", + highlightRanges: [{ start: 0, end: 8 }], + sourceSeq: null, + }, + ], + }, + ], + }, archived: { total: 1, results: [{ thread: archived, matches: [] }] }, }; modeState.drafts = [ @@ -652,18 +967,31 @@ describe("CommandPalette", () => { expect(within(results).getAllByRole("option")).toHaveLength(3), ); const rows = within(results).getAllByRole("option"); - expect(rows[0]?.textContent).toContain("Title active"); + expect(rows[0]?.textContent).toContain("Matching active thread"); expect(rows[1]?.textContent).toContain("matching draft"); expect(rows[1]?.textContent).toContain("Draft"); expect(rows[2]?.textContent).toContain("Title archived"); expect(rows[2]?.textContent).toContain("Archived"); expect(rows[0]?.textContent).not.toContain("Active"); + const activeMatch = rows[0]?.querySelector("mark"); + expectText(activeMatch, "Matching"); + expectClasses( + activeMatch, + "bg-[var(--sidebar-search-match)]", + "text-foreground", + ); + expectClasses(activeMatch?.parentElement, "text-foreground"); + for (const row of rows) { + expectClasses( + row.querySelector("[data-palette-thread-metadata]"), + "text-subtle-foreground", + ); + } + expect(results.querySelector("[data-palette-thread-state]")).toBeNull(); expect(within(results).queryAllByRole("group")).toHaveLength(0); expect(within(results).queryByText("Recent")).toBeNull(); expect(results.textContent).not.toContain("1/1"); - expect( - screen.getByTestId("command-palette").querySelectorAll("svg"), - ).toHaveLength(1); + expect(results.querySelector("svg")).toBeNull(); }); it("opens a persisted thread result in a split with Command-Enter", async () => { diff --git a/apps/app/src/components/commands/CommandPalette.tsx b/apps/app/src/components/commands/CommandPalette.tsx index 5ee29257e5..2113331a92 100644 --- a/apps/app/src/components/commands/CommandPalette.tsx +++ b/apps/app/src/components/commands/CommandPalette.tsx @@ -10,12 +10,9 @@ import type { KeyboardEvent as ReactKeyboardEvent } from "react"; import { useNavigate } from "react-router-dom"; import { Dialog, DialogContent, DialogTitle } from "@bb/shared-ui/dialog"; import { cn } from "@bb/shared-ui/lib/utils"; -import { COARSE_POINTER_TEXT_SM_CLASS } from "@bb/shared-ui/coarse-pointer-sizing"; import { CHROME_SECTION_LABEL_CLASS } from "@bb/shared-ui/chrome-style-tokens"; -import { LAUNCHER_ACTION_ROW_BASE_CLASS } from "@/components/secondary-panel/launcherRow"; import { useAppCommandHandler, - useAppCommandShortcut, useAppCommandRunner, useAppCommandShortcuts, useIndexedAppCommandHandlers, @@ -56,7 +53,12 @@ import { } from "@/lib/command-palette/palette-modes"; import { PaletteShell } from "./PaletteShell"; -const PALETTE_PLACEHOLDER = "Search commands"; +const PALETTE_INPUT_LABEL = "Search commands"; +const PALETTE_PLACEHOLDER = "Search commands…"; +const ROOT_FOOTER_KEYS = [ + { keys: ["↑↓"], label: "Select" }, + { keys: ["↵"], label: "Run" }, +] as const; const MODE_ENTRY_HANDLER_PRIORITY = 100; const MODE_BY_ACTION_ID = new Map( PALETTE_MODES.map((mode) => [ @@ -79,7 +81,6 @@ export function CommandPalette({ const navigate = useNavigate(); const runner = useAppCommandRunner(); const shortcuts = useAppCommandShortcuts(PALETTE_COMMAND_IDS); - const paletteShortcut = useAppCommandShortcut("palette.open"); const listId = useId(); const optionIdPrefix = useId(); @@ -224,7 +225,9 @@ export function CommandPalette({ const groups = PALETTE_ACTION_BUCKETS.map((bucket) => ({ bucket, entries: ranked.filter((entry) => entry.action.bucket === bucket), - })); + })).filter( + (group) => group.bucket !== "Plugins" || group.entries.length > 0, + ); return groups.map((group, index) => ({ ...group, startIndex: groups @@ -252,15 +255,18 @@ export function CommandPalette({ ?.scrollIntoView({ block: "nearest" }); }, [activeIndex]); - const chooseAction = useCallback((action: PaletteAction) => { - setRecents((current) => recordPaletteRecent(current, action.id)); - if (MODE_BY_ACTION_ID.has(action.id)) { - action.run(); - return; - } - pendingRunRef.current = action.run; - setOpen(false); - }, []); + const chooseAction = useCallback( + (action: PaletteAction) => { + setRecents((current) => recordPaletteRecent(current, action.id)); + if (MODE_BY_ACTION_ID.has(action.id)) { + action.run(); + return; + } + pendingRunRef.current = action.run; + setOpen(false); + }, + [], + ); const runAfterClose = useCallback((run: () => void) => { pendingRunRef.current = run; @@ -337,7 +343,7 @@ export function CommandPalette({ { if (activeMode !== undefined) event.preventDefault(); @@ -352,12 +358,8 @@ export function CommandPalette({ ? undefined : `${optionIdPrefix}-${activeIndex}` } - accessory={ - paletteShortcut === null ? null : ( - - ) - } - inputLabel={PALETTE_PLACEHOLDER} + footerKeys={ROOT_FOOTER_KEYS} + inputLabel={PALETTE_INPUT_LABEL} listId={listId} listLabel="Commands" listRef={listRef} @@ -371,11 +373,11 @@ export function CommandPalette({ value={query} > {!isGroupedRoot && visibleEntries.length === 0 ? ( -

+

No matching commands

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

+ {children} +

+ ); +} + +function PaletteFooter({ mode }: { mode: Mode }) { + const hints = + mode === "threads" + ? ([ + { keys: ["↑↓"], label: "Select" }, + { keys: ["↵"], label: "Open" }, + { keys: ["⌘↵"], label: "Split" }, + { keys: ["Backspace", "Esc"], label: "Back" }, + ] as const) + : ([ + { keys: ["↑↓"], label: "Select" }, + { keys: ["↵"], label: "Run" }, + ] as const); + return ( +
+ {hints.map((hint) => ( + + + {hint.keys.map((keys, index) => ( + + {index === 0 ? null : ( + + )} + + {keys} + + + ))} + + {hint.label} + + ))} +
+ ); +} + +export function Review() { + return ; +} diff --git a/apps/app/src/components/commands/PaletteShell.tsx b/apps/app/src/components/commands/PaletteShell.tsx index ecfc8e7cc8..b5d5b38ac4 100644 --- a/apps/app/src/components/commands/PaletteShell.tsx +++ b/apps/app/src/components/commands/PaletteShell.tsx @@ -1,17 +1,27 @@ import type { KeyboardEventHandler, ReactNode, Ref } from "react"; +import { useComposedRefs } from "@radix-ui/react-compose-refs"; import { Icon } from "@bb/shared-ui/icon"; +import { useScrollOverflowState } from "@/components/thread/timeline/useScrollOverflowState"; +import { TabPill } from "@/components/ui/tab-pill"; + +interface PaletteModeChipProps { + clearLabel: string; + icon: Parameters[0]["name"]; + label: string; + onClear: () => void; +} interface PaletteShellProps { activeDescendantId?: string; accessory?: ReactNode; children: ReactNode; - footerKeys?: readonly { keys: string; label: string }[]; + footerKeys: readonly { keys: readonly string[]; label: string }[]; inputLabel: string; inputRef?: Ref; listId: string; listLabel: string; listRef?: Ref; - modeChip?: { icon: Parameters[0]["name"]; label: string }; + modeChip?: PaletteModeChipProps; onInputChange: (value: string) => void; onInputKeyDown: KeyboardEventHandler; placeholder: string; @@ -22,7 +32,7 @@ export function PaletteShell({ activeDescendantId, accessory, children, - footerKeys = [], + footerKeys, inputLabel, inputRef, listId, @@ -34,58 +44,135 @@ export function PaletteShell({ placeholder, value, }: PaletteShellProps) { + const overflow = useScrollOverflowState({ + measureOverflow: true, + }); + const composedListRef = useComposedRefs(listRef, overflow.scrollRef); + const resultsMask = + overflow.aboveOverflow && overflow.belowOverflow + ? "linear-gradient(to bottom, transparent 0, black 1.5rem, black calc(100% - 1.5rem), transparent 100%)" + : overflow.aboveOverflow + ? "linear-gradient(to bottom, transparent 0, black 1.5rem, black 100%)" + : overflow.belowOverflow + ? "linear-gradient(to bottom, black 0, black calc(100% - 1.5rem), transparent 100%)" + : undefined; + return ( <> -
- {modeChip === undefined ? null : ( - - - {modeChip.label} - - )} - onInputChange(event.target.value)} - onKeyDown={onInputKeyDown} - /> - {accessory} +
+
+ {modeChip === undefined ? null : ( + + )} + onInputChange(event.target.value)} + onKeyDown={onInputKeyDown} + /> + {accessory} +
- {children} +
+
+ {children} +
+
- {footerKeys.length === 0 ? null : ( -
- {footerKeys.map((hint) => ( - - {hint.keys} - {hint.label} +
+ {footerKeys.map((hint) => ( + + + {hint.keys.map((keys, index) => ( + + {index === 0 ? null : ( + + / + + )} + + {keys} + + + ))} - ))} -
- )} + + {hint.label} + +
+ ))} +
); } + +function PaletteModeChip({ + clearLabel, + icon, + label, + onClear, +}: PaletteModeChipProps) { + return ( + + undefined} + leadingVisual={} + closeAction={{ onClose: onClear, closeLabel: clearLabel }} + /> + + ); +} diff --git a/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx b/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx index b2dc014850..c567fa667f 100644 --- a/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx +++ b/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx @@ -202,6 +202,12 @@ export function ThreadSearchPaletteMode({ const handleInputKeyDown = useCallback( (event: ReactKeyboardEvent) => { + if (event.key === "Backspace" && query.length === 0) { + event.preventDefault(); + event.stopPropagation(); + onExit(); + return; + } if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); @@ -233,7 +239,7 @@ export function ThreadSearchPaletteMode({ openRow(row, event.metaKey || event.ctrlKey); } }, - [activeIndex, onExit, openRow, result.rows], + [activeIndex, onExit, openRow, query.length, result.rows], ); const isLoading = @@ -270,12 +276,16 @@ export function ThreadSearchPaletteMode({ /> } footerKeys={presentation.footerKeys} - inputLabel={presentation.placeholder} + inputLabel="Search threads" inputRef={inputRef} listId={listId} listLabel="Threads" listRef={listRef} - modeChip={presentation.chip} + modeChip={{ + ...presentation.chip, + clearLabel: "Return to commands", + onClear: onExit, + }} onInputChange={(value) => { setQuery(value); setHighlightedIndex(0); @@ -297,7 +307,7 @@ export function ThreadSearchPaletteMode({ /> )) ) : ( -

+

{emptyMessage}

)} @@ -339,7 +349,7 @@ function ThreadSearchScopeFilter({ aria-haspopup="listbox" aria-expanded={open} aria-label="Thread scope" - className="rounded-md px-2 py-1 text-xs text-muted-foreground outline-none hover:bg-state-hover focus-visible:ring-2 focus-visible:ring-ring" + className="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-subtle-foreground outline-none hover:bg-state-hover hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring" onClick={() => setOpen((value) => !value)} onKeyDown={(event) => { if (event.key === "ArrowDown" || event.key === "ArrowUp") { @@ -364,13 +374,14 @@ function ThreadSearchScopeFilter({ } }} > - {current?.label ?? "All"} + {current?.label ?? "All"} + {open ? (
{PALETTE_THREAD_SEARCH_SCOPES.map((option) => (
event.preventDefault()} @@ -387,7 +398,7 @@ function ThreadSearchScopeFilter({ returnToInput(); }} > - {option.label} + {option.label}
))}
@@ -450,22 +461,26 @@ function ThreadSearchPaletteRow({ role="option" aria-selected={isActive} className={cn( - "flex min-h-11 cursor-pointer items-center gap-3 rounded-md px-2 py-1.5 text-left text-sm", + "flex min-h-11 cursor-pointer items-center gap-3 rounded-md px-3 py-1.5 text-left text-sm", isActive && "bg-state-hover text-foreground", )} onPointerMove={onActivate} onClick={onSelect} > - + {row.metadataText} @@ -473,7 +488,7 @@ function ThreadSearchPaletteRow({ {stateLabel === null ? null : ( diff --git a/apps/app/src/lib/command-palette/palette-mode.ts b/apps/app/src/lib/command-palette/palette-mode.ts index b9ae2e7055..873a54d32e 100644 --- a/apps/app/src/lib/command-palette/palette-mode.ts +++ b/apps/app/src/lib/command-palette/palette-mode.ts @@ -8,7 +8,7 @@ export interface PaletteModePresentation { label: string; }; footerKeys: readonly { - keys: string; + keys: readonly string[]; label: string; }[]; placeholder: string; diff --git a/apps/app/src/lib/command-palette/palette-modes.ts b/apps/app/src/lib/command-palette/palette-modes.ts index 637bdf73d2..b1d20b2330 100644 --- a/apps/app/src/lib/command-palette/palette-modes.ts +++ b/apps/app/src/lib/command-palette/palette-modes.ts @@ -6,12 +6,12 @@ export const PALETTE_MODES: readonly PaletteModeRegistration[] = [ id: "thread-search", entryCommand: "thread.search", chip: { icon: "Search", label: "Threads" }, - placeholder: "Search threads", + placeholder: "Search title, project, or message…", footerKeys: [ - { keys: "↑↓", label: "Select" }, - { keys: "↵", label: "Open" }, - { keys: "⌘↵", label: "Open in split" }, - { keys: "Esc", label: "Back" }, + { keys: ["↑↓"], label: "Select" }, + { keys: ["↵"], label: "Open" }, + { keys: ["⌘↵"], label: "Split" }, + { keys: ["Backspace", "Esc"], label: "Back" }, ], View: ThreadSearchPaletteMode, }, diff --git a/packages/plugin-api-map/src/anatomy-manifest.json b/packages/plugin-api-map/src/anatomy-manifest.json index d4f0d0a196..83654b6f40 100644 --- a/packages/plugin-api-map/src/anatomy-manifest.json +++ b/packages/plugin-api-map/src/anatomy-manifest.json @@ -38,9 +38,9 @@ { "path": "apps/app/src/components/commands/CommandPalette.tsx", "anchors": [ - "top-[12%] max-w-xl translate-y-0 gap-0 p-0", + "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-3 py-2 text-left text-sm outline-none", "bg-state-hover text-foreground" ] },