From a0f8be8fad2a8076d99490feaf65e25994ffcd22 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Mon, 31 Aug 2026 16:02:17 -0700 Subject: [PATCH 01/25] Polish command palette hierarchy and footer --- .../commands/AppCommandShortcutHint.tsx | 4 +- .../commands/CommandPalette.test.tsx | 326 +++++++- .../components/commands/CommandPalette.tsx | 85 +- .../CommandPalettePrototype.stories.tsx | 741 ++++++++++++++++++ .../src/components/commands/PaletteShell.tsx | 183 +++-- .../commands/ThreadSearchPaletteMode.tsx | 41 +- .../src/lib/command-palette/palette-mode.ts | 2 +- .../src/lib/command-palette/palette-modes.ts | 10 +- .../plugin-api-map/src/anatomy-manifest.json | 4 +- 9 files changed, 1269 insertions(+), 127 deletions(-) create mode 100644 apps/app/src/components/commands/CommandPalettePrototype.stories.tsx 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 f756fe6af2..deea6ea1bb 100644 --- a/apps/app/src/components/commands/CommandPalette.test.tsx +++ b/apps/app/src/components/commands/CommandPalette.test.tsx @@ -104,9 +104,50 @@ const modeState = vi.hoisted(() => ({ drafts: [] as PaletteNewThreadDraft[], searchResponse: undefined as ThreadSearchResponse | undefined, })); +const openPaneContentInSplitMock = vi.hoisted(() => vi.fn()); const openThreadInSplitMock = vi.hoisted(() => vi.fn()); const routeNavigateMock = vi.hoisted(() => vi.fn()); +function expectClasses( + element: Element | null | undefined, + ...classNames: string[] +): void { + expect(element).toBeTruthy(); + for (const className of classNames) { + expect(element?.classList.contains(className)).toBe(true); + } +} + +function expectNoClasses( + element: Element | null | undefined, + ...classNames: string[] +): void { + expect(element).toBeTruthy(); + for (const className of classNames) { + expect(element?.classList.contains(className)).toBe(false); + } +} + +function expectText( + element: Element | null | undefined, + text: string, +): void { + expect(element?.textContent).toContain(text); +} + +function expectAttribute( + element: Element | null | undefined, + name: string, + value?: string, +): void { + expect(element).toBeTruthy(); + if (value === undefined) { + expect(element?.hasAttribute(name)).toBe(true); + } else { + expect(element?.getAttribute(name)).toBe(value); + } +} + vi.mock("@/hooks/queries/system-queries", () => ({ useSystemConfig: () => ({ data: { @@ -156,6 +197,10 @@ vi.mock("@/lib/split-layout/openThreadInSplit", () => ({ openThreadInSplit: openThreadInSplitMock, })); +vi.mock("@/lib/split-layout/openPaneContentInSplit", () => ({ + openPaneContentInSplit: openPaneContentInSplitMock, +})); + vi.mock("@/components/ui/app-route-anchor", () => ({ useRouteNavigate: () => routeNavigateMock, })); @@ -336,6 +381,7 @@ afterEach(() => { modeState.archivedRecents = []; modeState.drafts = []; modeState.searchResponse = undefined; + openPaneContentInSplitMock.mockReset(); openThreadInSplitMock.mockReset(); routeNavigateMock.mockReset(); window.localStorage.clear(); @@ -359,7 +405,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()); @@ -367,16 +413,65 @@ 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]", + "overflow-hidden", + "shadow-lg", + "sm:rounded-xl", + ); + expectClasses( + searchField().closest("[data-palette-input-frame]"), + "h-10", + "px-3", + ); + expectNoClasses( + searchField().closest("[data-palette-input-frame]"), + "border", + "bg-command-palette-search", + "rounded-md", + "shadow-xs", + ); + expectClasses( + searchField().closest("[data-palette-input-band]"), + "border-b", + "bg-background", + "px-3", + "py-2", + ); + expectClasses( + searchField(), + "placeholder:text-subtle-foreground", + "placeholder:font-light", + "placeholder:opacity-70", + ); + expectClasses(commandList().parentElement, "bg-background"); + expect( + commandList().querySelectorAll("[data-palette-scroll-sentinel]"), + ).toHaveLength(2); + + const rootFooter = screen + .getByTestId("command-palette") + .querySelector("[data-palette-footer]"); + expectText(rootFooter, "Select"); + expectText(rootFooter, "Run"); + expect(rootFooter?.textContent).not.toContain("Open"); const threadRows = within(bucketGroup("Threads")).getAllByRole("option"); expect(threadRows.map((row) => row.textContent)).toEqual([ @@ -387,18 +482,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 () => { @@ -410,12 +525,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(() => @@ -429,6 +580,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(); @@ -448,6 +642,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(); @@ -481,7 +692,33 @@ describe("CommandPalette", () => { ); }); - it("makes scope the input's only sibling tab stop and applies every keyboard choice immediately", async () => { + 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, @@ -517,6 +754,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( @@ -524,7 +766,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" }); @@ -543,6 +785,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", @@ -606,15 +849,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 = [ @@ -642,18 +919,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 e3870b7c87..9c5d82aae5 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(); @@ -223,7 +224,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 @@ -251,15 +254,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; @@ -336,7 +342,7 @@ export function CommandPalette({ { if (activeMode !== undefined) event.preventDefault(); @@ -351,12 +357,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} @@ -370,11 +372,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} @@ -401,7 +402,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)} /> ); @@ -416,7 +420,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)} /> )) @@ -442,26 +449,30 @@ function PaletteRow({ entry, id, isActive, + isDrillIn, onActivate, onSelect, }: { entry: RankedPaletteAction; id: string; isActive: boolean; + isDrillIn: boolean; onActivate: () => void; onSelect: () => void; }) { const metadataGroup = entry.action.group === entry.action.bucket ? null : entry.action.group; - const 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..89eeddadb7 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 1a26c071ca..afda546348 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 overflow-hidden 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" ] }, From ca6c218af4e39ea41ef17530e7aeeac34863eea6 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Tue, 1 Sep 2026 14:33:25 -0700 Subject: [PATCH 02/25] Align command palette controls and metadata --- .../commands/CommandPalette.test.tsx | 9 ++++--- .../src/components/commands/PaletteShell.tsx | 24 ++++++++++--------- .../commands/ThreadSearchPaletteMode.tsx | 15 ++++++++---- apps/app/src/components/ui/tab-pill.tsx | 3 +++ 4 files changed, 32 insertions(+), 19 deletions(-) diff --git a/apps/app/src/components/commands/CommandPalette.test.tsx b/apps/app/src/components/commands/CommandPalette.test.tsx index deea6ea1bb..996a7c46ea 100644 --- a/apps/app/src/components/commands/CommandPalette.test.tsx +++ b/apps/app/src/components/commands/CommandPalette.test.tsx @@ -528,13 +528,14 @@ describe("CommandPalette", () => { const modeSelect = screen.getByRole("button", { name: "Threads search" }); expectAttribute(modeSelect, "aria-pressed", "true"); expect(modeSelect.querySelector('[data-icon="Search"]')).not.toBeNull(); + expectClasses(modeSelect.parentElement, "border-border/70", "bg-background/70"); expectAttribute( screen.getByRole("button", { name: "Return to commands" }), "data-tab-pill-close", ); - expect( - screen.getByRole("button", { name: "Thread scope" }).textContent, - ).toContain("All"); + const scope = screen.getByRole("button", { name: "Thread scope" }); + expect(scope.textContent).toContain("All"); + expectClasses(scope, "text-subtle-foreground", "opacity-70"); expectText(screen.getByTestId("command-palette"), "Split"); const footer = screen .getByTestId("command-palette") @@ -855,6 +856,7 @@ describe("CommandPalette", () => { expectClasses( row.querySelector("[data-palette-thread-metadata]"), "text-subtle-foreground", + "opacity-70", ); } expectClasses( @@ -937,6 +939,7 @@ describe("CommandPalette", () => { expectClasses( row.querySelector("[data-palette-thread-metadata]"), "text-subtle-foreground", + "opacity-70", ); } expect(results.querySelector("[data-palette-thread-state]")).toBeNull(); diff --git a/apps/app/src/components/commands/PaletteShell.tsx b/apps/app/src/components/commands/PaletteShell.tsx index 89eeddadb7..73d6e25048 100644 --- a/apps/app/src/components/commands/PaletteShell.tsx +++ b/apps/app/src/components/commands/PaletteShell.tsx @@ -4,6 +4,11 @@ import { Icon } from "@bb/shared-ui/icon"; import { useScrollOverflowState } from "@/components/thread/timeline/useScrollOverflowState"; import { TabPill } from "@/components/ui/tab-pill"; +export const PALETTE_FOOTER_CONTROL_SURFACE_CLASS = + "border border-border/70 bg-background/70 shadow-xs"; +export const PALETTE_FOOTER_KEYCAP_CLASS = `inline-flex min-w-5 items-center justify-center rounded px-1.5 py-0.5 font-mono text-xs leading-none text-muted-foreground ${PALETTE_FOOTER_CONTROL_SURFACE_CLASS}`; +export const PALETTE_FOOTER_LABEL_CLASS = "text-subtle-foreground opacity-70"; + interface PaletteModeChipProps { clearLabel: string; icon: Parameters[0]["name"]; @@ -67,9 +72,7 @@ export function PaletteShell({ className="flex h-10 items-center gap-2 px-3" data-palette-input-frame > - {modeChip === undefined ? null : ( - - )} + {modeChip === undefined ? null : } ( {index === 0 ? null : ( - + / )} - - {keys} - + {keys} ))} - + {hint.label} @@ -169,6 +170,7 @@ function PaletteModeChip({ label={label} title={label} isActive + className={PALETTE_FOOTER_CONTROL_SURFACE_CLASS} onSelect={() => 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 c567fa667f..ada14a68c4 100644 --- a/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx +++ b/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx @@ -35,7 +35,7 @@ import { } 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 { PaletteShell } from "./PaletteShell"; +import { PALETTE_FOOTER_LABEL_CLASS, PaletteShell } from "./PaletteShell"; export function ThreadSearchPaletteMode({ onExit, @@ -253,8 +253,7 @@ export function ThreadSearchPaletteMode({ ? "Searching threads" : trimmedQuery.length === 1 ? "Type at least 2 characters" - : (navigation.isLoading || archivedThreads.isLoading) && - result.isRecent + : (navigation.isLoading || archivedThreads.isLoading) && result.isRecent ? "Loading recent threads" : result.isRecent ? "No recent threads" @@ -349,7 +348,10 @@ function ThreadSearchScopeFilter({ aria-haspopup="listbox" aria-expanded={open} aria-label="Thread scope" - 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" + className={cn( + "inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs outline-none hover:bg-state-hover hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring", + PALETTE_FOOTER_LABEL_CLASS, + )} onClick={() => setOpen((value) => !value)} onKeyDown={(event) => { if (event.key === "ArrowDown" || event.key === "ArrowUp") { @@ -478,7 +480,10 @@ function ThreadSearchPaletteRow({ /> diff --git a/apps/app/src/components/ui/tab-pill.tsx b/apps/app/src/components/ui/tab-pill.tsx index 99ffdb98d5..7bdc0c4a94 100644 --- a/apps/app/src/components/ui/tab-pill.tsx +++ b/apps/app/src/components/ui/tab-pill.tsx @@ -23,6 +23,7 @@ interface TabPillCloseAction { interface TabPillProps { label: string; + className?: string; ariaLabel?: string; ariaKeyshortcuts?: string; iconOnly?: boolean; @@ -39,6 +40,7 @@ interface TabPillProps { export function TabPill({ label, + className, ariaLabel, ariaKeyshortcuts, iconOnly = false, @@ -72,6 +74,7 @@ export function TabPill({ isActive ? cn(CONTEXT_SELECTION_SURFACE_CLASS, "text-foreground") : "text-muted-foreground hover:bg-state-hover", + className, )} >
@@ -127,6 +139,7 @@ export function PaletteShell({
diff --git a/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx b/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx index ada14a68c4..8a1df0388f 100644 --- a/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx +++ b/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx @@ -12,7 +12,6 @@ import { import { useStore } from "jotai"; import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport"; import { cn } from "@bb/shared-ui/lib/utils"; -import { COARSE_POINTER_TEXT_SM_CLASS } from "@bb/shared-ui/coarse-pointer-sizing"; import { isPromptDraftEmpty } from "@bb/client-core"; import type { ThreadSearchHighlightRange } from "@bb/server-contract"; import { usePromptDraftStorage } from "@/hooks/usePromptDraftStorage"; @@ -275,6 +274,7 @@ export function ThreadSearchPaletteMode({ /> } footerKeys={presentation.footerKeys} + inputDescription={presentation.inputDescription} inputLabel="Search threads" inputRef={inputRef} listId={listId} @@ -451,12 +451,6 @@ function ThreadSearchPaletteRow({ } }, [matchKey, row.highlightRanges.length, shouldWindowMatch]); - const stateLabel = - row.lifecycle === "active" - ? null - : row.lifecycle === "draft" - ? "Draft" - : "Archived"; return (
- - {row.metadataText} - + {row.metadataText.length === 0 ? null : ( + + {row.metadataText} + + )} - {stateLabel === null ? null : ( - - {stateLabel} - - )}
); } diff --git a/apps/app/src/lib/command-palette/palette-mode.ts b/apps/app/src/lib/command-palette/palette-mode.ts index 873a54d32e..d4b9973b9b 100644 --- a/apps/app/src/lib/command-palette/palette-mode.ts +++ b/apps/app/src/lib/command-palette/palette-mode.ts @@ -11,6 +11,7 @@ export interface PaletteModePresentation { keys: readonly string[]; label: string; }[]; + inputDescription: 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 b1d20b2330..1d83a263d4 100644 --- a/apps/app/src/lib/command-palette/palette-modes.ts +++ b/apps/app/src/lib/command-palette/palette-modes.ts @@ -8,11 +8,11 @@ export const PALETTE_MODES: readonly PaletteModeRegistration[] = [ chip: { icon: "Search", label: "Threads" }, placeholder: "Search title, project, or message…", footerKeys: [ - { keys: ["↑↓"], label: "Select" }, - { keys: ["↵"], label: "Open" }, { keys: ["⌘↵"], label: "Split" }, - { keys: ["Backspace", "Esc"], label: "Back" }, + { keys: ["Esc"], label: "Back" }, ], + inputDescription: + "Use Command-Enter or Control-Enter to open the selected thread in a split. Use Escape to return to commands.", View: ThreadSearchPaletteMode, }, ]; 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 index ff7d5ba541..36b9b2cde4 100644 --- a/apps/app/src/lib/command-palette/palette-thread-search.test.ts +++ b/apps/app/src/lib/command-palette/palette-thread-search.test.ts @@ -108,16 +108,21 @@ describe("buildPaletteThreadSearchRows", () => { "draft", "archived", ]); + expect(result.rows.map((row) => row.metadataText)).toEqual([ + "Palette project · just now", + "Draft · Palette project · just now", + "Archived · Palette project · just now", + ]); expect(result.draftMatchCount).toBe(1); }); - it("narrows lifecycle immediately without changing row anatomy", () => { - const result = build({ + it("omits redundant lifecycle metadata after narrowing scope", () => { + const draftResult = build({ drafts: [makeDraft("draft", "A matching local draft")], scope: "draft", }); - expect(result.rows).toMatchObject([ + expect(draftResult.rows).toMatchObject([ { lifecycle: "draft", metadataText: "Palette project · just now", @@ -125,6 +130,26 @@ describe("buildPaletteThreadSearchRows", () => { draftSlotId: "draft", }, ]); + + const archived = makeThread("archived", { archivedAt: NOW - 1 }); + const archivedResult = build({ + scope: "archived", + searchResponse: { + active: { results: [], total: 0 }, + archived: { + total: 1, + results: [{ thread: archived, matches: [] }], + }, + }, + }); + + expect(archivedResult.rows).toMatchObject([ + { + lifecycle: "archived", + metadataText: "Palette project · just now", + threadId: "archived", + }, + ]); }); it("uses the matched message as primary while retaining title, project, and time metadata", () => { diff --git a/apps/app/src/lib/command-palette/palette-thread-search.ts b/apps/app/src/lib/command-palette/palette-thread-search.ts index c6473857a4..cec655eb2e 100644 --- a/apps/app/src/lib/command-palette/palette-thread-search.ts +++ b/apps/app/src/lib/command-palette/palette-thread-search.ts @@ -80,10 +80,19 @@ function metadataText(parts: readonly (string | null)[]): string { return parts.filter((part): part is string => Boolean(part)).join(" · "); } +function lifecycleMetadata( + scope: PaletteThreadSearchScope, + lifecycle: PaletteThreadLifecycle, +): string | null { + if (scope !== "all" || lifecycle === "active") return null; + return lifecycle === "draft" ? "Draft" : "Archived"; +} + function serverRow( thread: ThreadListEntry, matches: readonly ThreadSearchMatch[], lifecycle: "active" | "archived", + scope: PaletteThreadSearchScope, projectNamesById: ReadonlyMap, now: number, ): PaletteThreadSearchRow { @@ -99,6 +108,7 @@ function serverRow( primaryText: primaryMatch?.text ?? title, highlightRanges: primaryMatch?.highlightRanges ?? [], metadataText: metadataText([ + lifecycleMetadata(scope, lifecycle), snippetMatch === undefined ? null : title, projectMetadata(thread.projectId, projectNamesById), formatRelativeTime({ timestamp: thread.updatedAt, now }), @@ -157,13 +167,16 @@ export function buildPaletteThreadSearchRows({ const activeRows = isRecent ? recentThreads .slice(0, RECENT_THREAD_LIMIT) - .map((thread) => serverRow(thread, [], "active", projectNamesById, now)) + .map((thread) => + serverRow(thread, [], "active", scope, projectNamesById, now), + ) : isSearchable && searchResultsAreCurrent ? (searchResponse?.active.results ?? []).map((result) => serverRow( result.thread, result.matches, "active", + scope, projectNamesById, now, ), @@ -185,6 +198,7 @@ export function buildPaletteThreadSearchRows({ primaryText: item.title, highlightRanges: draftHighlightRanges(item.title, positions), metadataText: metadataText([ + lifecycleMetadata(scope, "draft"), projectMetadata(item.destination.projectId, projectNamesById), item.lastEditedAt === null ? null @@ -199,7 +213,7 @@ export function buildPaletteThreadSearchRows({ ? recentArchivedThreads .slice(0, RECENT_THREAD_LIMIT) .map((thread) => - serverRow(thread, [], "archived", projectNamesById, now), + serverRow(thread, [], "archived", scope, projectNamesById, now), ) : isSearchable && searchResultsAreCurrent ? (searchResponse?.archived.results ?? []).map((result) => @@ -207,6 +221,7 @@ export function buildPaletteThreadSearchRows({ result.thread, result.matches, "archived", + scope, projectNamesById, now, ), From 898e7206af3ad874f6a69fd500ae5214258e421a Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Thu, 3 Sep 2026 03:26:16 -0700 Subject: [PATCH 06/25] Align palette metadata assertions --- apps/app/src/components/commands/CommandPalette.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/app/src/components/commands/CommandPalette.test.tsx b/apps/app/src/components/commands/CommandPalette.test.tsx index a1990f3a70..751a9b872a 100644 --- a/apps/app/src/components/commands/CommandPalette.test.tsx +++ b/apps/app/src/components/commands/CommandPalette.test.tsx @@ -837,7 +837,7 @@ describe("CommandPalette", () => { within(results) .getByRole("option") .querySelector("[data-palette-thread-metadata]"), - "Palette project · just now", + "Palette project", ); fireEvent.keyDown(scope, { key: "Escape" }); expect(document.activeElement).toBe(input); @@ -896,7 +896,7 @@ describe("CommandPalette", () => { } expect( rows[1]?.querySelector("[data-palette-thread-metadata]")?.textContent, - ).toBe("Draft · Palette project · just now"); + ).toBe("Draft · Palette project"); expect( rows[2]?.querySelector("[data-palette-thread-metadata]")?.textContent, ).toBe("Archived · Palette project · just now"); @@ -977,7 +977,7 @@ describe("CommandPalette", () => { } expect( rows[1]?.querySelector("[data-palette-thread-metadata]")?.textContent, - ).toBe("Draft · Palette project · just now"); + ).toBe("Draft · Palette project"); expect( rows[2]?.querySelector("[data-palette-thread-metadata]")?.textContent, ).toBe("Archived · Palette project · just now"); From 3fb83cf3bbfcadd0944893dcd6bc187699d99aa1 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Thu, 3 Sep 2026 11:58:54 -0700 Subject: [PATCH 07/25] Restore trailing thread lifecycle labels --- .../components/commands/CommandPalette.test.tsx | 16 ++++++++++------ .../commands/ThreadSearchPaletteMode.tsx | 17 +++++++++++++++++ .../palette-thread-search.test.ts | 6 +++--- .../command-palette/palette-thread-search.ts | 17 ++--------------- 4 files changed, 32 insertions(+), 24 deletions(-) diff --git a/apps/app/src/components/commands/CommandPalette.test.tsx b/apps/app/src/components/commands/CommandPalette.test.tsx index 751a9b872a..1284b943d9 100644 --- a/apps/app/src/components/commands/CommandPalette.test.tsx +++ b/apps/app/src/components/commands/CommandPalette.test.tsx @@ -885,7 +885,6 @@ 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( @@ -896,10 +895,16 @@ describe("CommandPalette", () => { } expect( rows[1]?.querySelector("[data-palette-thread-metadata]")?.textContent, - ).toBe("Draft · Palette project"); + ).toBe("Palette project"); expect( rows[2]?.querySelector("[data-palette-thread-metadata]")?.textContent, - ).toBe("Archived · Palette project · just now"); + ).toBe("Palette project · just now"); + const draftState = rows[1]?.lastElementChild; + const archivedState = rows[2]?.lastElementChild; + expectText(draftState, "Draft"); + expectText(archivedState, "Archived"); + expectClasses(draftState, "shrink-0", "text-subtle-foreground"); + expectClasses(archivedState, "shrink-0", "text-subtle-foreground"); expect(within(results).queryAllByRole("group")).toHaveLength(0); expect(within(results).queryByText("Recent")).toBeNull(); }); @@ -977,11 +982,10 @@ describe("CommandPalette", () => { } expect( rows[1]?.querySelector("[data-palette-thread-metadata]")?.textContent, - ).toBe("Draft · Palette project"); + ).toBe("Palette project"); expect( rows[2]?.querySelector("[data-palette-thread-metadata]")?.textContent, - ).toBe("Archived · Palette project · just now"); - expect(results.querySelector("[data-palette-thread-state]")).toBeNull(); + ).toBe("Palette project · just now"); expect(within(results).queryAllByRole("group")).toHaveLength(0); expect(within(results).queryByText("Recent")).toBeNull(); expect(results.textContent).not.toContain("1/1"); diff --git a/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx b/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx index 8a1df0388f..c4b6a59ace 100644 --- a/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx +++ b/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx @@ -12,6 +12,7 @@ import { import { useStore } from "jotai"; import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport"; import { cn } from "@bb/shared-ui/lib/utils"; +import { COARSE_POINTER_TEXT_SM_CLASS } from "@bb/shared-ui/coarse-pointer-sizing"; import { isPromptDraftEmpty } from "@bb/client-core"; import type { ThreadSearchHighlightRange } from "@bb/server-contract"; import { usePromptDraftStorage } from "@/hooks/usePromptDraftStorage"; @@ -451,6 +452,12 @@ function ThreadSearchPaletteRow({ } }, [matchKey, row.highlightRanges.length, shouldWindowMatch]); + const stateLabel = + row.lifecycle === "active" + ? null + : row.lifecycle === "draft" + ? "Draft" + : "Archived"; return (
)} + {stateLabel === null ? null : ( + + {stateLabel} + + )}
); } 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 index 36b9b2cde4..df866af737 100644 --- a/apps/app/src/lib/command-palette/palette-thread-search.test.ts +++ b/apps/app/src/lib/command-palette/palette-thread-search.test.ts @@ -110,13 +110,13 @@ describe("buildPaletteThreadSearchRows", () => { ]); expect(result.rows.map((row) => row.metadataText)).toEqual([ "Palette project · just now", - "Draft · Palette project · just now", - "Archived · Palette project · just now", + "Palette project · just now", + "Palette project · just now", ]); expect(result.draftMatchCount).toBe(1); }); - it("omits redundant lifecycle metadata after narrowing scope", () => { + it("preserves metadata anatomy after narrowing lifecycle scope", () => { const draftResult = build({ drafts: [makeDraft("draft", "A matching local draft")], scope: "draft", diff --git a/apps/app/src/lib/command-palette/palette-thread-search.ts b/apps/app/src/lib/command-palette/palette-thread-search.ts index cec655eb2e..9acaa92dae 100644 --- a/apps/app/src/lib/command-palette/palette-thread-search.ts +++ b/apps/app/src/lib/command-palette/palette-thread-search.ts @@ -80,19 +80,10 @@ function metadataText(parts: readonly (string | null)[]): string { return parts.filter((part): part is string => Boolean(part)).join(" · "); } -function lifecycleMetadata( - scope: PaletteThreadSearchScope, - lifecycle: PaletteThreadLifecycle, -): string | null { - if (scope !== "all" || lifecycle === "active") return null; - return lifecycle === "draft" ? "Draft" : "Archived"; -} - function serverRow( thread: ThreadListEntry, matches: readonly ThreadSearchMatch[], lifecycle: "active" | "archived", - scope: PaletteThreadSearchScope, projectNamesById: ReadonlyMap, now: number, ): PaletteThreadSearchRow { @@ -108,7 +99,6 @@ function serverRow( primaryText: primaryMatch?.text ?? title, highlightRanges: primaryMatch?.highlightRanges ?? [], metadataText: metadataText([ - lifecycleMetadata(scope, lifecycle), snippetMatch === undefined ? null : title, projectMetadata(thread.projectId, projectNamesById), formatRelativeTime({ timestamp: thread.updatedAt, now }), @@ -168,7 +158,7 @@ export function buildPaletteThreadSearchRows({ ? recentThreads .slice(0, RECENT_THREAD_LIMIT) .map((thread) => - serverRow(thread, [], "active", scope, projectNamesById, now), + serverRow(thread, [], "active", projectNamesById, now), ) : isSearchable && searchResultsAreCurrent ? (searchResponse?.active.results ?? []).map((result) => @@ -176,7 +166,6 @@ export function buildPaletteThreadSearchRows({ result.thread, result.matches, "active", - scope, projectNamesById, now, ), @@ -198,7 +187,6 @@ export function buildPaletteThreadSearchRows({ primaryText: item.title, highlightRanges: draftHighlightRanges(item.title, positions), metadataText: metadataText([ - lifecycleMetadata(scope, "draft"), projectMetadata(item.destination.projectId, projectNamesById), item.lastEditedAt === null ? null @@ -213,7 +201,7 @@ export function buildPaletteThreadSearchRows({ ? recentArchivedThreads .slice(0, RECENT_THREAD_LIMIT) .map((thread) => - serverRow(thread, [], "archived", scope, projectNamesById, now), + serverRow(thread, [], "archived", projectNamesById, now), ) : isSearchable && searchResultsAreCurrent ? (searchResponse?.archived.results ?? []).map((result) => @@ -221,7 +209,6 @@ export function buildPaletteThreadSearchRows({ result.thread, result.matches, "archived", - scope, projectNamesById, now, ), From bc8e50887c5b00a1625b1d92e4e0fbcc40a25de7 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Wed, 9 Sep 2026 15:01:32 -0400 Subject: [PATCH 08/25] Keep thread scope menu outside palette results clipping --- .../commands/CommandPalette.test.tsx | 46 ++++++++++++++++++- .../components/commands/CommandPalette.tsx | 2 +- .../src/components/commands/PaletteShell.tsx | 4 +- 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/apps/app/src/components/commands/CommandPalette.test.tsx b/apps/app/src/components/commands/CommandPalette.test.tsx index 56caa66a57..219e200eea 100644 --- a/apps/app/src/components/commands/CommandPalette.test.tsx +++ b/apps/app/src/components/commands/CommandPalette.test.tsx @@ -433,7 +433,6 @@ describe("CommandPalette", () => { expectClasses( screen.getByTestId("command-palette"), "max-w-[640px]", - "overflow-hidden", "shadow-lg", "sm:rounded-xl", ); @@ -693,6 +692,51 @@ describe("CommandPalette", () => { ); }); + 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(); diff --git a/apps/app/src/components/commands/CommandPalette.tsx b/apps/app/src/components/commands/CommandPalette.tsx index 69eadee809..2113331a92 100644 --- a/apps/app/src/components/commands/CommandPalette.tsx +++ b/apps/app/src/components/commands/CommandPalette.tsx @@ -343,7 +343,7 @@ export function CommandPalette({ { if (activeMode !== undefined) event.preventDefault(); diff --git a/apps/app/src/components/commands/PaletteShell.tsx b/apps/app/src/components/commands/PaletteShell.tsx index 89eeddadb7..b5d5b38ac4 100644 --- a/apps/app/src/components/commands/PaletteShell.tsx +++ b/apps/app/src/components/commands/PaletteShell.tsx @@ -60,7 +60,7 @@ export function PaletteShell({ return ( <>
{footerKeys.map((hint) => ( From fbeabbd8d91f8f93b3e94784a739f48399d69539 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Wed, 9 Sep 2026 15:01:58 -0400 Subject: [PATCH 09/25] Update palette thread fixtures for current environment metadata --- apps/app/src/components/commands/CommandPalette.test.tsx | 3 +++ apps/app/src/lib/command-palette/palette-thread-search.test.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/apps/app/src/components/commands/CommandPalette.test.tsx b/apps/app/src/components/commands/CommandPalette.test.tsx index b99fff21e2..0b9e74d362 100644 --- a/apps/app/src/components/commands/CommandPalette.test.tsx +++ b/apps/app/src/components/commands/CommandPalette.test.tsx @@ -266,6 +266,9 @@ function makeThread( }, hasPendingInteraction: false, environmentHostId: null, + environmentPath: null, + environmentProviderId: null, + environmentIsWorktree: null, environmentName: null, environmentBranchName: null, environmentWorkspaceDisplayKind: "other", 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 index ff7d5ba541..4d4e02a2bf 100644 --- a/apps/app/src/lib/command-palette/palette-thread-search.test.ts +++ b/apps/app/src/lib/command-palette/palette-thread-search.test.ts @@ -44,6 +44,9 @@ function makeThread( }, hasPendingInteraction: false, environmentHostId: null, + environmentPath: null, + environmentProviderId: null, + environmentIsWorktree: null, environmentName: null, environmentBranchName: null, environmentWorkspaceDisplayKind: "other", From 25eff95532b1128c5ad6385f0ab8020b24b63f7b Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Wed, 9 Sep 2026 15:07:57 -0400 Subject: [PATCH 10/25] Align palette guide source anchor with results-only clipping --- packages/plugin-api-map/src/anatomy-manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugin-api-map/src/anatomy-manifest.json b/packages/plugin-api-map/src/anatomy-manifest.json index e2c6a1b4a3..83654b6f40 100644 --- a/packages/plugin-api-map/src/anatomy-manifest.json +++ b/packages/plugin-api-map/src/anatomy-manifest.json @@ -38,7 +38,7 @@ { "path": "apps/app/src/components/commands/CommandPalette.tsx", "anchors": [ - "top-[12%] max-w-[640px] translate-y-0 gap-0 overflow-hidden p-0", + "top-[12%] max-w-[640px] translate-y-0 gap-0 p-0", "aria-selected={isActive}", "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" From b9b9264da0a142a9398d919f82e32c5914c032b4 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Wed, 9 Sep 2026 15:13:34 -0400 Subject: [PATCH 11/25] Allow 2 KiB of raw boot budget for the persistent palette shell --- apps/app/bundle-budget.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", From 88848ccdf88fba7bcd75a44762a4d552ddddc25d Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Wed, 9 Sep 2026 15:28:57 -0400 Subject: [PATCH 12/25] Cover opening already archived threads in split panes --- .../SplitThreadArea.archive.test.tsx | 30 +++++++++++++- .../thread-detail/SplitThreadArea.test.tsx | 41 +++++++++++++++---- 2 files changed, 60 insertions(+), 11 deletions(-) diff --git a/apps/app/src/views/thread-detail/SplitThreadArea.archive.test.tsx b/apps/app/src/views/thread-detail/SplitThreadArea.archive.test.tsx index 038321a257..6d4993bf81 100644 --- a/apps/app/src/views/thread-detail/SplitThreadArea.archive.test.tsx +++ b/apps/app/src/views/thread-detail/SplitThreadArea.archive.test.tsx @@ -138,14 +138,14 @@ function twoPaneLayout(focusedPaneId: "pane-1" | "pane-2"): SplitLayout { }; } -function renderArchiveScenario() { +function renderArchiveScenario(initialArchivedAt: number | null = null) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); for (const id of ["thr-a", "thr-b"]) { queryClient.setQueryData(threadQueryKey(id), { id, - archivedAt: null, + archivedAt: id === "thr-b" ? initialArchivedAt : null, deletedAt: null, }); } @@ -177,6 +177,32 @@ afterEach(() => { }); describe("SplitThreadArea archive pruning", () => { + it("keeps an archived pane when its thread first loads, but closes it after unarchiving and archiving again", async () => { + deferArchive(); + const queryClient = renderArchiveScenario(ARCHIVED_AT); + expect(await screen.findByTestId("pane-thr-b")).toBeTruthy(); + expect(archivedAtOf(queryClient, "thr-b")).toBe(ARCHIVED_AT); + + await act(async () => { + queryClient.setQueryData(threadQueryKey("thr-b"), { + id: "thr-b", + archivedAt: null, + deletedAt: null, + }); + }); + fireEvent.click(screen.getByTestId("archive")); + await waitFor(() => + expect(archivedAtOf(queryClient, "thr-b")).toBe(ARCHIVED_AT), + ); + expect(screen.getByTestId("pane-thr-b")).toBeTruthy(); + + await act(async () => pendingArchive!.resolve()); + + await waitFor(() => expect(screen.queryByTestId("pane-thr-b")).toBeNull()); + expect(screen.getByTestId("pane-thr-a")).toBeTruthy(); + expect(screen.getByTestId("location").textContent).toBe("/threads/thr-a"); + }); + it("restores the pane, focus, and URL when a deferred archive is rejected", async () => { deferArchive(); const queryClient = renderArchiveScenario(); diff --git a/apps/app/src/views/thread-detail/SplitThreadArea.test.tsx b/apps/app/src/views/thread-detail/SplitThreadArea.test.tsx index 022b3e482d..4a7664a55f 100644 --- a/apps/app/src/views/thread-detail/SplitThreadArea.test.tsx +++ b/apps/app/src/views/thread-detail/SplitThreadArea.test.tsx @@ -2244,20 +2244,43 @@ describe("SplitThreadArea", () => { expect(screen.queryByTestId("pane-thr-b")).toBeNull(); }); - it("prunes a stale (archived) pane from a restored split", async () => { - threadStore.set("thr-b", { archivedAt: 123, deletedAt: null }); - renderSplitArea({ - path: threadPath("thr-a"), - layout: twoPaneLayout("pane-1"), - }); + it.each(["pane-1", "pane-2"] as const)( + "keeps archived threads in a restored split focused on %s", + async (focusedPaneId) => { + threadStore.set("thr-b", { archivedAt: 123, deletedAt: null }); + const path = threadPath(focusedPaneId === "pane-1" ? "thr-a" : "thr-b"); + const store = renderSplitArea({ + path, + layout: twoPaneLayout(focusedPaneId), + }); - await waitFor(() => { - expect(screen.queryByTestId("pane-thr-b")).toBeNull(); + expect(await screen.findByTestId("pane-thr-b")).toBeTruthy(); + expect(screen.getByTestId("pane-thr-a")).toBeTruthy(); + expect(screen.getByTestId("location").textContent).toBe(path); + expect(store.get(splitLayoutAtom)?.focusedPaneId).toBe(focusedPaneId); + expect(threadStore.get("thr-b")?.archivedAt).toBe(123); + }, + ); + + it("keeps an archived thread when navigating over an active thread in the same pane", async () => { + threadStore.set("thr-c", { archivedAt: 123, deletedAt: null }); + const store = renderSplitArea({ + path: threadPath("thr-b"), + layout: twoPaneLayout("pane-2"), + externalTo: threadPath("thr-c"), }); + + expect(screen.getByTestId("pane-thr-b")).toBeTruthy(); + fireEvent.click(screen.getByTestId("external-nav")); + + expect(await screen.findByTestId("pane-thr-c")).toBeTruthy(); expect(screen.getByTestId("pane-thr-a")).toBeTruthy(); + expect(screen.queryByTestId("pane-thr-b")).toBeNull(); expect(screen.getByTestId("location").textContent).toBe( - threadPath("thr-a"), + threadPath("thr-c"), ); + expect(store.get(splitLayoutAtom)?.focusedPaneId).toBe("pane-2"); + expect(threadStore.get("thr-c")?.archivedAt).toBe(123); }); it("prunes a stale focused pane and moves focus + URL to the survivor", async () => { From 3ff9a9dd2ad0b1e419ba009ca4d3201b4db4cb03 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Wed, 9 Sep 2026 15:34:43 -0400 Subject: [PATCH 13/25] Keep already archived threads readable in split panes --- .../hooks/mutations/thread-state-mutations.ts | 1 + .../SplitThreadArea.archive.test.tsx | 37 ++++++++++++++++++- .../views/thread-detail/SplitThreadArea.tsx | 19 ++++++++-- 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/apps/app/src/hooks/mutations/thread-state-mutations.ts b/apps/app/src/hooks/mutations/thread-state-mutations.ts index 0f2c9a24d9..7106807965 100644 --- a/apps/app/src/hooks/mutations/thread-state-mutations.ts +++ b/apps/app/src/hooks/mutations/thread-state-mutations.ts @@ -277,6 +277,7 @@ export function useUnarchiveThread() { const queryClient = useQueryClient(); return useMutation({ + mutationKey: ["unarchive-thread"], meta: { errorMessage: "Failed to unarchive thread.", }, diff --git a/apps/app/src/views/thread-detail/SplitThreadArea.archive.test.tsx b/apps/app/src/views/thread-detail/SplitThreadArea.archive.test.tsx index 6d4993bf81..e5a91ab419 100644 --- a/apps/app/src/views/thread-detail/SplitThreadArea.archive.test.tsx +++ b/apps/app/src/views/thread-detail/SplitThreadArea.archive.test.tsx @@ -20,6 +20,7 @@ import { import { PERSONAL_PROJECT_ID } from "@bb/domain"; import { afterEach, describe, expect, it, vi } from "vitest"; import { threadQueryKey } from "@/hooks/queries/query-keys"; +import { useUnarchiveThread } from "@/hooks/mutations/thread-state-mutations"; import { splitLayoutAtom } from "@/lib/split-layout/atoms"; import type { SplitLayout } from "@/lib/split-layout"; import { PaneContext } from "./PaneContext"; @@ -56,7 +57,10 @@ vi.mock("@/hooks/useRealtimeSubscription", () => ({ vi.mock("@/lib/sdk", () => ({ sdk: { - threads: { get: () => new Promise(() => {}) }, + threads: { + get: () => new Promise(() => {}), + unarchive: () => pendingArchive!.promise, + }, }, })); @@ -118,6 +122,19 @@ function LocationProbe() { return
{location.pathname}
; } +function UnarchiveHarness() { + const mutation = useUnarchiveThread(); + return ( + + ); +} + function twoPaneLayout(focusedPaneId: "pane-1" | "pane-2"): SplitLayout { const content = (threadId: string) => ({ kind: "thread" as const, @@ -158,6 +175,7 @@ function renderArchiveScenario(initialArchivedAt: number | null = null) { + , @@ -177,6 +195,23 @@ afterEach(() => { }); describe("SplitThreadArea archive pruning", () => { + it("keeps the archived pane and its focus when unarchiving is rejected", async () => { + deferArchive(); + const queryClient = renderArchiveScenario(ARCHIVED_AT); + expect(await screen.findByTestId("pane-thr-b")).toBeTruthy(); + + fireEvent.click(screen.getByTestId("unarchive")); + await waitFor(() => expect(archivedAtOf(queryClient, "thr-b")).toBeNull()); + await act(async () => pendingArchive!.reject(new Error("unarchive failed"))); + + await waitFor(() => + expect(archivedAtOf(queryClient, "thr-b")).toBe(ARCHIVED_AT), + ); + expect(screen.getByTestId("pane-thr-b").dataset.focused).toBe("true"); + expect(screen.getByTestId("pane-thr-a")).toBeTruthy(); + expect(screen.getByTestId("location").textContent).toBe("/threads/thr-b"); + }); + it("keeps an archived pane when its thread first loads, but closes it after unarchiving and archiving again", async () => { deferArchive(); const queryClient = renderArchiveScenario(ARCHIVED_AT); diff --git a/apps/app/src/views/thread-detail/SplitThreadArea.tsx b/apps/app/src/views/thread-detail/SplitThreadArea.tsx index 7103336b14..c51a30ddb5 100644 --- a/apps/app/src/views/thread-detail/SplitThreadArea.tsx +++ b/apps/app/src/views/thread-detail/SplitThreadArea.tsx @@ -763,6 +763,7 @@ function SplitTree(props: SplitTreeProps) { {} {node.content.kind === "thread" ? ( props.onPruneStalePane(node.paneId)} /> @@ -1448,6 +1449,10 @@ interface PaneStaleWatcherProps { function PaneStaleWatcher({ threadId, onStale }: PaneStaleWatcherProps) { const { data: thread, isSuccess, isError, error } = useThread(threadId); + const hasObservedUnarchived = useRef(false); + const unarchivesInFlight = useIsMutating({ + mutationKey: ["unarchive-thread"], + }); const archivesInFlight = useIsMutating({ predicate: (mutation) => mutation.options.meta?.lifecycleOperation === "archive_thread", @@ -1461,17 +1466,25 @@ function PaneStaleWatcher({ threadId, onStale }: PaneStaleWatcherProps) { thread !== undefined && thread.archivedAt !== null && archivesInFlight === 0; - const isStale = isGone || isDeleted || isConfirmedArchived; + const isUnarchived = + isSuccess && thread !== undefined && thread.archivedAt === null; const onStaleRef = useRef(onStale); useEffect(() => { onStaleRef.current = onStale; }, [onStale]); useEffect(() => { - if (isStale) { + if (isUnarchived && unarchivesInFlight === 0) { + hasObservedUnarchived.current = true; + } + if ( + isGone || + isDeleted || + (isConfirmedArchived && hasObservedUnarchived.current) + ) { onStaleRef.current(); } - }, [isStale]); + }, [isConfirmedArchived, isDeleted, isGone, isUnarchived, unarchivesInFlight]); return null; } From bb35971d364466d8b2e1404ef14ff9c0a87659f4 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Wed, 9 Sep 2026 18:03:28 -0400 Subject: [PATCH 14/25] Make palette defaults useful and guidance contextual --- .../commands/CommandPalette.test.tsx | 437 +++++++++++++----- .../components/commands/CommandPalette.tsx | 33 +- .../src/components/commands/PaletteShell.tsx | 63 +-- .../commands/ThreadSearchPaletteMode.tsx | 247 +++++----- .../src/lib/command-palette/palette-action.ts | 1 + .../src/lib/command-palette/palette-modes.ts | 5 +- .../command-palette/palette-ranking.test.ts | 18 +- .../lib/command-palette/palette-ranking.ts | 14 +- .../palette-settings-actions.ts | 4 +- .../palette-thread-search.test.ts | 47 +- .../command-palette/palette-thread-search.ts | 38 +- 11 files changed, 604 insertions(+), 303 deletions(-) diff --git a/apps/app/src/components/commands/CommandPalette.test.tsx b/apps/app/src/components/commands/CommandPalette.test.tsx index 5171e73648..9f5d0e0415 100644 --- a/apps/app/src/components/commands/CommandPalette.test.tsx +++ b/apps/app/src/components/commands/CommandPalette.test.tsx @@ -20,6 +20,7 @@ import { import { emptyPromptDraftState } from "@bb/client-core"; import type { ThreadSearchResponse } from "@bb/server-contract"; import { CHROME_SECTION_LABEL_CLASS } from "@bb/shared-ui/chrome-style-tokens"; +import { CompactViewportOverrideProvider } from "@bb/shared-ui/hooks/use-compact-viewport"; import { AppCommandProvider, useAppCommandHandler } from "./AppCommandProvider"; import { removePluginSlotRegistrations, @@ -104,6 +105,9 @@ const modeState = vi.hoisted(() => ({ archivedRecents: [] as ThreadListEntry[], drafts: [] as PaletteNewThreadDraft[], searchResponse: undefined as ThreadSearchResponse | undefined, + recentLoading: false, + recentError: false, + searchLoading: false, })); const openPaneContentInSplitMock = vi.hoisted(() => vi.fn()); const openThreadInSplitMock = vi.hoisted(() => vi.fn()); @@ -129,10 +133,7 @@ function expectNoClasses( } } -function expectText( - element: Element | null | undefined, - text: string, -): void { +function expectText(element: Element | null | undefined, text: string): void { expect(element?.textContent).toContain(text); } @@ -190,10 +191,6 @@ vi.mock("@/lib/app-query-client", () => ({ }, })); -vi.mock("@bb/shared-ui/hooks/use-compact-viewport", () => ({ - useIsCompactViewport: () => false, -})); - vi.mock("@/lib/split-layout/openThreadInSplit", () => ({ openThreadInSplit: openThreadInSplitMock, })); @@ -233,7 +230,8 @@ vi.mock("@/hooks/queries/sidebar-navigation-query", () => ({ ], personalProject: { id: "proj_personal", name: "Personal", threads: [] }, }, - isLoading: false, + isLoading: modeState.recentLoading, + isError: modeState.recentError, }), })); @@ -244,7 +242,8 @@ vi.mock("@/hooks/queries/thread-queries", async (importOriginal) => { ...actual, useArchivedThreads: () => ({ data: { pages: [modeState.archivedRecents] }, - isLoading: false, + isLoading: modeState.recentLoading, + isError: modeState.recentError, }), useThreadSearch: ({ query }: { query: string }) => ({ data: modeState.searchResponse, @@ -253,7 +252,7 @@ vi.mock("@/hooks/queries/thread-queries", async (importOriginal) => { isDebouncing: false, isError: false, isFetching: false, - isLoading: false, + isLoading: modeState.searchLoading, }), }; }); @@ -318,22 +317,27 @@ function makeThread( }; } -function renderPalette({ onSplit }: { onSplit?: () => void } = {}) { +function renderPalette({ + onSplit, + compact = false, +}: { onSplit?: () => void; compact?: boolean } = {}) { const result = render( - - - - - - - - - - - - , + + + + + + + + + + + + + + , ); screen.getByTestId("origin").focus(); return result; @@ -385,6 +389,9 @@ afterEach(() => { modeState.archivedRecents = []; modeState.drafts = []; modeState.searchResponse = undefined; + modeState.recentLoading = false; + modeState.recentError = false; + modeState.searchLoading = false; openPaneContentInSplitMock.mockReset(); openThreadInSplitMock.mockReset(); routeNavigateMock.mockReset(); @@ -417,11 +424,11 @@ describe("CommandPalette", () => { const groups = within(commandList()).getAllByRole("group"); expect( groups.map((group) => group.getAttribute("data-palette-bucket")), - ).toEqual(["Threads", "Actions"]); + ).toEqual(["Threads", "Actions", "Settings"]); expect( within(commandList()).queryByRole("group", { name: "Plugins" }), ).toBeNull(); - for (const [index, label] of ["Threads", "Actions"].entries()) { + for (const [index, label] of ["Threads", "Actions", "Settings"].entries()) { const header = within(groups[index] as HTMLElement).getByText(label, { selector: "div", }); @@ -472,10 +479,7 @@ describe("CommandPalette", () => { const rootFooter = screen .getByTestId("command-palette") .querySelector("[data-palette-footer]"); - expectText(rootFooter, "Close"); - expect(rootFooter?.textContent).not.toContain("Select"); - expect(rootFooter?.textContent).not.toContain("Run"); - expectAttribute(rootFooter, "aria-hidden", "true"); + expect(rootFooter).toBeNull(); const rootDescriptionId = searchField().getAttribute("aria-describedby"); expect(rootDescriptionId).not.toBeNull(); expectText( @@ -494,11 +498,7 @@ describe("CommandPalette", () => { } const searchThreadsRow = threadRows[1] as HTMLElement; expect(searchThreadsRow.querySelector("kbd")).not.toBeNull(); - expectAttribute( - searchThreadsRow, - "data-palette-action-kind", - "drill-in", - ); + expectAttribute(searchThreadsRow, "data-palette-action-kind", "drill-in"); expectText(searchThreadsRow, "Search threads…"); expect( searchThreadsRow.querySelector('[data-icon="ChevronRight"]'), @@ -513,11 +513,7 @@ describe("CommandPalette", () => { } expect(commandList().querySelector("[data-icon]")).toBeNull(); expectClasses(threadRows[0], "bg-state-hover", "text-foreground"); - expectAttribute( - actionRows[0], - "data-palette-action-kind", - "terminal", - ); + expectAttribute(actionRows[0], "data-palette-action-kind", "terminal"); expect( actionRows[0]?.querySelector('[data-icon="ChevronRight"]'), ).toBeNull(); @@ -527,6 +523,7 @@ describe("CommandPalette", () => { }); 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(() => @@ -546,8 +543,9 @@ describe("CommandPalette", () => { ); const scope = screen.getByRole("button", { name: "Thread scope" }); expect(scope.textContent).toContain("All"); - expectClasses(scope, "text-subtle-foreground", "opacity-70"); - expectText(screen.getByTestId("command-palette"), "Split"); + expectClasses(scope, "text-subtle-foreground"); + expect(scope.querySelector('[data-icon="ChevronDown"]')).not.toBeNull(); + expectText(screen.getByTestId("command-palette"), "Open in split"); const footer = screen .getByTestId("command-palette") .querySelector("[data-palette-footer]"); @@ -555,6 +553,7 @@ describe("CommandPalette", () => { footer, "flex-wrap", "bg-surface-recessed-soft-solid", + "border-border/40", "px-4", "py-2", ); @@ -579,9 +578,10 @@ describe("CommandPalette", () => { "shadow-xs", ); } - for (const label of - footer?.querySelectorAll("[data-palette-footer-label]") ?? []) { - expectClasses(label, "opacity-70"); + for (const label of footer?.querySelectorAll( + "[data-palette-footer-label]", + ) ?? []) { + expectClasses(label, "opacity-50"); expectClasses( label.closest("[data-palette-footer]"), "text-subtle-foreground", @@ -589,8 +589,8 @@ describe("CommandPalette", () => { } expect(footer?.textContent).not.toContain("Backspace"); expect(footer?.textContent).not.toContain("Select"); - expect(footer?.textContent).not.toContain("Open"); - expectText(footer, "Esc"); + expect(footer?.textContent).not.toContain("Esc"); + expectText(footer, "Open in split"); const threadInput = screen.getByRole("combobox", { name: "Search threads", }); @@ -639,6 +639,18 @@ describe("CommandPalette", () => { ).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") @@ -654,6 +666,97 @@ describe("CommandPalette", () => { ).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(["pointer", "keyboard"])( + "starts a thread from the first-use empty state with %s input", + async (inputMethod) => { + renderPalette(); + openThreadSearch(); + await screen.findByText("No threads yet"); + expect( + screen.getByText("Start a thread to ask a question or work on a task."), + ).toBeTruthy(); + const action = screen.getByRole("option", { name: "New thread" }); + expectAttribute(action, "aria-selected", "true"); + expectAttribute(searchField(), "aria-activedescendant", action.id); + expect( + screen + .getByTestId("command-palette") + .querySelector("[data-palette-footer]"), + ).toBeNull(); + if (inputMethod === "pointer") fireEvent.click(action); + else fireEvent.keyDown(searchField(), { key: "Enter" }); + await waitFor(() => expect(screen.queryByRole("combobox")).toBeNull()); + expect(testState.calls).toEqual(["thread.new"]); + }, + ); + + 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.queryByRole("option", { name: "New thread" })).toBeNull(); + expect( + screen + .getByTestId("command-palette") + .querySelector("[data-palette-footer]"), + ).toBeNull(); + }, + ); + + it("shows only the useful split hint and removes the footer on no matches and return to Commands", async () => { + modeState.activeRecents = [makeThread("selected")]; + renderPalette(); + openThreadSearch(); + await screen.findByText("Open in split"); + const palette = screen.getByTestId("command-palette"); + expect( + palette.querySelector("[data-palette-footer]")?.textContent, + ).not.toContain("Esc"); + fireEvent.change(searchField(), { target: { value: "no match" } }); + await screen.findByText("No matching threads"); + expect(palette.querySelector("[data-palette-footer]")).toBeNull(); + expect(searchField().hasAttribute("aria-activedescendant")).toBe(false); + fireEvent.keyDown(searchField(), { key: "Enter", ctrlKey: true }); + expect(openThreadInSplitMock).not.toHaveBeenCalled(); + fireEvent.change(searchField(), { target: { value: "" } }); + await screen.findByText("Open in split"); + fireEvent.keyDown(searchField(), { key: "Escape" }); + await screen.findByRole("combobox", { name: "Search commands" }); + expect(palette.querySelector("[data-palette-footer]")).toBeNull(); + }); + + it("omits split guidance while searching and on compact layouts", async () => { + modeState.activeRecents = [makeThread("selected")]; + modeState.searchLoading = true; + renderPalette({ compact: true }); + openThreadSearch(); + await screen.findByRole("combobox", { name: "Search threads" }); + expect( + screen + .getByTestId("command-palette") + .querySelector("[data-palette-footer]"), + ).toBeNull(); + fireEvent.change(searchField(), { target: { value: "search" } }); + await screen.findByText("Searching threads"); + expect( + screen + .getByTestId("command-palette") + .querySelector("[data-palette-footer]"), + ).toBeNull(); }); it("enters the same registered mode by running Search threads from the root", async () => { @@ -692,7 +795,7 @@ describe("CommandPalette", () => { expect(testState.calls).toEqual([]); }); - it("cycles the thread scope and resets it after leaving the mode", async () => { + it("selects a thread scope and resets it after leaving the mode", async () => { renderPalette(); openThreadSearch(); await waitFor(() => @@ -700,13 +803,17 @@ describe("CommandPalette", () => { ); const scope = screen.getByRole("button", { name: "Thread scope" }); scope.focus(); - fireEvent.keyDown(scope, { key: "ArrowDown" }); + fireEvent.keyDown(scope, { key: "Enter" }); + const options = await screen.findByRole("menu", { + name: "Thread scope options", + }); + fireEvent.click( + within(options).getByRole("menuitemradio", { name: "Active" }), + ); expect(scope.textContent).toContain("Active"); - expect( - screen.getByRole("listbox", { name: "Thread scope options" }), - ).toBeTruthy(); - fireEvent.keyDown(scope, { key: "Escape" }); - expect(document.activeElement).toBe(screen.getByRole("combobox")); + await waitFor(() => + expect(document.activeElement).toBe(screen.getByRole("combobox")), + ); fireEvent.keyDown(screen.getByRole("combobox"), { key: "Escape" }); await waitFor(() => @@ -732,45 +839,39 @@ describe("CommandPalette", () => { name: "Search threads", }); const scope = screen.getByRole("button", { name: "Thread scope" }); - fireEvent.click(scope); - const options = screen.getByRole("listbox", { + fireEvent.keyDown(scope, { key: "Enter" }); + const options = await screen.findByRole("menu", { 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-testid="command-palette"]')).toBeNull(); 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]"), + screen + .getByTestId("command-palette") + .querySelector("[data-palette-results-clip]"), "rounded-b-[inherit]", ); - const archived = within(options).getByRole("option", { name: "Archived" }); - fireEvent.pointerDown(archived); + const archived = within(options).getByRole("menuitemradio", { + name: "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); + await waitFor(() => { + expect( + screen.queryByRole("menu", { 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 () => { + it("opens the scope with Enter and closes only the menu with Escape", async () => { renderPalette(); openThreadSearch(); const input = await screen.findByRole("combobox", { @@ -781,22 +882,80 @@ describe("CommandPalette", () => { 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); + const options = await screen.findByRole("menu", { + name: "Thread scope options", + }); + const all = within(options).getByRole("menuitemradio", { name: "All" }); + await waitFor(() => expect(document.activeElement).toBe(all)); + fireEvent.keyDown(all, { key: "Escape" }); + await waitFor(() => { + expect(scope.getAttribute("aria-expanded")).toBe("false"); + expect( + screen.queryByRole("menu", { name: "Thread scope options" }), + ).toBeNull(); + expect(document.activeElement).toBe(input); + }); + expect(scope.textContent).toContain("All"); + expect(screen.getByTestId("command-palette")).toBeTruthy(); + }); + + it("dismisses the scope menu when returning to the search input", async () => { + renderPalette(); + openThreadSearch(); + const input = await screen.findByRole("combobox", { + name: "Search threads", + }); + fireEvent.change(input, { target: { value: "preserved query" } }); + const scope = screen.getByRole("button", { name: "Thread scope" }); + scope.focus(); fireEvent.keyDown(scope, { key: "Enter" }); + const options = await screen.findByRole("menu", { + name: "Thread scope options", + }); + await waitFor(() => + expect(document.activeElement).toBe( + within(options).getByRole("menuitemradio", { name: "All" }), + ), + ); - expect(scope.getAttribute("aria-expanded")).toBe("false"); + fireEvent.pointerDown(input, { pointerType: "mouse", button: 0 }); + input.focus(); + + await waitFor(() => { + expect( + screen.queryByRole("menu", { name: "Thread scope options" }), + ).toBeNull(); + expect(document.activeElement).toBe(input); + }); + expect(input.getAttribute("value")).toBe("preserved query"); + expect(screen.getByTestId("command-palette")).toBeTruthy(); + }); + + it("uses the shared compact drawer for scope choices without hiding the app", async () => { + const view = renderPalette({ compact: true }); + openThreadSearch(); + const scope = await screen.findByRole("button", { name: "Thread scope" }); + fireEvent.click(scope); + const drawer = await screen.findByRole("dialog", { name: "Thread scope" }); + const archived = await within(drawer).findByRole("menuitemradio", { + name: "Archived", + }); + expect(view.container.closest('[inert], [aria-hidden="true"]')).toBeNull(); expect( - screen.queryByRole("listbox", { name: "Thread scope options" }), - ).toBeNull(); - expect(document.activeElement).toBe(input); + drawer.querySelector("[data-persistent-drawer-content]") ?? + drawer.closest("[data-persistent-drawer-content]"), + ).not.toBeNull(); + + fireEvent.click(archived); + + await waitFor(() => expect(scope.textContent).toContain("Archived")); + expect(screen.getByTestId("command-palette")).toBeTruthy(); + expect(view.container.closest('[inert], [aria-hidden="true"]')).toBeNull(); + expect(screen.queryByRole("dialog", { name: "Thread scope" })).toBeNull(); }); - it("keeps the mode clear, input, and scope in a predictable tab sequence and applies every keyboard choice immediately", async () => { + it("keeps the tab sequence and applies scope choices only on selection", async () => { modeState.searchResponse = { active: { total: 1, @@ -832,7 +991,7 @@ describe("CommandPalette", () => { ); const input = screen.getByRole("combobox", { name: "Search threads" }); const scope = screen.getByRole("button", { name: "Thread scope" }); - expect(scope.querySelector("[data-icon]")).toBeNull(); + expect(scope.querySelector('[data-icon="ChevronDown"]')).not.toBeNull(); const modeSelect = screen.getByRole("button", { name: "Threads search" }); const clearMode = screen.getByRole("button", { name: "Return to commands", @@ -854,28 +1013,54 @@ describe("CommandPalette", () => { scope.focus(); fireEvent.keyDown(scope, { key: "ArrowDown" }); - expect(scope.textContent).toContain("Active"); - const scopeOptions = screen.getByRole("listbox", { + const scopeOptions = await screen.findByRole("menu", { name: "Thread scope options", }); expect( within(scopeOptions) - .getAllByRole("option") + .getAllByRole("menuitemradio") .map((option) => option.textContent), ).toEqual(["All", "Active", "Drafts", "Archived"]); - expect(scopeOptions.querySelector("[data-icon]")).toBeNull(); - expect(within(results).getAllByRole("option")).toHaveLength(1); + const all = within(scopeOptions).getByRole("menuitemradio", { + name: "All", + }); + const active = within(scopeOptions).getByRole("menuitemradio", { + name: "Active", + }); + expect(all.getAttribute("aria-checked")).toBe("true"); + expect(all.querySelector('[data-icon="Check"]')).not.toBeNull(); + await waitFor(() => expect(document.activeElement).toBe(all)); + fireEvent.keyDown(all, { key: "ArrowDown" }); + await waitFor(() => expect(document.activeElement).toBe(active)); + expect(scope.textContent).toContain("All"); + expect(within(results).getAllByRole("option")).toHaveLength(3); + fireEvent.keyDown(active, { key: "Enter" }); + await waitFor(() => { + expect(document.activeElement).toBe(input); + expect(within(results).getAllByRole("option")).toHaveLength(1); + }); + expect(scope.textContent).toContain("Active"); expect(within(results).getByRole("option").textContent).toContain( "matching-active", ); - fireEvent.keyDown(scope, { key: "Enter" }); - expect(document.activeElement).toBe(input); expect( - screen.queryByRole("listbox", { name: "Thread scope options" }), + screen.queryByRole("menu", { name: "Thread scope options" }), ).toBeNull(); scope.focus(); - fireEvent.keyDown(scope, { key: "ArrowDown" }); + fireEvent.keyDown(scope, { key: "Enter" }); + const reopenedOptions = await screen.findByRole("menu", { + name: "Thread scope options", + }); + expect( + within(reopenedOptions) + .getByRole("menuitemradio", { name: "Active" }) + .getAttribute("aria-checked"), + ).toBe("true"); + fireEvent.click( + within(reopenedOptions).getByRole("menuitemradio", { name: "Drafts" }), + ); + await waitFor(() => expect(document.activeElement).toBe(input)); expect(scope.textContent).toContain("Drafts"); expect(within(results).getAllByRole("option")).toHaveLength(1); expect(within(results).getByRole("option").textContent).toContain( @@ -884,11 +1069,9 @@ describe("CommandPalette", () => { expectText( within(results) .getByRole("option") - .querySelector("[data-palette-thread-metadata]"), + .querySelector("[data-palette-thread-metadata]")?.firstElementChild, "Palette project", ); - fireEvent.keyDown(scope, { key: "Escape" }); - expect(document.activeElement).toBe(input); fireEvent.click(within(results).getByRole("option")); await waitFor(() => expect(screen.queryByRole("combobox")).toBeNull()); @@ -900,8 +1083,10 @@ describe("CommandPalette", () => { ); }); - it("renders the resting thread mode as one unlabelled active, draft, archived list", async () => { - modeState.activeRecents = [makeThread("recent-active")]; + it("renders the resting thread mode in update order, with undated drafts last", async () => { + modeState.activeRecents = [ + makeThread("recent-active", { updatedAt: Date.now() + 1 }), + ]; modeState.archivedRecents = [ makeThread("recent-archived", { archivedAt: Date.now() }), ]; @@ -926,7 +1111,13 @@ describe("CommandPalette", () => { await waitFor(() => expect(within(results).getAllByRole("option")).toHaveLength(3), ); - const rows = within(results).getAllByRole("option"); + const orderedRows = within(results).getAllByRole("option"); + expect(orderedRows.map((row) => row.textContent)).toEqual([ + expect.stringContaining("Title recent-active"), + expect.stringContaining("Title recent-archived"), + expect.stringContaining("recent draft"), + ]); + const rows = [orderedRows[0], orderedRows[2], orderedRows[1]]; expect(rows[0]?.textContent).toContain("Title recent-active"); expect(rows[0]?.textContent).not.toContain("Active"); expect(rows[1]?.textContent).toContain("recent draft"); @@ -942,17 +1133,27 @@ describe("CommandPalette", () => { ); } expect( - rows[1]?.querySelector("[data-palette-thread-metadata]")?.textContent, + rows[1]?.querySelector("[data-palette-thread-metadata]") + ?.firstElementChild?.textContent, ).toBe("Palette project"); expect( - rows[2]?.querySelector("[data-palette-thread-metadata]")?.textContent, + rows[2]?.querySelector("[data-palette-thread-metadata]") + ?.firstElementChild?.textContent, ).toBe("Palette project · just now"); - const draftState = rows[1]?.lastElementChild; - const archivedState = rows[2]?.lastElementChild; + const draftMetadata = rows[1]?.querySelector( + "[data-palette-thread-metadata]", + ); + const archivedMetadata = rows[2]?.querySelector( + "[data-palette-thread-metadata]", + ); + const draftState = draftMetadata?.lastElementChild; + const archivedState = archivedMetadata?.lastElementChild; expectText(draftState, "Draft"); expectText(archivedState, "Archived"); - expectClasses(draftState, "shrink-0", "text-subtle-foreground"); - expectClasses(archivedState, "shrink-0", "text-subtle-foreground"); + expectClasses(draftState, "shrink-0"); + expectClasses(archivedState, "shrink-0"); + expectClasses(draftMetadata, "flex", "items-baseline"); + expectClasses(archivedMetadata, "flex", "items-baseline"); expect(within(results).queryAllByRole("group")).toHaveLength(0); expect(within(results).queryByText("Recent")).toBeNull(); }); @@ -1029,10 +1230,12 @@ describe("CommandPalette", () => { ); } expect( - rows[1]?.querySelector("[data-palette-thread-metadata]")?.textContent, + rows[1]?.querySelector("[data-palette-thread-metadata]") + ?.firstElementChild?.textContent, ).toBe("Palette project"); expect( - rows[2]?.querySelector("[data-palette-thread-metadata]")?.textContent, + rows[2]?.querySelector("[data-palette-thread-metadata]") + ?.firstElementChild?.textContent, ).toBe("Palette project · just now"); expect(within(results).queryAllByRole("group")).toHaveLength(0); expect(within(results).queryByText("Recent")).toBeNull(); @@ -1207,10 +1410,11 @@ describe("CommandPalette", () => { expect(testState.calls).toEqual([]); }); - it("offers the last command run first within its resting bucket", async () => { + it("keeps the default catalog unchanged after running a command", async () => { renderPalette(); openPalette(); await waitFor(() => expect(searchField()).toBeTruthy()); + const initialTitles = optionTitles(); fireEvent.change(searchField(), { target: { value: "toggle panel" } }); await waitFor(() => expect(selectedOption()?.textContent).toContain("Toggle panel"), @@ -1220,8 +1424,7 @@ describe("CommandPalette", () => { openPalette(); await waitFor(() => expect(searchField()).toBeTruthy()); - const actionRows = within(bucketGroup("Actions")).getAllByRole("option"); - expect(actionRows[0]?.textContent).toContain("Toggle panel"); + expect(optionTitles()).toEqual(initialTitles); }); it("closes on Escape without running anything", async () => { @@ -1310,7 +1513,9 @@ describe("CommandPalette", () => { fireEvent.change(searchField(), { target: { value: "files settings" }, }); - await waitFor(() => expect(screen.queryAllByRole("option")).toHaveLength(0)); + await waitFor(() => + expect(screen.queryAllByRole("option")).toHaveLength(0), + ); fireEvent.keyDown(searchField(), { key: "Escape" }); await waitFor(() => expect(screen.queryByRole("combobox")).toBeNull()); diff --git a/apps/app/src/components/commands/CommandPalette.tsx b/apps/app/src/components/commands/CommandPalette.tsx index 4530c3fded..34023d785c 100644 --- a/apps/app/src/components/commands/CommandPalette.tsx +++ b/apps/app/src/components/commands/CommandPalette.tsx @@ -56,7 +56,6 @@ import { PaletteShell } from "./PaletteShell"; const PALETTE_INPUT_LABEL = "Search commands"; const PALETTE_INPUT_DESCRIPTION = "Use Escape to close the command palette."; const PALETTE_PLACEHOLDER = "Search commands…"; -const ROOT_FOOTER_KEYS = [{ keys: ["Esc"], label: "Close" }] as const; const MODE_ENTRY_HANDLER_PRIORITY = 100; const MODE_BY_ACTION_ID = new Map( PALETTE_MODES.map((mode) => [ @@ -223,9 +222,7 @@ 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, - ); + })).filter((group) => group.entries.length > 0); return groups.map((group, index) => ({ ...group, startIndex: groups @@ -253,18 +250,15 @@ 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; @@ -356,7 +350,7 @@ export function CommandPalette({ ? undefined : `${optionIdPrefix}-${activeIndex}` } - footerKeys={ROOT_FOOTER_KEYS} + footerKeys={[]} inputDescription={PALETTE_INPUT_DESCRIPTION} inputLabel={PALETTE_INPUT_LABEL} listId={listId} @@ -479,10 +473,7 @@ function PaletteRow({ onClick={onSelect} > - + {hasTrailing ? ( diff --git a/apps/app/src/components/commands/PaletteShell.tsx b/apps/app/src/components/commands/PaletteShell.tsx index 6eafcba185..c465abd1e6 100644 --- a/apps/app/src/components/commands/PaletteShell.tsx +++ b/apps/app/src/components/commands/PaletteShell.tsx @@ -108,7 +108,10 @@ export function PaletteShell({
-
- {footerKeys.map((hint) => ( - - - {hint.keys.map((keys, index) => ( - - {index === 0 ? null : ( - - / - - )} - {keys} - - ))} - + {footerKeys.length === 0 ? null : ( +
+ {footerKeys.map((hint) => ( - {hint.label} + + {hint.keys.map((keys, index) => ( + + {index === 0 ? null : ( + + / + + )} + {keys} + + ))} + + + {hint.label} + - - ))} -
+ ))} +
+ )} ); } diff --git a/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx b/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx index c4b6a59ace..b567008c44 100644 --- a/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx +++ b/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx @@ -10,9 +10,16 @@ import { type ReactNode, } from "react"; import { useStore } from "jotai"; +import { Button } from "@bb/shared-ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@bb/shared-ui/dropdown-menu"; +import { Icon } from "@bb/shared-ui/icon"; import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport"; import { cn } from "@bb/shared-ui/lib/utils"; -import { COARSE_POINTER_TEXT_SM_CLASS } from "@bb/shared-ui/coarse-pointer-sizing"; import { isPromptDraftEmpty } from "@bb/client-core"; import type { ThreadSearchHighlightRange } from "@bb/server-contract"; import { usePromptDraftStorage } from "@/hooks/usePromptDraftStorage"; @@ -36,6 +43,14 @@ import { 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"; +import { useAppCommandRunner } from "./AppCommandProvider"; + +const EMPTY_SCOPE_MESSAGES = { + all: "No threads yet", + active: "No active threads", + draft: "No drafts yet", + archived: "No archived threads", +} satisfies Record; export function ThreadSearchPaletteMode({ onExit, @@ -48,6 +63,7 @@ export function ThreadSearchPaletteMode({ const listRef = useRef(null); const store = useStore(); const navigate = useRouteNavigate(); + const runner = useAppCommandRunner(); const isCompact = useIsCompactViewport(); const [query, setQuery] = useState(""); const [scope, setScope] = useState("all"); @@ -139,8 +155,26 @@ export function ThreadSearchPaletteMode({ result.rows.length === 0 ? -1 : Math.min(highlightedIndex, result.rows.length - 1); - const activeDescendantId = - activeIndex < 0 ? undefined : `${optionIdPrefix}-${activeIndex}`; + const isRecentLoading = + result.isRecent && (navigation.isLoading || archivedThreads.isLoading); + const hasLoadError = result.isRecent + ? navigation.isError || archivedThreads.isError + : searchResultsAreCurrent && threadSearch.isError; + const showNewThread = + result.rows.length === 0 && + result.isRecent && + scope === "all" && + !isRecentLoading && + !hasLoadError; + const activeDescendantId = showNewThread + ? `${optionIdPrefix}-new-thread` + : activeIndex < 0 + ? undefined + : `${optionIdPrefix}-${activeIndex}`; + + const startNewThread = useCallback(() => { + runAfterClose(() => runner.dispatch("thread.new", null)); + }, [runAfterClose, runner.dispatch]); const scrollOnNextHighlightRef = useRef(false); useEffect(() => { @@ -214,7 +248,18 @@ export function ThreadSearchPaletteMode({ onExit(); return; } - if (result.rows.length === 0) return; + if (result.rows.length === 0) { + if ( + showNewThread && + event.key === "Enter" && + !event.metaKey && + !event.ctrlKey + ) { + event.preventDefault(); + startNewThread(); + } + return; + } if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); scrollOnNextHighlightRef.current = true; @@ -239,7 +284,15 @@ export function ThreadSearchPaletteMode({ openRow(row, event.metaKey || event.ctrlKey); } }, - [activeIndex, onExit, openRow, query.length, result.rows], + [ + activeIndex, + onExit, + openRow, + query.length, + result.rows, + showNewThread, + startNewThread, + ], ); const isLoading = @@ -249,15 +302,18 @@ export function ThreadSearchPaletteMode({ threadSearch.isLoading); let emptyMessage: string | null = null; if (result.rows.length === 0) { - emptyMessage = isLoading - ? "Searching threads" - : trimmedQuery.length === 1 - ? "Type at least 2 characters" - : (navigation.isLoading || archivedThreads.isLoading) && result.isRecent - ? "Loading recent threads" - : result.isRecent - ? "No recent threads" - : "No matching threads"; + emptyMessage = + isLoading || isRecentLoading + ? result.isRecent + ? "Loading threads" + : "Searching threads" + : hasLoadError + ? "Couldn’t load threads" + : trimmedQuery.length === 1 + ? "Type at least 2 characters" + : result.isRecent + ? EMPTY_SCOPE_MESSAGES[scope] + : "No matching threads"; } return ( @@ -274,8 +330,12 @@ export function ThreadSearchPaletteMode({ }} /> } - footerKeys={presentation.footerKeys} - inputDescription={presentation.inputDescription} + footerKeys={activeIndex >= 0 && !isCompact ? presentation.footerKeys : []} + inputDescription={ + showNewThread + ? "Press Enter to start a new thread. Use Escape to return to commands." + : presentation.inputDescription + } inputLabel="Search threads" inputRef={inputRef} listId={listId} @@ -306,6 +366,25 @@ export function ThreadSearchPaletteMode({ onSelect={() => openRow(row, false)} /> )) + ) : showNewThread ? ( +
+
+

No threads yet

+

+ Start a thread to ask a question or work on a task. +

+
+ +
) : (

{emptyMessage} @@ -324,89 +403,49 @@ function ThreadSearchScopeFilter({ onScopeChange: (scope: PaletteThreadSearchScope) => void; scope: PaletteThreadSearchScope; }) { - const [open, setOpen] = useState(false); - const currentIndex = PALETTE_THREAD_SEARCH_SCOPES.findIndex( + const current = PALETTE_THREAD_SEARCH_SCOPES.find( (candidate) => candidate.id === scope, ); - const current = PALETTE_THREAD_SEARCH_SCOPES[currentIndex]; - const returnToInput = () => { - setOpen(false); - inputRef.current?.focus({ preventScroll: true }); - }; - const cycle = (direction: 1 | -1) => { - const nextIndex = - (currentIndex + direction + PALETTE_THREAD_SEARCH_SCOPES.length) % - PALETTE_THREAD_SEARCH_SCOPES.length; - const next = PALETTE_THREAD_SEARCH_SCOPES[nextIndex]; - if (next !== undefined) onScopeChange(next.id); - setOpen(true); - }; return ( -

- + + { + event.preventDefault(); + inputRef.current?.focus({ preventScroll: true }); }} + onEscapeKeyDown={(event) => event.stopPropagation()} > - {current?.label ?? "All"} - - - {open ? ( -
- {PALETTE_THREAD_SEARCH_SCOPES.map((option) => ( -
event.preventDefault()} - onClick={() => { - onScopeChange(option.id); - returnToInput(); - }} - > - {option.label} -
- ))} -
- ) : null} -
+ {PALETTE_THREAD_SEARCH_SCOPES.map((option) => ( + onScopeChange(option.id)} + > + {option.label} + {option.id === scope ? ( + + ) : null} + + ))} + + ); } @@ -480,29 +519,23 @@ function ThreadSearchPaletteRow({ ranges={primary.highlightRanges} /> - {row.metadataText.length === 0 ? null : ( + {row.metadataText.length === 0 && stateLabel === null ? null : ( - {row.metadataText} + + {row.metadataText} + + {stateLabel === null ? null : ( + {stateLabel} + )} )} - {stateLabel === null ? null : ( - - {stateLabel} - - )}
); } diff --git a/apps/app/src/lib/command-palette/palette-action.ts b/apps/app/src/lib/command-palette/palette-action.ts index 1736a78599..be819f9b61 100644 --- a/apps/app/src/lib/command-palette/palette-action.ts +++ b/apps/app/src/lib/command-palette/palette-action.ts @@ -3,6 +3,7 @@ import type { AppShortcutPresentation } from "@/lib/app-keybindings"; export const PALETTE_ACTION_BUCKETS = [ "Threads", "Actions", + "Settings", "Plugins", ] as const; diff --git a/apps/app/src/lib/command-palette/palette-modes.ts b/apps/app/src/lib/command-palette/palette-modes.ts index 1d83a263d4..1538417e71 100644 --- a/apps/app/src/lib/command-palette/palette-modes.ts +++ b/apps/app/src/lib/command-palette/palette-modes.ts @@ -7,10 +7,7 @@ export const PALETTE_MODES: readonly PaletteModeRegistration[] = [ entryCommand: "thread.search", chip: { icon: "Search", label: "Threads" }, placeholder: "Search title, project, or message…", - footerKeys: [ - { keys: ["⌘↵"], label: "Split" }, - { keys: ["Esc"], label: "Back" }, - ], + footerKeys: [{ keys: ["⌘↵"], label: "Open in split" }], inputDescription: "Use Command-Enter or Control-Enter to open the selected thread in a split. Use Escape to return to commands.", View: ThreadSearchPaletteMode, 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 f4bdd81b26..0146b94e40 100644 --- a/apps/app/src/lib/command-palette/palette-ranking.test.ts +++ b/apps/app/src/lib/command-palette/palette-ranking.test.ts @@ -32,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({ @@ -41,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", () => { @@ -53,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 a422214083..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,7 +18,7 @@ export function buildSettingsPaletteActions( return [ ...args.sections.map((section) => ({ id: `settings:${section.id}`, - bucket: "Actions" as const, + bucket: "Settings" as const, group: "Settings", title: `${section.label} settings`, shortcut: null, @@ -26,7 +26,7 @@ export function buildSettingsPaletteActions( })), ...args.pluginEntries.map((plugin) => ({ id: `settings:plugin:${plugin.id}`, - bucket: "Actions" as const, + 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.test.ts b/apps/app/src/lib/command-palette/palette-thread-search.test.ts index f9ef041e99..8fdc20cedc 100644 --- a/apps/app/src/lib/command-palette/palette-thread-search.test.ts +++ b/apps/app/src/lib/command-palette/palette-thread-search.test.ts @@ -186,7 +186,7 @@ describe("buildPaletteThreadSearchRows", () => { }); }); - it("orders active, draft, and archived recents and does not reuse them for a one-character query", () => { + it("uses stable source order for equal timestamps and does not reuse recents for a one-character query", () => { const active = makeThread("recent-active"); const archived = makeThread("recent-archived", { archivedAt: NOW - 1 }); const recents = build({ @@ -208,4 +208,49 @@ describe("buildPaletteThreadSearchRows", () => { rows: [], }); }); + + it("orders existing threads and dated drafts by last updated across projects and lifecycles", () => { + const older = makeThread("older", { updatedAt: NOW - 100, pinnedAt: NOW }); + const newest = makeThread("newest", { + projectId: "project-2", + updatedAt: NOW, + }); + const archived = makeThread("archived", { + updatedAt: NOW - 50, + archivedAt: NOW, + }); + const datedDraft = { + ...makeDraft("dated", "Dated draft"), + lastEditedAt: NOW - 25, + }; + const undatedDraft = { + ...makeDraft("undated", "Undated draft"), + lastEditedAt: null, + }; + expect( + build({ + query: "", + recentThreads: [older, newest], + recentArchivedThreads: [archived], + drafts: [undatedDraft, datedDraft], + }).rows.map((row) => row.id), + ).toEqual([ + "active:newest", + "draft:dated", + "archived:archived", + "active:older", + "draft:undated", + ]); + }); + + 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 index 9acaa92dae..083068f284 100644 --- a/apps/app/src/lib/command-palette/palette-thread-search.ts +++ b/apps/app/src/lib/command-palette/palette-thread-search.ts @@ -155,11 +155,10 @@ export function buildPaletteThreadSearchRows({ const isRecent = trimmedQuery.length === 0; const isSearchable = trimmedQuery.length >= 2; const activeRows = isRecent - ? recentThreads + ? [...recentThreads] + .sort((left, right) => right.updatedAt - left.updatedAt) .slice(0, RECENT_THREAD_LIMIT) - .map((thread) => - serverRow(thread, [], "active", projectNamesById, now), - ) + .map((thread) => serverRow(thread, [], "active", projectNamesById, now)) : isSearchable && searchResultsAreCurrent ? (searchResponse?.active.results ?? []).map((result) => serverRow( @@ -198,7 +197,8 @@ export function buildPaletteThreadSearchRows({ messageSeq: null, })); const archivedRows = isRecent - ? recentArchivedThreads + ? [...recentArchivedThreads] + .sort((left, right) => right.updatedAt - left.updatedAt) .slice(0, RECENT_THREAD_LIMIT) .map((thread) => serverRow(thread, [], "archived", projectNamesById, now), @@ -215,13 +215,31 @@ export function buildPaletteThreadSearchRows({ ) : []; + const rows = [ + ...(includesLifecycle(scope, "active") ? activeRows : []), + ...(includesLifecycle(scope, "draft") ? draftRows : []), + ...(includesLifecycle(scope, "archived") ? archivedRows : []), + ]; + if (isRecent) { + const updatedAtByThread = new Map( + [...recentThreads, ...recentArchivedThreads].map((thread) => [ + thread.id, + thread.updatedAt, + ]), + ); + const editedAtByDraft = new Map( + drafts.map((draft) => [draft.id, draft.lastEditedAt]), + ); + const updatedAt = (row: PaletteThreadSearchRow) => + row.threadId !== null + ? (updatedAtByThread.get(row.threadId) ?? 0) + : (editedAtByDraft.get(row.draftSlotId ?? "") ?? 0); + rows.sort((left, right) => updatedAt(right) - updatedAt(left)); + } + return { draftMatchCount: draftMatches.length, isRecent, - rows: [ - ...(includesLifecycle(scope, "active") ? activeRows : []), - ...(includesLifecycle(scope, "draft") ? draftRows : []), - ...(includesLifecycle(scope, "archived") ? archivedRows : []), - ], + rows, }; } From 4f08192de4cdc6e7f6aac9aa98b35ee79001774d Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Wed, 9 Sep 2026 18:12:16 -0400 Subject: [PATCH 15/25] Align palette scope tests with accessible menu naming --- .../commands/CommandPalette.test.tsx | 28 +++++++------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/apps/app/src/components/commands/CommandPalette.test.tsx b/apps/app/src/components/commands/CommandPalette.test.tsx index 9f5d0e0415..f62d2eebfd 100644 --- a/apps/app/src/components/commands/CommandPalette.test.tsx +++ b/apps/app/src/components/commands/CommandPalette.test.tsx @@ -805,7 +805,7 @@ describe("CommandPalette", () => { scope.focus(); fireEvent.keyDown(scope, { key: "Enter" }); const options = await screen.findByRole("menu", { - name: "Thread scope options", + name: "Thread scope", }); fireEvent.click( within(options).getByRole("menuitemradio", { name: "Active" }), @@ -841,7 +841,7 @@ describe("CommandPalette", () => { const scope = screen.getByRole("button", { name: "Thread scope" }); fireEvent.keyDown(scope, { key: "Enter" }); const options = await screen.findByRole("menu", { - name: "Thread scope options", + name: "Thread scope", }); expect(options.closest('[data-testid="command-palette"]')).toBeNull(); expect(options.closest("[data-palette-results-clip]")).toBeNull(); @@ -864,9 +864,7 @@ describe("CommandPalette", () => { expect(scope.textContent).toContain("Archived"); expect(screen.getByTestId("command-palette")).toBeTruthy(); await waitFor(() => { - expect( - screen.queryByRole("menu", { name: "Thread scope options" }), - ).toBeNull(); + expect(screen.queryByRole("menu", { name: "Thread scope" })).toBeNull(); expect(document.activeElement).toBe(input); }); }); @@ -883,7 +881,7 @@ describe("CommandPalette", () => { fireEvent.keyDown(scope, { key: "Enter" }); const options = await screen.findByRole("menu", { - name: "Thread scope options", + name: "Thread scope", }); const all = within(options).getByRole("menuitemradio", { name: "All" }); await waitFor(() => expect(document.activeElement).toBe(all)); @@ -891,9 +889,7 @@ describe("CommandPalette", () => { await waitFor(() => { expect(scope.getAttribute("aria-expanded")).toBe("false"); - expect( - screen.queryByRole("menu", { name: "Thread scope options" }), - ).toBeNull(); + expect(screen.queryByRole("menu", { name: "Thread scope" })).toBeNull(); expect(document.activeElement).toBe(input); }); expect(scope.textContent).toContain("All"); @@ -911,7 +907,7 @@ describe("CommandPalette", () => { scope.focus(); fireEvent.keyDown(scope, { key: "Enter" }); const options = await screen.findByRole("menu", { - name: "Thread scope options", + name: "Thread scope", }); await waitFor(() => expect(document.activeElement).toBe( @@ -923,9 +919,7 @@ describe("CommandPalette", () => { input.focus(); await waitFor(() => { - expect( - screen.queryByRole("menu", { name: "Thread scope options" }), - ).toBeNull(); + expect(screen.queryByRole("menu", { name: "Thread scope" })).toBeNull(); expect(document.activeElement).toBe(input); }); expect(input.getAttribute("value")).toBe("preserved query"); @@ -1014,7 +1008,7 @@ describe("CommandPalette", () => { scope.focus(); fireEvent.keyDown(scope, { key: "ArrowDown" }); const scopeOptions = await screen.findByRole("menu", { - name: "Thread scope options", + name: "Thread scope", }); expect( within(scopeOptions) @@ -1043,14 +1037,12 @@ describe("CommandPalette", () => { expect(within(results).getByRole("option").textContent).toContain( "matching-active", ); - expect( - screen.queryByRole("menu", { name: "Thread scope options" }), - ).toBeNull(); + expect(screen.queryByRole("menu", { name: "Thread scope" })).toBeNull(); scope.focus(); fireEvent.keyDown(scope, { key: "Enter" }); const reopenedOptions = await screen.findByRole("menu", { - name: "Thread scope options", + name: "Thread scope", }); expect( within(reopenedOptions) From d19dbfb476f70602e952f49711a7d30e84f4bfe5 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Wed, 9 Sep 2026 18:12:59 -0400 Subject: [PATCH 16/25] Track the command runner in the empty-state callback --- apps/app/src/components/commands/ThreadSearchPaletteMode.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx b/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx index b567008c44..67a63d0292 100644 --- a/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx +++ b/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx @@ -174,7 +174,7 @@ export function ThreadSearchPaletteMode({ const startNewThread = useCallback(() => { runAfterClose(() => runner.dispatch("thread.new", null)); - }, [runAfterClose, runner.dispatch]); + }, [runAfterClose, runner]); const scrollOnNextHighlightRef = useRef(false); useEffect(() => { From 75de5ebe0cfc8154af0d609ef8c16be21d94c92d Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Wed, 9 Sep 2026 18:20:16 -0400 Subject: [PATCH 17/25] Sort palette rows using their source timestamps --- .../command-palette/palette-thread-search.ts | 29 +++++++++---------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/apps/app/src/lib/command-palette/palette-thread-search.ts b/apps/app/src/lib/command-palette/palette-thread-search.ts index 083068f284..516959273f 100644 --- a/apps/app/src/lib/command-palette/palette-thread-search.ts +++ b/apps/app/src/lib/command-palette/palette-thread-search.ts @@ -37,6 +37,7 @@ export interface PaletteThreadSearchRow { primaryText: string; highlightRanges: readonly ThreadSearchHighlightRange[]; metadataText: string; + updatedAt: number | null; projectId: string; threadId: string | null; draftSlotId: string | null; @@ -63,6 +64,13 @@ export interface PaletteThreadSearchRowsResult { const RECENT_THREAD_LIMIT = 20; +function newestFirst( + left: { updatedAt: number | null }, + right: { updatedAt: number | null }, +): number { + return (right.updatedAt ?? 0) - (left.updatedAt ?? 0); +} + function isTitleMatch(match: ThreadSearchMatch): boolean { return match.sourceKind === "title" || match.sourceKind === "title_fallback"; } @@ -104,6 +112,7 @@ function serverRow( formatRelativeTime({ timestamp: thread.updatedAt, now }), ]), projectId: thread.projectId, + updatedAt: thread.updatedAt, threadId: thread.id, draftSlotId: null, messageSeq: snippetMatch?.sourceSeq ?? null, @@ -156,7 +165,7 @@ export function buildPaletteThreadSearchRows({ const isSearchable = trimmedQuery.length >= 2; const activeRows = isRecent ? [...recentThreads] - .sort((left, right) => right.updatedAt - left.updatedAt) + .sort(newestFirst) .slice(0, RECENT_THREAD_LIMIT) .map((thread) => serverRow(thread, [], "active", projectNamesById, now)) : isSearchable && searchResultsAreCurrent @@ -192,13 +201,14 @@ export function buildPaletteThreadSearchRows({ : formatRelativeTime({ timestamp: item.lastEditedAt, now }), ]), projectId: item.destination.projectId, + updatedAt: item.lastEditedAt, threadId: null, draftSlotId: item.id, messageSeq: null, })); const archivedRows = isRecent ? [...recentArchivedThreads] - .sort((left, right) => right.updatedAt - left.updatedAt) + .sort(newestFirst) .slice(0, RECENT_THREAD_LIMIT) .map((thread) => serverRow(thread, [], "archived", projectNamesById, now), @@ -221,20 +231,7 @@ export function buildPaletteThreadSearchRows({ ...(includesLifecycle(scope, "archived") ? archivedRows : []), ]; if (isRecent) { - const updatedAtByThread = new Map( - [...recentThreads, ...recentArchivedThreads].map((thread) => [ - thread.id, - thread.updatedAt, - ]), - ); - const editedAtByDraft = new Map( - drafts.map((draft) => [draft.id, draft.lastEditedAt]), - ); - const updatedAt = (row: PaletteThreadSearchRow) => - row.threadId !== null - ? (updatedAtByThread.get(row.threadId) ?? 0) - : (editedAtByDraft.get(row.draftSlotId ?? "") ?? 0); - rows.sort((left, right) => updatedAt(right) - updatedAt(left)); + rows.sort(newestFirst); } return { From 432d8ce8fad982928d4da9931e30e7a94c0d9c30 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Wed, 9 Sep 2026 23:22:49 -0400 Subject: [PATCH 18/25] Reuse the sidebar empty state in thread search --- .../commands/CommandPalette.test.tsx | 47 ++++++------ .../commands/ThreadSearchPaletteMode.tsx | 71 ++++--------------- .../app/src/components/sidebar/ProjectRow.tsx | 33 ++------- .../thread/ThreadListEmptyState.tsx | 23 ++++++ 4 files changed, 66 insertions(+), 108 deletions(-) create mode 100644 apps/app/src/components/thread/ThreadListEmptyState.tsx diff --git a/apps/app/src/components/commands/CommandPalette.test.tsx b/apps/app/src/components/commands/CommandPalette.test.tsx index f62d2eebfd..0ff1acb368 100644 --- a/apps/app/src/components/commands/CommandPalette.test.tsx +++ b/apps/app/src/components/commands/CommandPalette.test.tsx @@ -673,29 +673,26 @@ describe("CommandPalette", () => { expect(optionTitles()).toEqual(commandsAfterExit); }); - it.each(["pointer", "keyboard"])( - "starts a thread from the first-use empty state with %s input", - async (inputMethod) => { - renderPalette(); - openThreadSearch(); - await screen.findByText("No threads yet"); - expect( - screen.getByText("Start a thread to ask a question or work on a task."), - ).toBeTruthy(); - const action = screen.getByRole("option", { name: "New thread" }); - expectAttribute(action, "aria-selected", "true"); - expectAttribute(searchField(), "aria-activedescendant", action.id); - expect( - screen - .getByTestId("command-palette") - .querySelector("[data-palette-footer]"), - ).toBeNull(); - if (inputMethod === "pointer") fireEvent.click(action); - else fireEvent.keyDown(searchField(), { key: "Enter" }); - await waitFor(() => expect(screen.queryByRole("combobox")).toBeNull()); - expect(testState.calls).toEqual(["thread.new"]); - }, - ); + it("shows the shared thread-list empty state without a create action", async () => { + renderPalette(); + openThreadSearch(); + await screen.findByText("No threads"); + 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(); + expect(openThreadInSplitMock).not.toHaveBeenCalled(); + expect(openPaneContentInSplitMock).not.toHaveBeenCalled(); + }); it.each([ ["loading", "Loading threads"], @@ -708,7 +705,9 @@ describe("CommandPalette", () => { renderPalette(); openThreadSearch(); await screen.findByText(message); - expect(screen.queryByRole("option", { name: "New thread" })).toBeNull(); + expect(screen.queryByText("No threads")).toBeNull(); + expect(screen.queryByRole("option")).toBeNull(); + expect(searchField().hasAttribute("aria-activedescendant")).toBe(false); expect( screen .getByTestId("command-palette") diff --git a/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx b/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx index 67a63d0292..d7f44531c0 100644 --- a/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx +++ b/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx @@ -30,6 +30,10 @@ import { useThreadSearch, } from "@/hooks/queries/thread-queries"; import { useRouteNavigate } from "@/components/ui/app-route-anchor"; +import { + NO_THREADS_MESSAGE, + ThreadListEmptyState, +} from "@/components/thread/ThreadListEmptyState"; import { getRootComposeRoutePath, getThreadRoutePath } from "@/lib/route-paths"; import { useRootComposeProjectId } from "@/lib/root-compose-selection"; import { openPaneContentInSplit } from "@/lib/split-layout/openPaneContentInSplit"; @@ -43,10 +47,9 @@ import { 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"; -import { useAppCommandRunner } from "./AppCommandProvider"; const EMPTY_SCOPE_MESSAGES = { - all: "No threads yet", + all: NO_THREADS_MESSAGE, active: "No active threads", draft: "No drafts yet", archived: "No archived threads", @@ -63,7 +66,6 @@ export function ThreadSearchPaletteMode({ const listRef = useRef(null); const store = useStore(); const navigate = useRouteNavigate(); - const runner = useAppCommandRunner(); const isCompact = useIsCompactViewport(); const [query, setQuery] = useState(""); const [scope, setScope] = useState("all"); @@ -160,21 +162,14 @@ export function ThreadSearchPaletteMode({ const hasLoadError = result.isRecent ? navigation.isError || archivedThreads.isError : searchResultsAreCurrent && threadSearch.isError; - const showNewThread = + const showThreadListEmptyState = result.rows.length === 0 && result.isRecent && scope === "all" && !isRecentLoading && !hasLoadError; - const activeDescendantId = showNewThread - ? `${optionIdPrefix}-new-thread` - : activeIndex < 0 - ? undefined - : `${optionIdPrefix}-${activeIndex}`; - - const startNewThread = useCallback(() => { - runAfterClose(() => runner.dispatch("thread.new", null)); - }, [runAfterClose, runner]); + const activeDescendantId = + activeIndex < 0 ? undefined : `${optionIdPrefix}-${activeIndex}`; const scrollOnNextHighlightRef = useRef(false); useEffect(() => { @@ -248,18 +243,7 @@ export function ThreadSearchPaletteMode({ onExit(); return; } - if (result.rows.length === 0) { - if ( - showNewThread && - event.key === "Enter" && - !event.metaKey && - !event.ctrlKey - ) { - event.preventDefault(); - startNewThread(); - } - return; - } + if (result.rows.length === 0) return; if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); scrollOnNextHighlightRef.current = true; @@ -284,15 +268,7 @@ export function ThreadSearchPaletteMode({ openRow(row, event.metaKey || event.ctrlKey); } }, - [ - activeIndex, - onExit, - openRow, - query.length, - result.rows, - showNewThread, - startNewThread, - ], + [activeIndex, onExit, openRow, query.length, result.rows], ); const isLoading = @@ -331,11 +307,7 @@ export function ThreadSearchPaletteMode({ /> } footerKeys={activeIndex >= 0 && !isCompact ? presentation.footerKeys : []} - inputDescription={ - showNewThread - ? "Press Enter to start a new thread. Use Escape to return to commands." - : presentation.inputDescription - } + inputDescription={presentation.inputDescription} inputLabel="Search threads" inputRef={inputRef} listId={listId} @@ -366,25 +338,8 @@ export function ThreadSearchPaletteMode({ onSelect={() => openRow(row, false)} /> )) - ) : showNewThread ? ( -
-
-

No threads yet

-

- Start a thread to ask a question or work on a task. -

-
- -
+ ) : showThreadListEmptyState ? ( + ) : (

{emptyMessage} diff --git a/apps/app/src/components/sidebar/ProjectRow.tsx b/apps/app/src/components/sidebar/ProjectRow.tsx index 0534c8038a..90f81b1cb0 100644 --- a/apps/app/src/components/sidebar/ProjectRow.tsx +++ b/apps/app/src/components/sidebar/ProjectRow.tsx @@ -41,8 +41,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, @@ -468,16 +468,6 @@ 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 { @@ -488,10 +478,6 @@ function getProjectThreadTreeEmptyStateClassName( ); } -function getProjectThreadTreeEmptyStateMessageClassName(): string { - return "text-xs leading-4 text-subtle-foreground/60"; -} - function getProjectThreadTreeGroupLineClassName( variant: ProjectThreadTreeVariant, ): string | undefined { @@ -1952,16 +1938,14 @@ export const ProjectThreadTree = memo(function ProjectThreadTree({ if (rootItems.length === 0) { const emptyState = ( - ); @@ -2158,16 +2142,13 @@ export const ChronologicalSectionThreadSections = memo( const showLoosePreview = renderedSectionDnd?.dragOverParentKey === CHRONOLOGICAL_CONTAINER_ID; 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 ( + + ); +} From 30d3f2399457407a1b9adc07ab3ee8235bf239f8 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Wed, 9 Sep 2026 23:44:56 -0400 Subject: [PATCH 19/25] Show palette guidance on demand and expose the split action --- .../commands/CommandPalette.test.tsx | 235 ++++++++++++++---- .../components/commands/CommandPalette.tsx | 16 +- .../src/components/commands/PaletteShell.tsx | 19 +- .../commands/ThreadSearchPaletteMode.tsx | 99 ++++++-- apps/app/src/components/ui/tab-pill.tsx | 75 ++++-- .../src/lib/command-palette/palette-mode.ts | 4 - .../src/lib/command-palette/palette-modes.ts | 1 - 7 files changed, 343 insertions(+), 106 deletions(-) diff --git a/apps/app/src/components/commands/CommandPalette.test.tsx b/apps/app/src/components/commands/CommandPalette.test.tsx index 0ff1acb368..627bc54ae3 100644 --- a/apps/app/src/components/commands/CommandPalette.test.tsx +++ b/apps/app/src/components/commands/CommandPalette.test.tsx @@ -2,6 +2,7 @@ import { cleanup, + act, fireEvent, render, screen, @@ -9,6 +10,10 @@ import { within, } from "@testing-library/react"; import { MemoryRouter, useLocation } from "react-router-dom"; +import { createStore, Provider } from "jotai"; +import { TooltipProvider } from "@bb/shared-ui/tooltip"; +import { splitLayoutAtom } from "@/lib/split-layout/atoms"; +import { MAX_PANES, type SplitLayout } from "@/lib/split-layout"; import { afterEach, describe, expect, it, vi } from "vitest"; import { defaultAppSettings, @@ -92,6 +97,7 @@ function defaults(...commands: AppCommandId[]): AppDefaultKeybinding[] { const testState = vi.hoisted(() => ({ calls: [] as string[], filesAvailable: false, + showKeyboardHints: true, plugins: [] as Array<{ enabled: boolean; hasSettings: boolean; @@ -155,7 +161,7 @@ vi.mock("@/hooks/queries/system-queries", () => ({ data: { generalSettings: { ...defaultAppSettings, - showKeyboardHints: false, + showKeyboardHints: testState.showKeyboardHints, }, keybindings: [PALETTE_BINDING, THREAD_NEW_BINDING, THREAD_SEARCH_BINDING], defaultKeybindings: [ @@ -320,27 +326,49 @@ function makeThread( function renderPalette({ onSplit, compact = false, -}: { onSplit?: () => void; compact?: boolean } = {}) { + layout = { + root: { + type: "pane", + paneId: "origin", + content: { kind: "thread", projectId: "project-1", threadId: "origin" }, + }, + focusedPaneId: "origin", + }, +}: { + onSplit?: () => void; + compact?: boolean; + layout?: SplitLayout | null; +} = {}) { + const store = createStore(); + store.set(splitLayoutAtom, layout); const result = render( - - - - - - - - - - - - - - , + + + + + + + + + + + + + + + + + + , ); screen.getByTestId("origin").focus(); - return result; + return { ...result, store }; } function openPalette(): KeyboardEvent { @@ -377,6 +405,15 @@ const selectedOption = () => .getAllByRole("option") .find((option) => option.getAttribute("aria-selected") === "true"); +async function requestShortcutHints() { + fireEvent.keyDown(window, { key: "Control", ctrlKey: true }); + await waitFor( + () => + expect(document.querySelector("[data-palette-footer]")).not.toBeNull(), + { timeout: 1500 }, + ); +} + afterEach(() => { cleanup(); removePluginSlotRegistrations("linear"); @@ -384,6 +421,7 @@ afterEach(() => { resetPluginLogoStoreForTest(); testState.calls.length = 0; testState.filesAvailable = false; + testState.showKeyboardHints = true; testState.plugins.length = 0; modeState.activeRecents = []; modeState.archivedRecents = []; @@ -545,7 +583,9 @@ describe("CommandPalette", () => { expect(scope.textContent).toContain("All"); expectClasses(scope, "text-subtle-foreground"); expect(scope.querySelector('[data-icon="ChevronDown"]')).not.toBeNull(); - expectText(screen.getByTestId("command-palette"), "Open in split"); + expect(screen.getByRole("button", { name: "Open in split" })).toBeTruthy(); + expect(document.querySelector("[data-palette-footer]")).toBeNull(); + await requestShortcutHints(); const footer = screen .getByTestId("command-palette") .querySelector("[data-palette-footer]"); @@ -581,7 +621,8 @@ describe("CommandPalette", () => { for (const label of footer?.querySelectorAll( "[data-palette-footer-label]", ) ?? []) { - expectClasses(label, "opacity-50"); + expectClasses(label, "text-subtle-foreground"); + expectNoClasses(label, "opacity-50"); expectClasses( label.closest("[data-palette-footer]"), "text-subtle-foreground", @@ -716,12 +757,30 @@ describe("CommandPalette", () => { }, ); - it("shows only the useful split hint and removes the footer on no matches and return to Commands", async () => { + it("reveals split guidance on demand and hides it on release, menu focus, no matches, and Commands", async () => { modeState.activeRecents = [makeThread("selected")]; renderPalette(); openThreadSearch(); - await screen.findByText("Open in split"); + await screen.findByRole("option"); const palette = screen.getByTestId("command-palette"); + expect(palette.querySelector("[data-palette-footer]")).toBeNull(); + await requestShortcutHints(); + expect( + palette.querySelector("[data-palette-footer]")?.textContent, + ).toContain("Ctrl+↵"); + fireEvent.keyUp(window, { key: "Control" }); + expect(palette.querySelector("[data-palette-footer]")).toBeNull(); + await requestShortcutHints(); + const scope = screen.getByRole("button", { name: "Thread scope" }); + act(() => scope.focus()); + fireEvent.keyDown(scope, { key: "Enter" }); + await screen.findByRole("menu", { name: "Thread scope" }); + expect(palette.querySelector("[data-palette-footer]")).toBeNull(); + fireEvent.keyDown(screen.getByRole("menuitemradio", { name: "All" }), { + key: "Escape", + }); + await waitFor(() => expect(document.activeElement).toBe(searchField())); + await requestShortcutHints(); expect( palette.querySelector("[data-palette-footer]")?.textContent, ).not.toContain("Esc"); @@ -732,7 +791,7 @@ describe("CommandPalette", () => { fireEvent.keyDown(searchField(), { key: "Enter", ctrlKey: true }); expect(openThreadInSplitMock).not.toHaveBeenCalled(); fireEvent.change(searchField(), { target: { value: "" } }); - await screen.findByText("Open in split"); + await screen.findByRole("option"); fireEvent.keyDown(searchField(), { key: "Escape" }); await screen.findByRole("combobox", { name: "Search commands" }); expect(palette.querySelector("[data-palette-footer]")).toBeNull(); @@ -744,6 +803,7 @@ describe("CommandPalette", () => { renderPalette({ compact: true }); openThreadSearch(); await screen.findByRole("combobox", { name: "Search threads" }); + expect(screen.queryByRole("button", { name: "Open in split" })).toBeNull(); expect( screen .getByTestId("command-palette") @@ -758,6 +818,99 @@ describe("CommandPalette", () => { ).toBeNull(); }); + it("keeps the split action discoverable with keyboard hints disabled", async () => { + testState.showKeyboardHints = false; + modeState.activeRecents = [ + makeThread("first"), + makeThread("second", { updatedAt: 1 }), + ]; + renderPalette(); + openThreadSearch(); + await screen.findByRole("option", { name: /Title first/ }); + fireEvent.keyDown(window, { key: "Control", ctrlKey: true }); + await act(() => new Promise((resolve) => setTimeout(resolve, 800))); + expect(document.querySelector("[data-palette-footer]")).toBeNull(); + fireEvent.keyUp(window, { key: "Control" }); + fireEvent.keyDown(searchField(), { key: "ArrowDown" }); + const action = screen.getByRole("button", { name: "Open in split" }); + expectAttribute(action, "aria-disabled", "false"); + expect(action.closest('[role="listbox"]')).toBeNull(); + fireEvent.click(action); + await waitFor(() => + expect(openThreadInSplitMock).toHaveBeenCalledWith( + expect.objectContaining({ threadId: "second" }), + ), + ); + expect(openThreadInSplitMock).toHaveBeenCalledTimes(1); + expect(routeNavigateMock).not.toHaveBeenCalled(); + }); + + it.each(["missing workspace", "already open", "pane limit"])( + "removes split guidance and disables the action for %s", + async (state) => { + modeState.activeRecents = [makeThread("selected")]; + const { store } = renderPalette(); + openThreadSearch(); + await screen.findByRole("option"); + await requestShortcutHints(); + const layout: SplitLayout = { + root: { + type: "split", + dir: "row", + sizes: Array(MAX_PANES).fill(1 / MAX_PANES), + children: Array.from({ length: MAX_PANES }, (_, index) => ({ + type: "pane", + paneId: `pane-${index}`, + content: { + kind: "thread", + projectId: "project-1", + threadId: + state === "already open" && index === 0 + ? "selected" + : `other-${index}`, + }, + })), + }, + focusedPaneId: "pane-0", + }; + act(() => + store.set( + splitLayoutAtom, + state === "missing workspace" ? null : layout, + ), + ); + expect(document.querySelector("[data-palette-footer]")).toBeNull(); + const action = screen.getByRole("button", { name: "Open in split" }); + expectAttribute(action, "aria-disabled", "true"); + fireEvent.click(action); + expect(openThreadInSplitMock).not.toHaveBeenCalled(); + expect(screen.getByRole("combobox")).toBeTruthy(); + act(() => action.focus()); + const tooltip = await screen.findByRole("tooltip"); + expectText( + tooltip, + state === "missing workspace" + ? "Open a thread first" + : state === "already open" + ? "Already open" + : "Close a split pane first", + ); + }, + ); + + 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(); @@ -1060,7 +1213,7 @@ describe("CommandPalette", () => { expectText( within(results) .getByRole("option") - .querySelector("[data-palette-thread-metadata]")?.firstElementChild, + .querySelector("[data-palette-thread-metadata]"), "Palette project", ); @@ -1124,27 +1277,21 @@ describe("CommandPalette", () => { ); } expect( - rows[1]?.querySelector("[data-palette-thread-metadata]") - ?.firstElementChild?.textContent, - ).toBe("Palette project"); + rows[1]?.querySelector("[data-palette-thread-metadata]")?.textContent, + ).toBe("Draft · Palette project"); expect( - rows[2]?.querySelector("[data-palette-thread-metadata]") - ?.firstElementChild?.textContent, - ).toBe("Palette project · just now"); + rows[2]?.querySelector("[data-palette-thread-metadata]")?.textContent, + ).toBe("Archived · Palette project · just now"); const draftMetadata = rows[1]?.querySelector( "[data-palette-thread-metadata]", ); const archivedMetadata = rows[2]?.querySelector( "[data-palette-thread-metadata]", ); - const draftState = draftMetadata?.lastElementChild; - const archivedState = archivedMetadata?.lastElementChild; - expectText(draftState, "Draft"); - expectText(archivedState, "Archived"); - expectClasses(draftState, "shrink-0"); - expectClasses(archivedState, "shrink-0"); - expectClasses(draftMetadata, "flex", "items-baseline"); - expectClasses(archivedMetadata, "flex", "items-baseline"); + expectClasses(draftMetadata, "block", "truncate"); + expectClasses(archivedMetadata, "block", "truncate"); + expect(draftMetadata?.childElementCount).toBe(0); + expect(archivedMetadata?.childElementCount).toBe(0); expect(within(results).queryAllByRole("group")).toHaveLength(0); expect(within(results).queryByText("Recent")).toBeNull(); }); @@ -1221,13 +1368,11 @@ describe("CommandPalette", () => { ); } expect( - rows[1]?.querySelector("[data-palette-thread-metadata]") - ?.firstElementChild?.textContent, - ).toBe("Palette project"); + rows[1]?.querySelector("[data-palette-thread-metadata]")?.textContent, + ).toBe("Draft · Palette project"); expect( - rows[2]?.querySelector("[data-palette-thread-metadata]") - ?.firstElementChild?.textContent, - ).toBe("Palette project · just now"); + rows[2]?.querySelector("[data-palette-thread-metadata]")?.textContent, + ).toBe("Archived · Palette project · just now"); expect(within(results).queryAllByRole("group")).toHaveLength(0); expect(within(results).queryByText("Recent")).toBeNull(); expect(results.textContent).not.toContain("1/1"); diff --git a/apps/app/src/components/commands/CommandPalette.tsx b/apps/app/src/components/commands/CommandPalette.tsx index 34023d785c..964fc00d46 100644 --- a/apps/app/src/components/commands/CommandPalette.tsx +++ b/apps/app/src/components/commands/CommandPalette.tsx @@ -329,6 +329,11 @@ export function CommandPalette({ activeModeId === null ? undefined : PALETTE_MODES.find((mode) => mode.id === activeModeId); + const exitMode = () => { + setActiveModeId(null); + setQuery(""); + setHighlightedIndex(0); + }; return (

@@ -338,7 +343,10 @@ export function CommandPalette({ className="top-[12%] max-w-[640px] translate-y-0 gap-0 p-0 shadow-lg sm:rounded-xl" onCloseAutoFocus={handleCloseAutoFocus} onEscapeKeyDown={(event) => { - if (activeMode !== undefined) event.preventDefault(); + if (activeMode !== undefined) { + event.preventDefault(); + exitMode(); + } }} data-testid="command-palette" > @@ -426,11 +434,7 @@ export function CommandPalette({ ) : ( { - setActiveModeId(null); - setQuery(""); - setHighlightedIndex(0); - }} + onExit={exitMode} runAfterClose={runAfterClose} /> )} diff --git a/apps/app/src/components/commands/PaletteShell.tsx b/apps/app/src/components/commands/PaletteShell.tsx index c465abd1e6..063826aebe 100644 --- a/apps/app/src/components/commands/PaletteShell.tsx +++ b/apps/app/src/components/commands/PaletteShell.tsx @@ -1,5 +1,6 @@ import { useId, + useState, type KeyboardEventHandler, type ReactNode, type Ref, @@ -10,6 +11,7 @@ import { cn } from "@bb/shared-ui/lib/utils"; import { useScrollOverflowState } from "@/components/thread/timeline/useScrollOverflowState"; import { TabPill } from "@/components/ui/tab-pill"; import { APP_COMMAND_ACCESSORY_PILL_CLASS } from "./AppCommandShortcutHint"; +import { useIsAppCommandModifierHeld } from "./AppCommandProvider"; export const PALETTE_FOOTER_KEYCAP_CLASS = cn( APP_COMMAND_ACCESSORY_PILL_CLASS, @@ -60,6 +62,9 @@ export function PaletteShell({ value, }: PaletteShellProps) { const inputDescriptionId = useId(); + const [inputFocused, setInputFocused] = useState(false); + const modifierHeld = useIsAppCommandModifierHeld(); + const showFooter = inputFocused && modifierHeld && footerKeys.length > 0; const overflow = useScrollOverflowState({ measureOverflow: true, }); @@ -99,6 +104,8 @@ export function PaletteShell({ placeholder={placeholder} value={value} onChange={(event) => onInputChange(event.target.value)} + onFocus={() => setInputFocused(true)} + onBlur={() => setInputFocused(false)} onKeyDown={onInputKeyDown} /> @@ -110,7 +117,7 @@ export function PaletteShell({
@@ -141,7 +148,7 @@ export function PaletteShell({ />
- {footerKeys.length === 0 ? null : ( + {!showFooter ? null : (
{hint.label} @@ -193,7 +200,11 @@ function PaletteModeChip({ isActive onSelect={() => undefined} leadingVisual={} - closeAction={{ onClose: onClear, closeLabel: clearLabel }} + 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 index d7f44531c0..aafadb7cd7 100644 --- a/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx +++ b/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx @@ -9,7 +9,7 @@ import { type KeyboardEvent as ReactKeyboardEvent, type ReactNode, } from "react"; -import { useStore } from "jotai"; +import { useAtomValue, useStore } from "jotai"; import { Button } from "@bb/shared-ui/button"; import { DropdownMenu, @@ -18,6 +18,7 @@ import { DropdownMenuTrigger, } from "@bb/shared-ui/dropdown-menu"; import { Icon } from "@bb/shared-ui/icon"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@bb/shared-ui/tooltip"; import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport"; import { cn } from "@bb/shared-ui/lib/utils"; import { isPromptDraftEmpty } from "@bb/client-core"; @@ -38,6 +39,9 @@ import { getRootComposeRoutePath, getThreadRoutePath } from "@/lib/route-paths"; import { useRootComposeProjectId } from "@/lib/root-compose-selection"; import { openPaneContentInSplit } from "@/lib/split-layout/openPaneContentInSplit"; import { openThreadInSplit } from "@/lib/split-layout/openThreadInSplit"; +import { splitLayoutAtom } from "@/lib/split-layout/atoms"; +import { countPanes, findPaneByContent, MAX_PANES } from "@/lib/split-layout"; +import { isMacKeyboardPlatform } from "@bb/domain"; import { buildPaletteThreadSearchRows, PALETTE_THREAD_SEARCH_SCOPES, @@ -65,6 +69,7 @@ export function ThreadSearchPaletteMode({ const inputRef = useRef(null); const listRef = useRef(null); const store = useStore(); + const splitLayout = useAtomValue(splitLayoutAtom); const navigate = useRouteNavigate(); const isCompact = useIsCompactViewport(); const [query, setQuery] = useState(""); @@ -170,6 +175,30 @@ export function ThreadSearchPaletteMode({ !hasLoadError; const activeDescendantId = activeIndex < 0 ? undefined : `${optionIdPrefix}-${activeIndex}`; + const activeRow = result.rows[activeIndex]; + const splitDisabledReason = + splitLayout === null + ? "Open a thread first to use split view" + : activeRow !== undefined && + findPaneByContent( + splitLayout.root, + activeRow.threadId === null + ? { kind: "new-thread" } + : { + kind: "thread", + projectId: activeRow.projectId, + threadId: activeRow.threadId, + }, + ) !== null + ? "Already open in this workspace" + : countPanes(splitLayout.root) >= MAX_PANES + ? "Close a split pane first" + : null; + const canSplit = + activeRow !== undefined && !isCompact && splitDisabledReason === null; + const splitShortcut = isMacKeyboardPlatform(navigator.platform) + ? "⌘↵" + : "Ctrl+↵"; const scrollOnNextHighlightRef = useRef(false); useEffect(() => { @@ -296,18 +325,51 @@ export function ThreadSearchPaletteMode({ { - setScope(nextScope); - setHighlightedIndex(0); - if (listRef.current !== null) listRef.current.scrollTop = 0; - }} - /> + <> + { + setScope(nextScope); + setHighlightedIndex(0); + if (listRef.current !== null) listRef.current.scrollTop = 0; + }} + /> + {activeRow === undefined || isCompact ? null : ( + + + + + + {splitDisabledReason ?? `Open in split (${splitShortcut})`} + + + )} + + } + footerKeys={ + canSplit ? [{ keys: [splitShortcut], label: "Open in split" }] : [] + } + inputDescription={ + canSplit + ? presentation.inputDescription + : "Use Escape to return to commands." } - footerKeys={activeIndex >= 0 && !isCompact ? presentation.footerKeys : []} - inputDescription={presentation.inputDescription} inputLabel="Search threads" inputRef={inputRef} listId={listId} @@ -452,6 +514,7 @@ function ThreadSearchPaletteRow({ : row.lifecycle === "draft" ? "Draft" : "Archived"; + const metadata = [stateLabel, row.metadataText].filter(Boolean).join(" · "); return (
- {row.metadataText.length === 0 && stateLabel === null ? null : ( + {metadata.length === 0 ? null : ( - - {row.metadataText} - - {stateLabel === null ? null : ( - {stateLabel} - )} + {metadata} )} diff --git a/apps/app/src/components/ui/tab-pill.tsx b/apps/app/src/components/ui/tab-pill.tsx index 7bdc0c4a94..8000a80579 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 = @@ -19,6 +20,7 @@ interface TabPillCloseAction { onClose: () => void; closeLabel: string; isClosing?: boolean; + tooltip?: string; } interface TabPillProps { @@ -86,9 +88,7 @@ export function TabPill({ className={cn( "flex h-full min-w-0 items-center rounded-md focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", iconOnly ? "px-1.5" : "pl-1.5 pr-2", - !iconOnly && - closeAction !== null && - enlargeCloseTargetOnCoarsePointer + !iconOnly && closeAction !== null && enlargeCloseTargetOnCoarsePointer ? "max-md:pointer-coarse:pl-3.5" : null, )} @@ -123,30 +123,53 @@ export function TabPill({ ) : null} {closeAction ? ( - + ) : 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-mode.ts b/apps/app/src/lib/command-palette/palette-mode.ts index d4b9973b9b..8e34d63c46 100644 --- a/apps/app/src/lib/command-palette/palette-mode.ts +++ b/apps/app/src/lib/command-palette/palette-mode.ts @@ -7,10 +7,6 @@ export interface PaletteModePresentation { icon: IconName; label: string; }; - footerKeys: readonly { - keys: readonly string[]; - label: string; - }[]; inputDescription: 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 1538417e71..72896cf1a7 100644 --- a/apps/app/src/lib/command-palette/palette-modes.ts +++ b/apps/app/src/lib/command-palette/palette-modes.ts @@ -7,7 +7,6 @@ export const PALETTE_MODES: readonly PaletteModeRegistration[] = [ entryCommand: "thread.search", chip: { icon: "Search", label: "Threads" }, placeholder: "Search title, project, or message…", - footerKeys: [{ keys: ["⌘↵"], label: "Open in split" }], inputDescription: "Use Command-Enter or Control-Enter to open the selected thread in a split. Use Escape to return to commands.", View: ThreadSearchPaletteMode, From 6cf627853c65e353e2af6eb356f96d1a4314a89d Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Wed, 9 Sep 2026 23:50:57 -0400 Subject: [PATCH 20/25] Handle Escape from palette action tooltips and account for their shell cost --- apps/app/bundle-budget.json | 2 +- .../commands/CommandPalette.test.tsx | 8 +++++- .../src/components/commands/PaletteShell.tsx | 25 +++++++++---------- .../commands/ThreadSearchPaletteMode.tsx | 6 +++++ 4 files changed, 26 insertions(+), 15 deletions(-) diff --git a/apps/app/bundle-budget.json b/apps/app/bundle-budget.json index 0790f42af2..99b90cca68 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": 1725665, + "maxBootBytes": 1726689, "maxBootBrotliBytes": 429072, "forbiddenBootPackages": [ "@pierre/diffs", diff --git a/apps/app/src/components/commands/CommandPalette.test.tsx b/apps/app/src/components/commands/CommandPalette.test.tsx index 627bc54ae3..d49c34494e 100644 --- a/apps/app/src/components/commands/CommandPalette.test.tsx +++ b/apps/app/src/components/commands/CommandPalette.test.tsx @@ -1149,7 +1149,13 @@ describe("CommandPalette", () => { 'input:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])', ), ), - ).toEqual([modeSelect, clearMode, input, scope]); + ).toEqual([ + modeSelect, + clearMode, + input, + scope, + screen.getByRole("button", { name: "Open in split" }), + ]); fireEvent.change(input, { target: { value: "match" } }); const results = screen.getByRole("listbox", { name: "Threads" }); diff --git a/apps/app/src/components/commands/PaletteShell.tsx b/apps/app/src/components/commands/PaletteShell.tsx index 063826aebe..2c7220155e 100644 --- a/apps/app/src/components/commands/PaletteShell.tsx +++ b/apps/app/src/components/commands/PaletteShell.tsx @@ -159,18 +159,9 @@ export function PaletteShell({ key={`${hint.keys.join(":")}:${hint.label}`} className="inline-flex items-center gap-1.5" > - - {hint.keys.map((keys, index) => ( - - {index === 0 ? null : ( - - / - - )} - {keys} - - ))} - + + {hint.keys.join(" / ")} + + { + if (event.key !== "Escape") return; + event.preventDefault(); + event.stopPropagation(); + onClear(); + }} + > { if (canSplit) openRow(activeRow, true); }} + onKeyDown={(event) => { + if (event.key !== "Escape") return; + event.preventDefault(); + event.stopPropagation(); + onExit(); + }} > From 8edd64c9fd20baaacb797c717241a421dd4659f9 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Wed, 9 Sep 2026 23:54:18 -0400 Subject: [PATCH 21/25] Own palette tooltip context in the production shell --- .../commands/CommandPalette.test.tsx | 43 +++++++++---------- .../src/components/commands/PaletteShell.tsx | 5 ++- 2 files changed, 23 insertions(+), 25 deletions(-) diff --git a/apps/app/src/components/commands/CommandPalette.test.tsx b/apps/app/src/components/commands/CommandPalette.test.tsx index d49c34494e..3340c38d05 100644 --- a/apps/app/src/components/commands/CommandPalette.test.tsx +++ b/apps/app/src/components/commands/CommandPalette.test.tsx @@ -11,7 +11,6 @@ import { } from "@testing-library/react"; import { MemoryRouter, useLocation } from "react-router-dom"; import { createStore, Provider } from "jotai"; -import { TooltipProvider } from "@bb/shared-ui/tooltip"; import { splitLayoutAtom } from "@/lib/split-layout/atoms"; import { MAX_PANES, type SplitLayout } from "@/lib/split-layout"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -343,28 +342,26 @@ function renderPalette({ store.set(splitLayoutAtom, layout); const result = render( - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + , ); screen.getByTestId("origin").focus(); diff --git a/apps/app/src/components/commands/PaletteShell.tsx b/apps/app/src/components/commands/PaletteShell.tsx index 2c7220155e..fec9b51660 100644 --- a/apps/app/src/components/commands/PaletteShell.tsx +++ b/apps/app/src/components/commands/PaletteShell.tsx @@ -7,6 +7,7 @@ import { } 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 { cn } from "@bb/shared-ui/lib/utils"; import { useScrollOverflowState } from "@/components/thread/timeline/useScrollOverflowState"; import { TabPill } from "@/components/ui/tab-pill"; @@ -79,7 +80,7 @@ export function PaletteShell({ : undefined; return ( - <> +
)} - + ); } From 5677d47875d9134f07854fa124ccc266b35bd088 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 11 Sep 2026 11:59:43 -0400 Subject: [PATCH 22/25] Tighten palette spacing and clarify result metadata --- .../commands/CommandPalette.test.tsx | 226 ++++++++++++++---- .../components/commands/CommandPalette.tsx | 13 +- .../src/components/commands/PaletteShell.tsx | 11 +- .../commands/ThreadSearchPaletteMode.tsx | 101 +++++++- .../palette-thread-search.test.ts | 5 + .../command-palette/palette-thread-search.ts | 3 + 6 files changed, 287 insertions(+), 72 deletions(-) diff --git a/apps/app/src/components/commands/CommandPalette.test.tsx b/apps/app/src/components/commands/CommandPalette.test.tsx index 3340c38d05..1f9fb4aecf 100644 --- a/apps/app/src/components/commands/CommandPalette.test.tsx +++ b/apps/app/src/components/commands/CommandPalette.test.tsx @@ -109,6 +109,7 @@ const modeState = vi.hoisted(() => ({ activeRecents: [] as ThreadListEntry[], archivedRecents: [] as ThreadListEntry[], drafts: [] as PaletteNewThreadDraft[], + threadDraftIds: new Set(), searchResponse: undefined as ThreadSearchResponse | undefined, recentLoading: false, recentError: false, @@ -171,6 +172,8 @@ vi.mock("@/hooks/queries/system-queries", () => ({ "thread.next", "panel.toggle", "terminal.open", + "composer.focus", + "browser.reload", ), ], }, @@ -209,6 +212,9 @@ vi.mock("@/components/ui/app-route-anchor", () => ({ })); vi.mock("@/hooks/usePromptDraftStorage", () => ({ + usePromptDraftHasInput: (scope: { kind: string; threadId?: string }) => + scope.kind === "thread" && + modeState.threadDraftIds.has(scope.threadId ?? ""), usePromptDraftStorage: () => { const draft = modeState.drafts[0]?.draft ?? emptyPromptDraftState(); return { @@ -353,6 +359,8 @@ function renderPalette({ + + { modeState.activeRecents = []; modeState.archivedRecents = []; modeState.drafts = []; + modeState.threadDraftIds.clear(); modeState.searchResponse = undefined; modeState.recentLoading = false; modeState.recentError = false; @@ -470,10 +479,10 @@ describe("CommandPalette", () => { for (const className of CHROME_SECTION_LABEL_CLASS.split(" ")) { expect(header.classList.contains(className)).toBe(true); } - expectClasses(header, "px-3", "pb-1", "pt-3"); + expectClasses(header, "px-2", "py-1"); expectNoClasses(header, "bg-muted/30"); } - expectClasses(commandList(), "p-2"); + expectClasses(commandList(), "p-1"); expectClasses(commandList().parentElement, "overflow-hidden"); expectClasses( screen.getByTestId("command-palette"), @@ -481,24 +490,21 @@ describe("CommandPalette", () => { "shadow-lg", "sm:rounded-xl", ); - expectClasses( - searchField().closest("[data-palette-input-frame]"), - "h-10", - "px-3", - ); + 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-2", + "py-1", ); expectClasses( searchField(), @@ -541,10 +547,12 @@ describe("CommandPalette", () => { 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"); + 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-3")).toBe(true); + expect(row.classList.contains("px-2")).toBe(true); } expect(commandList().querySelector("[data-icon]")).toBeNull(); expectClasses(threadRows[0], "bg-state-hover", "text-foreground"); @@ -591,7 +599,7 @@ describe("CommandPalette", () => { "flex-wrap", "bg-surface-recessed-soft-solid", "border-border/40", - "px-4", + "px-3", "py-2", ); expectAttribute(footer, "aria-hidden", "true"); @@ -711,26 +719,38 @@ describe("CommandPalette", () => { expect(optionTitles()).toEqual(commandsAfterExit); }); - it("shows the shared thread-list empty state without a create action", async () => { - renderPalette(); - openThreadSearch(); - await screen.findByText("No threads"); - 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(); - expect(openThreadInSplitMock).not.toHaveBeenCalled(); - expect(openPaneContentInSplitMock).not.toHaveBeenCalled(); - }); + 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(); + expect(openThreadInSplitMock).not.toHaveBeenCalled(); + expect(openPaneContentInSplitMock).not.toHaveBeenCalled(); + }, + ); it.each([ ["loading", "Loading threads"], @@ -783,6 +803,11 @@ describe("CommandPalette", () => { ).not.toContain("Esc"); fireEvent.change(searchField(), { target: { value: "no match" } }); await screen.findByText("No matching threads"); + expectClasses( + screen.getByText("No matching threads").parentElement, + "px-3", + "py-4", + ); expect(palette.querySelector("[data-palette-footer]")).toBeNull(); expect(searchField().hasAttribute("aria-activedescendant")).toBe(false); fireEvent.keyDown(searchField(), { key: "Enter", ctrlKey: true }); @@ -1268,10 +1293,18 @@ describe("CommandPalette", () => { expect(rows[0]?.textContent).toContain("Title recent-active"); expect(rows[0]?.textContent).not.toContain("Active"); expect(rows[1]?.textContent).toContain("recent draft"); - expect(rows[1]?.textContent).toContain("Draft"); + expect( + within(rows[1]).getByRole("img", { + name: "Thread has unsubmitted draft", + }), + ).toBeTruthy(); expect(rows[2]?.textContent).toContain("Title recent-archived"); - expect(rows[2]?.textContent).toContain("Archived"); - expect(results.querySelector("[data-icon]")).toBeNull(); + expect( + within(rows[2]).getByRole("img", { name: "Archived thread" }), + ).toBeTruthy(); + expect(rows[1].querySelector('[data-icon="Edit"]')).not.toBeNull(); + expect(rows[2].querySelector('[data-icon="Archive"]')).not.toBeNull(); + expect(results.querySelector('[data-icon="Folder"]')).toBeNull(); for (const row of rows) { expectClasses( row.querySelector("[data-palette-thread-metadata]"), @@ -1281,10 +1314,10 @@ describe("CommandPalette", () => { } expect( rows[1]?.querySelector("[data-palette-thread-metadata]")?.textContent, - ).toBe("Draft · Palette project"); + ).toBe("Palette project"); expect( rows[2]?.querySelector("[data-palette-thread-metadata]")?.textContent, - ).toBe("Archived · Palette project · just now"); + ).toBe("Palette project · just now"); const draftMetadata = rows[1]?.querySelector( "[data-palette-thread-metadata]", ); @@ -1296,7 +1329,10 @@ describe("CommandPalette", () => { expect(draftMetadata?.childElementCount).toBe(0); expect(archivedMetadata?.childElementCount).toBe(0); expect(within(results).queryAllByRole("group")).toHaveLength(0); - expect(within(results).queryByText("Recent")).toBeNull(); + expect(within(results).getByText("Recent")).toBeTruthy(); + expectClasses(results, "p-1"); + expectClasses(within(results).getByText("Recent"), "px-2", "py-1"); + for (const row of rows) expectClasses(row, "px-2", "py-1.5", "min-h-11"); }); it("renders search matches as one unlabelled active, draft, archived list", async () => { @@ -1351,9 +1387,15 @@ describe("CommandPalette", () => { const rows = within(results).getAllByRole("option"); expect(rows[0]?.textContent).toContain("Matching active thread"); expect(rows[1]?.textContent).toContain("matching draft"); - expect(rows[1]?.textContent).toContain("Draft"); + expect( + within(rows[1]).getByRole("img", { + name: "Thread has unsubmitted draft", + }), + ).toBeTruthy(); expect(rows[2]?.textContent).toContain("Title archived"); - expect(rows[2]?.textContent).toContain("Archived"); + expect( + within(rows[2]).getByRole("img", { name: "Archived thread" }), + ).toBeTruthy(); expect(rows[0]?.textContent).not.toContain("Active"); const activeMatch = rows[0]?.querySelector("mark"); expectText(activeMatch, "Matching"); @@ -1372,16 +1414,75 @@ describe("CommandPalette", () => { } expect( rows[1]?.querySelector("[data-palette-thread-metadata]")?.textContent, - ).toBe("Draft · Palette project"); + ).toBe("Palette project"); expect( rows[2]?.querySelector("[data-palette-thread-metadata]")?.textContent, - ).toBe("Archived · Palette project · just now"); + ).toBe("Palette project · just now"); expect(within(results).queryAllByRole("group")).toHaveLength(0); expect(within(results).queryByText("Recent")).toBeNull(); expect(results.textContent).not.toContain("1/1"); - expect(results.querySelector("svg")).toBeNull(); + expect( + results.querySelectorAll("[data-palette-thread-status]"), + ).toHaveLength(3); }); + 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 a persisted thread result in a split with Command-Enter", async () => { modeState.searchResponse = { active: { @@ -1484,10 +1585,33 @@ describe("CommandPalette", () => { await waitFor(() => expect(optionTitles()).toHaveLength(1)); expect(selectedOption()?.textContent).toContain("Open terminal"); - expect(selectedOption()?.textContent).toContain("Workspace"); + 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 () => { renderPalette(); openPalette(); @@ -1537,7 +1661,7 @@ describe("CommandPalette", () => { const splitRow = within(bucketGroup("Actions")) .getAllByRole("option") .find((row) => row.textContent?.includes("Split")); - expect(splitRow?.textContent).toContain("Window and layout"); + expect(splitRow?.textContent).not.toContain("Window and layout"); fireEvent.change(searchField(), { target: { value: "split" } }); await waitFor(() => @@ -1682,12 +1806,19 @@ describe("CommandPalette", () => { 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" }, }); await waitFor(() => expect(selectedOption()?.textContent).toContain("Linear settings"), ); + expect(selectedOption()?.textContent).not.toContain("Plugin settings"); fireEvent.keyDown(searchField(), { key: "Enter" }); await waitFor(() => @@ -1719,10 +1850,14 @@ describe("CommandPalette", () => { openPalette(); await waitFor(() => expect(searchField()).toBeTruthy()); + 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(() => @@ -1790,6 +1925,7 @@ describe("CommandPalette", () => { await waitFor(() => expect(screen.getByText("No matching commands")).toBeTruthy(), ); + 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 964fc00d46..52b5fdd0a1 100644 --- a/apps/app/src/components/commands/CommandPalette.tsx +++ b/apps/app/src/components/commands/CommandPalette.tsx @@ -374,7 +374,7 @@ export function CommandPalette({ value={query} > {!isGroupedRoot && visibleEntries.length === 0 ? ( -

+

No matching commands

) : isGroupedRoot ? ( @@ -389,10 +389,7 @@ export function CommandPalette({ >
{group.bucket}
@@ -459,7 +456,9 @@ function PaletteRow({ onSelect: () => void; }) { const metadataGroup = - entry.action.group === entry.action.bucket ? null : entry.action.group; + 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; @@ -470,7 +469,7 @@ function PaletteRow({ aria-selected={isActive} data-palette-action-kind={isDrillIn ? "drill-in" : "terminal"} className={cn( - "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", + "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", isActive && "bg-state-hover text-foreground", )} onPointerMove={onActivate} diff --git a/apps/app/src/components/commands/PaletteShell.tsx b/apps/app/src/components/commands/PaletteShell.tsx index fec9b51660..170a643bb3 100644 --- a/apps/app/src/components/commands/PaletteShell.tsx +++ b/apps/app/src/components/commands/PaletteShell.tsx @@ -82,13 +82,10 @@ export function PaletteShell({ return (
-
+
{modeChip === undefined ? null : } {footerKeys.map((hint) => ( diff --git a/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx b/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx index 88924a3690..34a59ec212 100644 --- a/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx +++ b/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx @@ -21,9 +21,26 @@ import { Icon } from "@bb/shared-ui/icon"; import { Tooltip, TooltipContent, TooltipTrigger } from "@bb/shared-ui/tooltip"; import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport"; import { cn } from "@bb/shared-ui/lib/utils"; -import { isPromptDraftEmpty } from "@bb/client-core"; +import { CHROME_SECTION_LABEL_CLASS } from "@bb/shared-ui/chrome-style-tokens"; +import { + getThreadListIndicatorLabel, + hasActiveBackgroundAgentActivity, + hasActiveBackgroundCommandActivity, + hasActiveGoalActivity, + hasActivePlanModeActivity, + hasActiveWorkflowActivity, + isPromptDraftEmpty, + isRuntimeBusyThread, + isUnreadDoneThread, + resolveThreadListIndicator, + type ThreadListIndicatorState, +} from "@bb/client-core"; import type { ThreadSearchHighlightRange } from "@bb/server-contract"; -import { usePromptDraftStorage } from "@/hooks/usePromptDraftStorage"; +import { + usePromptDraftHasInput, + usePromptDraftStorage, +} from "@/hooks/usePromptDraftStorage"; +import { ThreadStatusGlyph } from "@/components/sidebar/ThreadRow"; import { useSidebarNavigation } from "@/hooks/queries/sidebar-navigation-query"; import { hasThreadSearchableQuery, @@ -395,6 +412,11 @@ export function ThreadSearchPaletteMode({ placeholder={presentation.placeholder} value={query} > + {result.isRecent && result.rows.length > 0 ? ( +
+ Recent +
+ ) : null} {emptyMessage === null ? ( result.rows.map((row, index) => ( openRow(row, false)} /> )) - ) : showThreadListEmptyState ? ( - + ) : showThreadListEmptyState || + (searchable && !isLoading && !hasLoadError) ? ( + ) : ( -

+

{emptyMessage}

)} @@ -514,20 +540,14 @@ function ThreadSearchPaletteRow({ } }, [matchKey, row.highlightRanges.length, shouldWindowMatch]); - const stateLabel = - row.lifecycle === "active" - ? null - : row.lifecycle === "draft" - ? "Draft" - : "Archived"; - const metadata = [stateLabel, row.metadataText].filter(Boolean).join(" · "); + const metadata = row.metadataText; return (
)} +
); } +function ThreadSearchPaletteStatus({ row }: { row: PaletteThreadSearchRow }) { + const hasUnsubmittedDraft = usePromptDraftHasInput( + row.threadId === null + ? { kind: "new-thread" } + : { kind: "thread", projectId: row.projectId, threadId: row.threadId }, + ); + const thread = row.thread; + const unread = thread !== null && isUnreadDoneThread(thread); + const state: ThreadListIndicatorState = { + hasPendingInteraction: thread?.hasPendingInteraction ?? false, + hasUnsubmittedDraft: row.lifecycle === "draft" || hasUnsubmittedDraft, + hasUnreadError: unread && thread?.status === "error", + hasUnreadSuccess: unread && thread?.status !== "error", + isBackgroundAgentActive: + thread !== null && hasActiveBackgroundAgentActivity(thread), + isBackgroundCommandActive: + thread !== null && hasActiveBackgroundCommandActivity(thread), + isGoalActive: thread !== null && hasActiveGoalActivity(thread), + isPlanModeActive: thread !== null && hasActivePlanModeActivity(thread), + isRuntimeActive: thread !== null && isRuntimeBusyThread(thread), + isWorkflowActive: thread !== null && hasActiveWorkflowActivity(thread), + queuedWork: thread?.queuedWork ?? "none", + }; + const kind = resolveThreadListIndicator(state); + const archived = row.lifecycle === "archived"; + const label = archived + ? "Archived thread" + : (getThreadListIndicatorLabel(kind) ?? "Active thread"); + return ( + + + + + + + {label} + + ); +} + function HighlightedText({ ranges, text, 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 index 8fdc20cedc..b1f3e812d8 100644 --- a/apps/app/src/lib/command-palette/palette-thread-search.test.ts +++ b/apps/app/src/lib/command-palette/palette-thread-search.test.ts @@ -111,6 +111,11 @@ describe("buildPaletteThreadSearchRows", () => { "draft", "archived", ]); + expect(result.rows.map((row) => row.thread)).toEqual([ + active, + null, + archived, + ]); expect(result.rows.map((row) => row.metadataText)).toEqual([ "Palette project · just now", "Palette project · just now", diff --git a/apps/app/src/lib/command-palette/palette-thread-search.ts b/apps/app/src/lib/command-palette/palette-thread-search.ts index 516959273f..bab2883790 100644 --- a/apps/app/src/lib/command-palette/palette-thread-search.ts +++ b/apps/app/src/lib/command-palette/palette-thread-search.ts @@ -40,6 +40,7 @@ export interface PaletteThreadSearchRow { updatedAt: number | null; projectId: string; threadId: string | null; + thread: ThreadListEntry | null; draftSlotId: string | null; messageSeq: number | null; } @@ -114,6 +115,7 @@ function serverRow( projectId: thread.projectId, updatedAt: thread.updatedAt, threadId: thread.id, + thread, draftSlotId: null, messageSeq: snippetMatch?.sourceSeq ?? null, }; @@ -203,6 +205,7 @@ export function buildPaletteThreadSearchRows({ projectId: item.destination.projectId, updatedAt: item.lastEditedAt, threadId: null, + thread: null, draftSlotId: item.id, messageSeq: null, })); From 46f8ecc2c64fc9e9c1f690aa9ae2153ba570a00b Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 11 Sep 2026 12:15:29 -0400 Subject: [PATCH 23/25] Align palette guidance tests and source anchors with the trimmed UI --- .../commands/CommandPalette.test.tsx | 18 +++++------------- .../plugin-api-map/src/anatomy-manifest.json | 2 +- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/apps/app/src/components/commands/CommandPalette.test.tsx b/apps/app/src/components/commands/CommandPalette.test.tsx index 0ed349945e..894912ebd1 100644 --- a/apps/app/src/components/commands/CommandPalette.test.tsx +++ b/apps/app/src/components/commands/CommandPalette.test.tsx @@ -555,10 +555,7 @@ describe("CommandPalette", () => { screen.getByRole("button", { name: "Return to commands" }), "data-tab-pill-close", ); - const scope = screen.getByRole("button", { name: "Thread scope" }); - expect(scope.textContent).toContain("All"); - expectClasses(scope, "text-subtle-foreground"); - expect(scope.querySelector('[data-icon="ChevronDown"]')).not.toBeNull(); + expect(screen.queryByRole("button", { name: "Thread scope" })).toBeNull(); expect(screen.getByRole("button", { name: "Open in split" })).toBeTruthy(); expect(document.querySelector("[data-palette-footer]")).toBeNull(); await requestShortcutHints(); @@ -744,7 +741,7 @@ describe("CommandPalette", () => { }, ); - it("reveals split guidance on demand and hides it on release, menu focus, no matches, and Commands", async () => { + it("reveals split guidance on demand and hides it on release, action focus, no matches, and Commands", async () => { modeState.activeRecents = [makeThread("selected")]; renderPalette(); openThreadSearch(); @@ -758,15 +755,10 @@ describe("CommandPalette", () => { fireEvent.keyUp(window, { key: "Control" }); expect(palette.querySelector("[data-palette-footer]")).toBeNull(); await requestShortcutHints(); - const scope = screen.getByRole("button", { name: "Thread scope" }); - act(() => scope.focus()); - fireEvent.keyDown(scope, { key: "Enter" }); - await screen.findByRole("menu", { name: "Thread scope" }); + const split = screen.getByRole("button", { name: "Open in split" }); + act(() => split.focus()); expect(palette.querySelector("[data-palette-footer]")).toBeNull(); - fireEvent.keyDown(screen.getByRole("menuitemradio", { name: "All" }), { - key: "Escape", - }); - await waitFor(() => expect(document.activeElement).toBe(searchField())); + act(() => searchField().focus()); await requestShortcutHints(); expect( palette.querySelector("[data-palette-footer]")?.textContent, diff --git a/packages/plugin-api-map/src/anatomy-manifest.json b/packages/plugin-api-map/src/anatomy-manifest.json index 24631fc336..acee4e75b3 100644 --- a/packages/plugin-api-map/src/anatomy-manifest.json +++ b/packages/plugin-api-map/src/anatomy-manifest.json @@ -40,7 +40,7 @@ "anchors": [ "top-[12%] max-w-[640px] translate-y-0 gap-0 p-0", "aria-selected={isActive}", - "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", + "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" ] }, From 52b388ca7e33c5cef58432c634988ce1acd2e54c Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sat, 12 Sep 2026 17:41:51 -0400 Subject: [PATCH 24/25] Keep status tooltips and derive highlights from current search contract --- .../commands/ThreadSearchPaletteMode.tsx | 5 +++-- .../palette-thread-search-window.ts | 16 ++++++++-------- .../lib/command-palette/palette-thread-search.ts | 3 +-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx b/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx index a063f97a8a..47ff5fdd9c 100644 --- a/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx +++ b/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx @@ -10,6 +10,7 @@ import { 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 { @@ -24,7 +25,7 @@ import { resolveThreadListIndicator, type ThreadListIndicatorState, } from "@bb/client-core"; -import type { ThreadSearchHighlightRange } from "@bb/server-contract"; +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"; @@ -405,7 +406,7 @@ function HighlightedText({ ranges, text, }: { - ranges: readonly ThreadSearchHighlightRange[]; + ranges: readonly ThreadSearchMatch["highlightRanges"][number][]; text: string; }) { if (ranges.length === 0) return <>{text}; 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 index ee2e12708e..8016308217 100644 --- a/apps/app/src/lib/command-palette/palette-thread-search-window.ts +++ b/apps/app/src/lib/command-palette/palette-thread-search-window.ts @@ -1,4 +1,4 @@ -import type { ThreadSearchHighlightRange } from "@bb/server-contract"; +import type { ThreadSearchMatch } from "@bb/server-contract"; const THREAD_SEARCH_WINDOW_LEAD_CHARS = 16; const THREAD_SEARCH_WINDOW_TAIL_CHARS = 40; @@ -6,12 +6,12 @@ const THREAD_SEARCH_WINDOW_ELLIPSIS = "…"; export interface WindowPaletteThreadSearchTextArgs { text: string; - highlightRanges: readonly ThreadSearchHighlightRange[]; + highlightRanges: readonly ThreadSearchMatch["highlightRanges"][number][]; } export interface WindowedPaletteThreadSearchText { text: string; - highlightRanges: ThreadSearchHighlightRange[]; + highlightRanges: ThreadSearchMatch["highlightRanges"][number][]; } function isHighSurrogate(text: string, index: number): boolean { @@ -42,9 +42,9 @@ function clampInteger(value: number, maximum: number): number | null { function normalizeHighlightRanges( text: string, - ranges: readonly ThreadSearchHighlightRange[], -): ThreadSearchHighlightRange[] { - const normalized: ThreadSearchHighlightRange[] = []; + 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); @@ -67,7 +67,7 @@ function normalizeHighlightRanges( (left, right) => left.start - right.start || left.end - right.end, ); - const merged: ThreadSearchHighlightRange[] = []; + const merged: ThreadSearchMatch["highlightRanges"][number][] = []; for (const range of normalized) { const previous = merged.at(-1); if (previous !== undefined && range.start < previous.end) { @@ -144,7 +144,7 @@ export function windowPaletteThreadSearchText({ const prefix = start > 0 ? THREAD_SEARCH_WINDOW_ELLIPSIS : ""; const suffix = end < text.length ? THREAD_SEARCH_WINDOW_ELLIPSIS : ""; - const rebasedRanges: ThreadSearchHighlightRange[] = []; + const rebasedRanges: ThreadSearchMatch["highlightRanges"][number][] = []; for (const range of normalizedRanges) { const rangeStart = Math.max(range.start, start); diff --git a/apps/app/src/lib/command-palette/palette-thread-search.ts b/apps/app/src/lib/command-palette/palette-thread-search.ts index 66b5f59ea2..11cae469b4 100644 --- a/apps/app/src/lib/command-palette/palette-thread-search.ts +++ b/apps/app/src/lib/command-palette/palette-thread-search.ts @@ -1,6 +1,5 @@ import { PERSONAL_PROJECT_ID, type ThreadListEntry } from "@bb/domain"; import type { - ThreadSearchHighlightRange, ThreadSearchMatch, ThreadSearchResponse, } from "@bb/server-contract"; @@ -13,7 +12,7 @@ export interface PaletteThreadSearchRow { id: string; lifecycle: PaletteThreadLifecycle; primaryText: string; - highlightRanges: readonly ThreadSearchHighlightRange[]; + highlightRanges: readonly ThreadSearchMatch["highlightRanges"][number][]; metadataText: string; projectId: string; threadId: string; From 15e439becdcee7ff94fa599909a7ee2521ce5fb5 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sat, 12 Sep 2026 17:44:58 -0400 Subject: [PATCH 25/25] Make follow-up draft fixture explicitly read --- apps/app/src/components/commands/CommandPalette.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/app/src/components/commands/CommandPalette.test.tsx b/apps/app/src/components/commands/CommandPalette.test.tsx index bedc51e4e0..1a0956560b 100644 --- a/apps/app/src/components/commands/CommandPalette.test.tsx +++ b/apps/app/src/components/commands/CommandPalette.test.tsx @@ -711,7 +711,7 @@ describe("CommandPalette", () => { 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() }), + makeThread("newer", { updatedAt: Date.now(), lastReadAt: Date.now() }), ]; modeState.threadDraftIds.add("newer"); modeState.searchResponse = {