From 42d1d50caf7bbb3e6d80da1f18bd52f51d3d54ad Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Wed, 26 Aug 2026 10:50:39 +0000 Subject: [PATCH 1/6] Group command palette root commands --- .../commands/CommandPalette.test.tsx | 104 ++++++++++++++++- .../components/commands/CommandPalette.tsx | 108 +++++++++++++++--- .../src/lib/command-palette/palette-action.ts | 13 ++- .../palette-app-commands.test.ts | 10 ++ .../command-palette/palette-app-commands.ts | 1 + .../palette-plugin-actions.test.ts | 41 ++++++- .../command-palette/palette-plugin-actions.ts | 4 +- .../command-palette/palette-ranking.test.ts | 9 +- apps/app/src/lib/plugin-logos.ts | 5 + 9 files changed, 266 insertions(+), 29 deletions(-) diff --git a/apps/app/src/components/commands/CommandPalette.test.tsx b/apps/app/src/components/commands/CommandPalette.test.tsx index 3d88794e7a..1e440043d6 100644 --- a/apps/app/src/components/commands/CommandPalette.test.tsx +++ b/apps/app/src/components/commands/CommandPalette.test.tsx @@ -6,6 +6,7 @@ import { render, screen, waitFor, + within, } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -15,11 +16,16 @@ import { type AppDefaultKeybinding, type AppKeybinding, } from "@bb/domain"; +import { CHROME_SECTION_LABEL_CLASS } from "@bb/shared-ui/chrome-style-tokens"; import { AppCommandProvider, useAppCommandHandler } from "./AppCommandProvider"; import { removePluginSlotRegistrations, setPluginSlotRegistrations, } from "@/lib/plugin-slots"; +import { + resetPluginLogoStoreForTest, + setPluginLogoUrls, +} from "@/lib/plugin-logos"; import { CommandPalette } from "./CommandPalette"; const PALETTE_SHORTCUT = { @@ -55,6 +61,20 @@ const THREAD_NEW_BINDING: AppKeybinding = { when: { all: ["mainSurface"], none: ["modalOpen"] }, }; +const THREAD_SEARCH_BINDING: AppKeybinding = { + command: "thread.search", + desktopOnly: false, + shortcut: { + key: "k", + mod: true, + meta: false, + control: false, + alt: false, + shift: false, + }, + when: { all: ["mainSurface"], none: ["modalOpen"] }, +}; + function defaults(...commands: AppCommandId[]): AppDefaultKeybinding[] { return commands.map((command) => ({ command, @@ -73,9 +93,10 @@ vi.mock("@/hooks/queries/system-queries", () => ({ ...defaultAppSettings, showKeyboardHints: false, }, - keybindings: [PALETTE_BINDING, THREAD_NEW_BINDING], + keybindings: [PALETTE_BINDING, THREAD_NEW_BINDING, THREAD_SEARCH_BINDING], defaultKeybindings: [ PALETTE_BINDING, + THREAD_SEARCH_BINDING, ...defaults( "thread.new", "thread.next", @@ -107,6 +128,7 @@ function renderPalette() { origin + @@ -131,6 +153,9 @@ function openPalette(): KeyboardEvent { } const searchField = () => screen.getByRole("combobox"); +const commandList = () => screen.getByRole("listbox", { name: "Commands" }); +const bucketGroup = (name: string) => + within(commandList()).getByRole("group", { name }); const optionTitles = () => screen.getAllByRole("option").map((option) => option.textContent); const selectedOption = () => @@ -141,6 +166,7 @@ const selectedOption = () => afterEach(() => { cleanup(); removePluginSlotRegistrations("linear"); + resetPluginLogoStoreForTest(); testState.calls.length = 0; window.localStorage.clear(); }); @@ -155,7 +181,49 @@ describe("CommandPalette", () => { const titles = optionTitles(); expect(titles?.[0]).toContain("New thread"); // Every mounted handler is listed; nothing else is. - expect(titles).toHaveLength(4); + expect(titles).toHaveLength(5); + }); + + it("groups the resting root into three text buckets with producer metadata", async () => { + renderPalette(); + openPalette(); + await waitFor(() => expect(searchField()).toBeTruthy()); + + const groups = within(commandList()).getAllByRole("group"); + expect( + groups.map((group) => group.getAttribute("data-palette-bucket")), + ).toEqual(["Threads", "Actions", "Plugins"]); + for (const [index, label] of ["Threads", "Actions", "Plugins"].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); + } + + const threadRows = within(bucketGroup("Threads")).getAllByRole("option"); + expect(threadRows.map((row) => row.textContent)).toEqual([ + expect.stringContaining("New thread"), + expect.stringContaining("Search threads"), + expect.stringContaining("Next thread"), + ]); + // The bucket already supplies this context, so rows do not repeat it. + for (const row of threadRows) { + expect(within(row).queryByText("Threads")).toBeNull(); + } + expect(threadRows[1]?.querySelector("kbd")).not.toBeNull(); + + 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); + } + // Root rows and headers remain text-only; the existing input icon is + // outside this list and is removed by the mode-shell layer. + expect(commandList().querySelector("[data-icon]")).toBeNull(); }); it("filters as the user types and keeps the selection on a live row", async () => { @@ -169,6 +237,8 @@ describe("CommandPalette", () => { await waitFor(() => expect(optionTitles()).toHaveLength(1)); expect(selectedOption()?.textContent).toContain("Open terminal"); + expect(selectedOption()?.textContent).toContain("Workspace"); + expect(within(commandList()).queryAllByRole("group")).toHaveLength(0); }); it("wraps at both ends of the list", async () => { @@ -199,7 +269,7 @@ describe("CommandPalette", () => { expect(document.activeElement).toBe(screen.getByTestId("origin")); }); - it("offers the last command run first the next time it opens", async () => { + it("offers the last command run first within its resting bucket", async () => { renderPalette(); openPalette(); await waitFor(() => expect(searchField()).toBeTruthy()); @@ -212,7 +282,8 @@ describe("CommandPalette", () => { openPalette(); await waitFor(() => expect(searchField()).toBeTruthy()); - expect(optionTitles()?.[0]).toContain("Toggle panel"); + const actionRows = within(bucketGroup("Actions")).getAllByRole("option"); + expect(actionRows[0]?.textContent).toContain("Toggle panel"); }); it("closes on Escape without running anything", async () => { @@ -278,6 +349,21 @@ describe("CommandPalette", () => { }); it("lists a plugin's commandPaletteAction and runs it", async () => { + setPluginLogoUrls( + new Map([ + [ + "linear", + { + displayName: "Linear", + icon: null, + compactIconUrl: null, + logoUrl: null, + logoDarkUrl: null, + icons: new Map(), + }, + ], + ]), + ); setPluginSlotRegistrations("linear", { homepageSections: [], settingsSections: [], @@ -289,7 +375,7 @@ describe("CommandPalette", () => { commandPaletteActions: [ { id: "open-issue", - title: "Linear: open issue", + title: "Open issue", run: () => { testState.calls.push("plugin-ran"); }, @@ -300,9 +386,15 @@ describe("CommandPalette", () => { openPalette(); await waitFor(() => expect(searchField()).toBeTruthy()); + const pluginRow = within(bucketGroup("Plugins")).getByRole("option"); + expect(pluginRow.textContent).toContain("Open issue"); + expect(pluginRow.textContent).toContain("Linear"); + fireEvent.change(searchField(), { target: { value: "linear" } }); await waitFor(() => expect(optionTitles()).toHaveLength(1)); - expect(optionTitles()?.[0]).toContain("Linear: open issue"); + expect(optionTitles()?.[0]).toContain("Open issue"); + expect(optionTitles()?.[0]).toContain("Linear"); + expect(within(commandList()).queryAllByRole("group")).toHaveLength(0); fireEvent.keyDown(searchField(), { key: "Enter" }); await waitFor(() => expect(testState.calls).toEqual(["plugin-ran"])); diff --git a/apps/app/src/components/commands/CommandPalette.tsx b/apps/app/src/components/commands/CommandPalette.tsx index 3db58738c2..f60adbba9d 100644 --- a/apps/app/src/components/commands/CommandPalette.tsx +++ b/apps/app/src/components/commands/CommandPalette.tsx @@ -11,6 +11,7 @@ import { Dialog, DialogContent, DialogTitle } from "@bb/shared-ui/dialog"; import { Icon } from "@bb/shared-ui/icon"; import { cn } from "@bb/shared-ui/lib/utils"; import { COARSE_POINTER_TEXT_SM_CLASS } from "@bb/shared-ui/coarse-pointer-sizing"; +import { CHROME_SECTION_LABEL_CLASS } from "@bb/shared-ui/chrome-style-tokens"; import { LAUNCHER_ACTION_ROW_BASE_CLASS } from "@/components/secondary-panel/launcherRow"; import { useAppCommandHandler, @@ -18,7 +19,10 @@ import { useAppCommandShortcuts, } from "./AppCommandProvider"; import { AppCommandShortcutPill } from "./AppCommandShortcutHint"; -import type { PaletteAction } from "@/lib/command-palette/palette-action"; +import { + PALETTE_ACTION_BUCKETS, + type PaletteAction, +} from "@/lib/command-palette/palette-action"; import { buildAppCommandActions, PALETTE_COMMAND_IDS, @@ -94,9 +98,29 @@ export function CommandPalette({ threadId, projectId }: CommandPaletteProps) { () => rankPaletteActions({ actions, query, recentIds: recents }), [actions, query, recents], ); + const isGroupedRoot = query.trim() === ""; + const rootGroups = useMemo(() => { + const groups = PALETTE_ACTION_BUCKETS.map((bucket) => ({ + bucket, + entries: ranked.filter((entry) => entry.action.bucket === bucket), + })); + return groups.map((group, index) => ({ + ...group, + startIndex: groups + .slice(0, index) + .reduce((total, prior) => total + prior.entries.length, 0), + })); + }, [ranked]); + const visibleEntries = useMemo( + () => + isGroupedRoot ? rootGroups.flatMap((group) => group.entries) : ranked, + [isGroupedRoot, ranked, rootGroups], + ); // Typing can shrink the list under the selection. const activeIndex = - ranked.length === 0 ? -1 : Math.min(highlightedIndex, ranked.length - 1); + visibleEntries.length === 0 + ? -1 + : Math.min(highlightedIndex, visibleEntries.length - 1); /** * Focus stays in the search field, so nothing scrolls the highlighted row @@ -136,12 +160,12 @@ export function CommandPalette({ threadId, projectId }: CommandPaletteProps) { const handleKeyDown = useCallback( (event: ReactKeyboardEvent) => { - if (ranked.length === 0) return; + if (visibleEntries.length === 0) return; if (event.key === "ArrowDown") { event.preventDefault(); scrollOnNextHighlightRef.current = true; setHighlightedIndex((current) => - current + 1 >= ranked.length ? 0 : current + 1, + current + 1 >= visibleEntries.length ? 0 : current + 1, ); return; } @@ -149,7 +173,7 @@ export function CommandPalette({ threadId, projectId }: CommandPaletteProps) { event.preventDefault(); scrollOnNextHighlightRef.current = true; setHighlightedIndex((current) => - current <= 0 ? ranked.length - 1 : current - 1, + current <= 0 ? visibleEntries.length - 1 : current - 1, ); return; } @@ -162,17 +186,17 @@ export function CommandPalette({ threadId, projectId }: CommandPaletteProps) { if (event.key === "End") { event.preventDefault(); scrollOnNextHighlightRef.current = true; - setHighlightedIndex(ranked.length - 1); + setHighlightedIndex(visibleEntries.length - 1); return; } if (event.key === "Enter") { - const choice = ranked[activeIndex]; + const choice = visibleEntries[activeIndex]; if (choice === undefined) return; event.preventDefault(); chooseAction(choice.action); } }, - [activeIndex, chooseAction, ranked], + [activeIndex, chooseAction, visibleEntries], ); return ( @@ -224,12 +248,48 @@ export function CommandPalette({ threadId, projectId }: CommandPaletteProps) { aria-label="Commands" className="max-h-[min(24rem,50dvh)] overflow-y-auto p-1" > - {ranked.length === 0 ? ( + {!isGroupedRoot && visibleEntries.length === 0 ? (

No matching commands

+ ) : isGroupedRoot ? ( + rootGroups.map((group, groupIndex) => { + const labelId = `${optionIdPrefix}-${group.bucket.toLowerCase()}-label`; + return ( +
+
+ {group.bucket} +
+ {group.entries.map((entry, index) => { + const visibleIndex = group.startIndex + index; + return ( + setHighlightedIndex(visibleIndex)} + onSelect={() => chooseAction(entry.action)} + /> + ); + })} +
+ ); + }) ) : ( - ranked.map((entry, index) => ( + visibleEntries.map((entry, index) => ( void; onSelect: () => void; }) { + const metadataGroup = + entry.action.group === entry.action.bucket ? null : entry.action.group; + const hasTrailing = metadataGroup !== null || entry.action.shortcut !== null; return ( // A listbox option the input points at, not a focusable control.
- - - {entry.action.group} + {hasTrailing ? ( + + {metadataGroup === null ? null : ( + + {metadataGroup} + + )} + {entry.action.shortcut === null ? null : ( + + )} - {entry.action.shortcut === null ? null : ( - - )} - + ) : null}
); } diff --git a/apps/app/src/lib/command-palette/palette-action.ts b/apps/app/src/lib/command-palette/palette-action.ts index 131a3d2172..c4ca04db3a 100644 --- a/apps/app/src/lib/command-palette/palette-action.ts +++ b/apps/app/src/lib/command-palette/palette-action.ts @@ -1,5 +1,14 @@ import type { AppShortcutPresentation } from "@/lib/app-keybindings"; +/** Root sections, in their rendered order. Producers choose; the shell groups. */ +export const PALETTE_ACTION_BUCKETS = [ + "Threads", + "Actions", + "Plugins", +] as const; + +export type PaletteActionBucket = (typeof PALETTE_ACTION_BUCKETS)[number]; + /** * One row of the quick palette. Producer-agnostic so ranking and rendering do * not care whether an action came from an app command or elsewhere. @@ -7,7 +16,9 @@ import type { AppShortcutPresentation } from "@/lib/app-keybindings"; export interface PaletteAction { /** Stable across sessions; the recents key. `app:thread.new`. */ id: string; - /** Section label; also matched against the query. */ + /** Root section at rest. */ + bucket: PaletteActionBucket; + /** Producer-owned metadata group; also matched against the query. */ group: string; title: string; /** Drawn as a pill on the row; null when the command has no binding. */ diff --git a/apps/app/src/lib/command-palette/palette-app-commands.test.ts b/apps/app/src/lib/command-palette/palette-app-commands.test.ts index ca90f7d23c..495515276e 100644 --- a/apps/app/src/lib/command-palette/palette-app-commands.test.ts +++ b/apps/app/src/lib/command-palette/palette-app-commands.test.ts @@ -71,11 +71,21 @@ describe("buildAppCommandActions", () => { ); expect(actions[0]).toMatchObject({ id: "app:thread.new", + bucket: "Threads", group: "Threads", shortcut: SHORTCUT, }); }); + it("buckets non-thread commands as actions without replacing their metadata group", () => { + const { actions } = build(["panel.toggle"]); + expect(actions[0]).toMatchObject({ + id: "app:panel.toggle", + bucket: "Actions", + group: "Window and layout", + }); + }); + it("leaves the shortcut null for a command the user has not bound", () => { const { actions } = build(["thread.rename"]); expect(actions[0]?.shortcut).toBeNull(); diff --git a/apps/app/src/lib/command-palette/palette-app-commands.ts b/apps/app/src/lib/command-palette/palette-app-commands.ts index 7841c867c3..15ac3988f9 100644 --- a/apps/app/src/lib/command-palette/palette-app-commands.ts +++ b/apps/app/src/lib/command-palette/palette-app-commands.ts @@ -41,6 +41,7 @@ export function buildAppCommandActions( if (!args.isCommandAvailable(command, args.target)) continue; actions.push({ id: paletteActionIdForCommand(command), + bucket: group.label === "Threads" ? "Threads" : "Actions", group: group.label, title: metadata.label, shortcut: args.shortcuts.get(command) ?? null, diff --git a/apps/app/src/lib/command-palette/palette-plugin-actions.test.ts b/apps/app/src/lib/command-palette/palette-plugin-actions.test.ts index 46069c1646..23b729c6e0 100644 --- a/apps/app/src/lib/command-palette/palette-plugin-actions.test.ts +++ b/apps/app/src/lib/command-palette/palette-plugin-actions.test.ts @@ -1,6 +1,10 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { PluginThreadPanelOpenHandler } from "@/components/plugin/plugin-thread-panel-navigation"; import type { PluginCommandPaletteActionSlot } from "@/lib/plugin-slots"; +import { + resetPluginLogoStoreForTest, + setPluginLogoUrls, +} from "@/lib/plugin-logos"; import { buildPluginPaletteActions } from "./palette-plugin-actions"; function slot( @@ -27,7 +31,42 @@ function build( }); } +afterEach(() => { + resetPluginLogoStoreForTest(); + vi.restoreAllMocks(); +}); + describe("buildPluginPaletteActions", () => { + it("buckets plugin actions together and attributes them to the manifest name", () => { + setPluginLogoUrls( + new Map([ + [ + "linear", + { + displayName: "Linear", + icon: null, + compactIconUrl: null, + logoUrl: null, + logoDarkUrl: null, + icons: new Map(), + }, + ], + ]), + ); + + expect(build([slot({ id: "listed" })])[0]).toMatchObject({ + bucket: "Plugins", + group: "Linear", + }); + }); + + it("uses the stable plugin id when the manifest name is unavailable", () => { + expect(build([slot({ id: "listed" })])[0]).toMatchObject({ + bucket: "Plugins", + group: "linear", + }); + }); + it("drops a row whose isAvailable declines or throws", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const rows = build([ diff --git a/apps/app/src/lib/command-palette/palette-plugin-actions.ts b/apps/app/src/lib/command-palette/palette-plugin-actions.ts index 5956e1b499..a49f0135ad 100644 --- a/apps/app/src/lib/command-palette/palette-plugin-actions.ts +++ b/apps/app/src/lib/command-palette/palette-plugin-actions.ts @@ -1,6 +1,7 @@ import type { PluginCommandPaletteActionContext } from "@get-bb/plugin-sdk"; import type { PluginThreadPanelOpenHandler } from "@/components/plugin/plugin-thread-panel-navigation"; import type { PluginCommandPaletteActionSlot } from "@/lib/plugin-slots"; +import { getPluginDisplayName } from "@/lib/plugin-logos"; import type { PaletteAction } from "./palette-action"; export interface BuildPluginPaletteActionsArgs { @@ -61,7 +62,8 @@ export function buildPluginPaletteActions( } actions.push({ id: `plugin:${slot.pluginId}/${slot.id}`, - group: "Plugins", + bucket: "Plugins", + group: getPluginDisplayName(slot.pluginId), title: slot.title, shortcut: null, run: () => { diff --git a/apps/app/src/lib/command-palette/palette-ranking.test.ts b/apps/app/src/lib/command-palette/palette-ranking.test.ts index a3749ef2bc..5b60a33f44 100644 --- a/apps/app/src/lib/command-palette/palette-ranking.test.ts +++ b/apps/app/src/lib/command-palette/palette-ranking.test.ts @@ -3,7 +3,14 @@ import type { PaletteAction } from "./palette-action"; import { rankPaletteActions } from "./palette-ranking"; function action(id: string, title: string, group: string): PaletteAction { - return { id, title, group, shortcut: null, run: () => {} }; + return { + id, + bucket: group === "Threads" ? "Threads" : "Actions", + title, + group, + shortcut: null, + run: () => {}, + }; } const ACTIONS: readonly PaletteAction[] = [ diff --git a/apps/app/src/lib/plugin-logos.ts b/apps/app/src/lib/plugin-logos.ts index e3335a7d9a..b416704380 100644 --- a/apps/app/src/lib/plugin-logos.ts +++ b/apps/app/src/lib/plugin-logos.ts @@ -52,6 +52,11 @@ function getPluginLogoUrls(): ReadonlyMap { return logoUrls; } +/** Manifest display name, with the stable plugin id as the unavailable fallback. */ +export function getPluginDisplayName(pluginId: string): string { + return getPluginLogoUrls().get(pluginId)?.displayName ?? pluginId; +} + /** Compact branding resolved from the latest plugin inventory. */ export function usePluginCompactBranding( pluginId: string, From 19d204225e5465471f74dc2d5a4a1633e0e4a525 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Wed, 26 Aug 2026 11:17:42 +0000 Subject: [PATCH 2/6] Add thread search palette mode --- .../commands/CommandPalette.test.tsx | 226 ++++++++- .../components/commands/CommandPalette.tsx | 269 ++++++---- .../src/components/commands/PaletteShell.tsx | 92 ++++ .../commands/ThreadSearchPaletteMode.tsx | 467 ++++++++++++++++++ .../src/lib/command-palette/palette-mode.ts | 32 ++ .../src/lib/command-palette/palette-modes.ts | 22 + .../palette-thread-search-window.test.ts | 139 ++++++ .../palette-thread-search-window.ts | 175 +++++++ .../palette-thread-search.test.ts | 169 +++++++ .../command-palette/palette-thread-search.ts | 209 ++++++++ .../split-layout/openThreadInSplit.test.ts | 63 +++ .../src/lib/split-layout/openThreadInSplit.ts | 17 +- 12 files changed, 1778 insertions(+), 102 deletions(-) create mode 100644 apps/app/src/components/commands/PaletteShell.tsx create mode 100644 apps/app/src/components/commands/ThreadSearchPaletteMode.tsx create mode 100644 apps/app/src/lib/command-palette/palette-mode.ts create mode 100644 apps/app/src/lib/command-palette/palette-modes.ts create mode 100644 apps/app/src/lib/command-palette/palette-thread-search-window.test.ts create mode 100644 apps/app/src/lib/command-palette/palette-thread-search-window.ts create mode 100644 apps/app/src/lib/command-palette/palette-thread-search.test.ts create mode 100644 apps/app/src/lib/command-palette/palette-thread-search.ts create mode 100644 apps/app/src/lib/split-layout/openThreadInSplit.test.ts diff --git a/apps/app/src/components/commands/CommandPalette.test.tsx b/apps/app/src/components/commands/CommandPalette.test.tsx index 1e440043d6..bc8b9eeb05 100644 --- a/apps/app/src/components/commands/CommandPalette.test.tsx +++ b/apps/app/src/components/commands/CommandPalette.test.tsx @@ -15,7 +15,10 @@ import { type AppCommandId, type AppDefaultKeybinding, type AppKeybinding, + type ThreadListEntry, } from "@bb/domain"; +import { emptyPromptDraftState } from "@bb/client-core"; +import type { ThreadSearchResponse } from "@bb/server-contract"; import { CHROME_SECTION_LABEL_CLASS } from "@bb/shared-ui/chrome-style-tokens"; import { AppCommandProvider, useAppCommandHandler } from "./AppCommandProvider"; import { @@ -27,6 +30,7 @@ import { setPluginLogoUrls, } from "@/lib/plugin-logos"; import { CommandPalette } from "./CommandPalette"; +import type { NewThreadDraftRow } from "@/hooks/useNewThreadDraftSlots"; const PALETTE_SHORTCUT = { key: "p", @@ -85,6 +89,10 @@ function defaults(...commands: AppCommandId[]): AppDefaultKeybinding[] { } const testState = vi.hoisted(() => ({ calls: [] as string[] })); +const modeState = vi.hoisted(() => ({ + drafts: [] as NewThreadDraftRow[], + searchResponse: undefined as ThreadSearchResponse | undefined, +})); vi.mock("@/hooks/queries/system-queries", () => ({ useSystemConfig: () => ({ @@ -112,6 +120,35 @@ vi.mock("@/lib/bb-desktop", () => ({ getBbDesktopInfo: () => null, })); +vi.mock("@bb/shared-ui/hooks/use-compact-viewport", () => ({ + useIsCompactViewport: () => false, +})); + +vi.mock("@/hooks/useNewThreadDraftSlots", () => ({ + useNewThreadDraftSlots: () => modeState.drafts, +})); + +vi.mock("@/hooks/queries/sidebar-navigation-query", () => ({ + useSidebarNavigation: () => ({ data: undefined, isLoading: false }), +})); + +vi.mock("@/hooks/queries/thread-queries", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + useThreadSearch: ({ query }: { query: string }) => ({ + data: modeState.searchResponse, + debouncedQuery: query.trim(), + hasSearchableQuery: query.trim().length >= 2, + isDebouncing: false, + isError: false, + isFetching: false, + isLoading: false, + }), + }; +}); + function Handler({ command }: { command: AppCommandId }) { useAppCommandHandler(command, () => { testState.calls.push(command); @@ -120,6 +157,49 @@ function Handler({ command }: { command: AppCommandId }) { return null; } +function makeThread( + id: string, + overrides: Partial = {}, +): ThreadListEntry { + return { + id, + projectId: "project-1", + environmentId: null, + providerId: "codex", + title: `Title ${id}`, + titleFallback: `Title ${id}`, + sectionId: null, + status: "idle", + parentThreadId: null, + sourceThreadId: null, + originKind: null, + originPluginId: null, + visibility: "visible", + archivedAt: null, + pinnedAt: null, + pinSortKey: null, + deletedAt: null, + lastReadAt: null, + latestAttentionAt: 1, + createdAt: 1, + updatedAt: Date.now(), + activity: { + activeWorkflowCount: 0, + activeBackgroundAgentCount: 0, + activeBackgroundCommandCount: 0, + activePlanModeCount: 0, + activeGoalCount: 0, + }, + hasPendingInteraction: false, + environmentHostId: null, + environmentName: null, + environmentBranchName: null, + environmentWorkspaceDisplayKind: "other", + runtime: { displayStatus: "idle", hostReconnectGraceExpiresAt: null }, + ...overrides, + }; +} + function renderPalette() { const result = render( @@ -152,6 +232,17 @@ function openPalette(): KeyboardEvent { return event; } +function openThreadSearch(): KeyboardEvent { + const event = new KeyboardEvent("keydown", { + key: "k", + ctrlKey: true, + bubbles: true, + cancelable: true, + }); + (document.activeElement ?? window).dispatchEvent(event); + return event; +} + const searchField = () => screen.getByRole("combobox"); const commandList = () => screen.getByRole("listbox", { name: "Commands" }); const bucketGroup = (name: string) => @@ -168,6 +259,8 @@ afterEach(() => { removePluginSlotRegistrations("linear"); resetPluginLogoStoreForTest(); testState.calls.length = 0; + modeState.drafts = []; + modeState.searchResponse = undefined; window.localStorage.clear(); }); @@ -221,9 +314,138 @@ describe("CommandPalette", () => { for (const row of [...threadRows, ...actionRows]) { expect(row.classList.contains("px-2")).toBe(true); } - // Root rows and headers remain text-only; the existing input icon is - // outside this list and is removed by the mode-shell layer. + // Root rows and headers remain text-only. expect(commandList().querySelector("[data-icon]")).toBeNull(); + expect( + screen.getByTestId("command-palette").querySelector("svg"), + ).toBeNull(); + }); + + it("enters the registered thread mode from its existing command and pops one level per Escape", async () => { + renderPalette(); + const event = openThreadSearch(); + await waitFor(() => + expect( + screen.getByRole("combobox", { name: "Search threads" }), + ).toBeTruthy(), + ); + expect(event.defaultPrevented).toBe(true); + expect( + screen.getByText("Threads").closest("[data-palette-mode-chip]"), + ).not.toBeNull(); + expect( + screen.getByRole("button", { name: "Thread scope" }).textContent, + ).toContain("All"); + + fireEvent.keyDown(screen.getByRole("combobox"), { key: "Escape" }); + await waitFor(() => + expect( + screen.getByRole("combobox", { name: "Search commands" }), + ).toBeTruthy(), + ); + expect(screen.queryByRole("button", { name: "Thread scope" })).toBeNull(); + + fireEvent.keyDown(screen.getByRole("combobox"), { key: "Escape" }); + await waitFor(() => expect(screen.queryByRole("combobox")).toBeNull()); + }); + + it("enters the same registered mode by running Search threads from the root", async () => { + renderPalette(); + openPalette(); + await waitFor(() => expect(searchField()).toBeTruthy()); + + const searchCommand = within(bucketGroup("Threads")) + .getAllByRole("option") + .find((row) => row.textContent?.includes("Search threads")); + expect(searchCommand).toBeDefined(); + fireEvent.click(searchCommand as HTMLElement); + + await waitFor(() => + expect( + screen.getByRole("combobox", { name: "Search threads" }), + ).toBeTruthy(), + ); + expect(testState.calls).toEqual([]); + }); + + it("cycles the thread scope and resets it after leaving the mode", async () => { + renderPalette(); + openThreadSearch(); + await waitFor(() => + expect(screen.getByRole("button", { name: "Thread scope" })).toBeTruthy(), + ); + const scope = screen.getByRole("button", { name: "Thread scope" }); + scope.focus(); + fireEvent.keyDown(scope, { key: "ArrowDown" }); + expect(scope.textContent).toContain("Active"); + expect( + screen.getByRole("listbox", { name: "Thread scope options" }), + ).toBeTruthy(); + fireEvent.keyDown(scope, { key: "Escape" }); + expect(document.activeElement).toBe(screen.getByRole("combobox")); + + fireEvent.keyDown(screen.getByRole("combobox"), { key: "Escape" }); + await waitFor(() => + expect( + screen.getByRole("combobox", { name: "Search commands" }), + ).toBeTruthy(), + ); + const searchCommand = within(bucketGroup("Threads")) + .getAllByRole("option") + .find((row) => row.textContent?.includes("Search threads")); + fireEvent.click(searchCommand as HTMLElement); + await waitFor(() => + expect( + screen.getByRole("button", { name: "Thread scope" }).textContent, + ).toContain("All"), + ); + }); + + it("renders search matches as one unlabelled active, draft, archived list", async () => { + const active = makeThread("active"); + const archived = makeThread("archived", { archivedAt: Date.now() }); + modeState.searchResponse = { + active: { total: 1, results: [{ thread: active, matches: [] }] }, + archived: { total: 1, results: [{ thread: archived, matches: [] }] }, + }; + modeState.drafts = [ + { + id: "draft-1", + title: "matching draft", + draft: { ...emptyPromptDraftState(), text: "matching draft" }, + lastEditedAt: Date.now(), + destination: { projectId: "project-1", sectionId: null }, + delete: vi.fn(), + }, + ]; + renderPalette(); + openThreadSearch(); + await waitFor(() => + expect( + screen.getByRole("combobox", { name: "Search threads" }), + ).toBeTruthy(), + ); + fireEvent.change(screen.getByRole("combobox"), { + target: { value: "match" }, + }); + + const results = screen.getByRole("listbox", { name: "Threads" }); + await waitFor(() => + expect(within(results).getAllByRole("option")).toHaveLength(3), + ); + const rows = within(results).getAllByRole("option"); + expect(rows[0]?.textContent).toContain("Title active"); + 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"); + 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); }); it("filters as the user types and keeps the selection on a live row", async () => { diff --git a/apps/app/src/components/commands/CommandPalette.tsx b/apps/app/src/components/commands/CommandPalette.tsx index f60adbba9d..6ac252920f 100644 --- a/apps/app/src/components/commands/CommandPalette.tsx +++ b/apps/app/src/components/commands/CommandPalette.tsx @@ -8,15 +8,16 @@ import { } from "react"; import type { KeyboardEvent as ReactKeyboardEvent } from "react"; import { Dialog, DialogContent, DialogTitle } from "@bb/shared-ui/dialog"; -import { Icon } from "@bb/shared-ui/icon"; import { cn } from "@bb/shared-ui/lib/utils"; import { COARSE_POINTER_TEXT_SM_CLASS } from "@bb/shared-ui/coarse-pointer-sizing"; import { 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, } from "./AppCommandProvider"; import { AppCommandShortcutPill } from "./AppCommandShortcutHint"; import { @@ -26,6 +27,7 @@ import { import { buildAppCommandActions, PALETTE_COMMAND_IDS, + paletteActionIdForCommand, } from "@/lib/command-palette/palette-app-commands"; import { rankPaletteActions, @@ -38,8 +40,20 @@ import { import { buildPluginPaletteActions } from "@/lib/command-palette/palette-plugin-actions"; import { getPluginSlotSnapshot } from "@/lib/plugin-slots"; import { getActiveThreadPanelOpener } from "@/components/plugin/plugin-thread-panel-navigation"; +import { + PALETTE_MODE_ENTRY_COMMANDS, + PALETTE_MODES, +} from "@/lib/command-palette/palette-modes"; +import { PaletteShell } from "./PaletteShell"; const PALETTE_PLACEHOLDER = "Search commands"; +const MODE_ENTRY_HANDLER_PRIORITY = 100; +const MODE_BY_ACTION_ID = new Map( + PALETTE_MODES.map((mode) => [ + paletteActionIdForCommand(mode.entryCommand), + mode, + ]), +); export interface CommandPaletteProps { /** The surface's thread and project, handed to plugin rows. */ @@ -54,6 +68,7 @@ export interface CommandPaletteProps { export function CommandPalette({ threadId, projectId }: CommandPaletteProps) { const runner = useAppCommandRunner(); const shortcuts = useAppCommandShortcuts(PALETTE_COMMAND_IDS); + const paletteShortcut = useAppCommandShortcut("palette.open"); const listId = useId(); const optionIdPrefix = useId(); @@ -61,20 +76,17 @@ export function CommandPalette({ threadId, projectId }: CommandPaletteProps) { const [query, setQuery] = useState(""); const [actions, setActions] = useState([]); const [highlightedIndex, setHighlightedIndex] = useState(0); + const [activeModeId, setActiveModeId] = useState(null); const [recents, setRecents] = useState(() => readPaletteRecents(), ); // Where availability, dispatch, and focus-on-close all point. const openTargetRef = useRef(null); // Set when a row is chosen, read once focus has been restored. - const pendingActionRef = useRef(null); + const pendingRunRef = useRef<(() => void) | null>(null); - useAppCommandHandler("palette.open", (invocation) => { - const target = - invocation.target ?? - (typeof document === "undefined" ? null : document.activeElement); - openTargetRef.current = target; - setActions([ + const buildActions = useCallback( + (target: EventTarget | null) => [ ...buildAppCommandActions({ target, isCommandAvailable: runner.isCommandAvailable, @@ -87,13 +99,52 @@ export function CommandPalette({ threadId, projectId }: CommandPaletteProps) { projectId, openThreadPanel: getActiveThreadPanelOpener(), }), - ]); - setQuery(""); - setHighlightedIndex(0); + ], + [ + projectId, + runner.dispatch, + runner.isCommandAvailable, + shortcuts, + threadId, + ], + ); + + const prepareOpen = useCallback( + (target: EventTarget | null) => { + openTargetRef.current = target; + setActions(buildActions(target)); + setQuery(""); + setHighlightedIndex(0); + }, + [buildActions], + ); + + useAppCommandHandler("palette.open", (invocation) => { + const target = + invocation.target ?? + (typeof document === "undefined" ? null : document.activeElement); + prepareOpen(target); + setActiveModeId(null); setOpen(true); return true; }); + useIndexedAppCommandHandlers( + PALETTE_MODE_ENTRY_COMMANDS, + (index, invocation) => { + const mode = PALETTE_MODES[index]; + if (mode === undefined) return false; + const target = + invocation.target ?? + (typeof document === "undefined" ? null : document.activeElement); + prepareOpen(target); + setActiveModeId(mode.id); + setOpen(true); + return true; + }, + MODE_ENTRY_HANDLER_PRIORITY, + ); + const ranked = useMemo( () => rankPaletteActions({ actions, query, recentIds: recents }), [actions, query, recents], @@ -138,8 +189,17 @@ export function CommandPalette({ threadId, projectId }: CommandPaletteProps) { }, [activeIndex]); const chooseAction = useCallback((action: PaletteAction) => { - pendingActionRef.current = action; 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; setOpen(false); }, []); @@ -148,14 +208,23 @@ export function CommandPalette({ threadId, projectId }: CommandPaletteProps) { * have it taken back by the dialog's own restoration a tick later. */ const handleCloseAutoFocus = useCallback((event: Event) => { - const pending = pendingActionRef.current; - pendingActionRef.current = null; + const pending = pendingRunRef.current; + pendingRunRef.current = null; const target = openTargetRef.current; if (target instanceof HTMLElement && target.isConnected) { event.preventDefault(); target.focus({ preventScroll: true }); } - pending?.run(); + pending?.(); + }, []); + + const handleOpenChange = useCallback((nextOpen: boolean) => { + setOpen(nextOpen); + if (!nextOpen) { + setActiveModeId(null); + setQuery(""); + setHighlightedIndex(0); + } }, []); const handleKeyDown = useCallback( @@ -198,109 +267,117 @@ export function CommandPalette({ threadId, projectId }: CommandPaletteProps) { }, [activeIndex, chooseAction, visibleEntries], ); + const activeMode = + activeModeId === null + ? undefined + : PALETTE_MODES.find((mode) => mode.id === activeModeId); return ( - + { + // A mode owns Escape as a one-level pop. The focused input/filter + // performs that transition; the dialog must not close underneath it. + if (activeMode !== undefined) event.preventDefault(); + }} data-testid="command-palette" > Quick palette -
- - { - setQuery(event.target.value); + accessory={ + paletteShortcut === null ? null : ( + + ) + } + inputLabel={PALETTE_PLACEHOLDER} + listId={listId} + listLabel="Commands" + listRef={listRef} + onInputChange={(value) => { + setQuery(value); setHighlightedIndex(0); // `activeIndex` may not change, so the effect above cannot do // this: send the scrolled container back to the first row. if (listRef.current !== null) listRef.current.scrollTop = 0; }} - onKeyDown={handleKeyDown} - /> -
-
- {!isGroupedRoot && visibleEntries.length === 0 ? ( -

- No matching commands -

- ) : isGroupedRoot ? ( - rootGroups.map((group, groupIndex) => { - const labelId = `${optionIdPrefix}-${group.bucket.toLowerCase()}-label`; - return ( -
+ onInputKeyDown={handleKeyDown} + placeholder={PALETTE_PLACEHOLDER} + value={query} + > + {!isGroupedRoot && visibleEntries.length === 0 ? ( +

+ No matching commands +

+ ) : isGroupedRoot ? ( + rootGroups.map((group, groupIndex) => { + const labelId = `${optionIdPrefix}-${group.bucket.toLowerCase()}-label`; + return (
- {group.bucket} +
+ {group.bucket} +
+ {group.entries.map((entry, index) => { + const visibleIndex = group.startIndex + index; + return ( + setHighlightedIndex(visibleIndex)} + onSelect={() => chooseAction(entry.action)} + /> + ); + })}
- {group.entries.map((entry, index) => { - const visibleIndex = group.startIndex + index; - return ( - setHighlightedIndex(visibleIndex)} - onSelect={() => chooseAction(entry.action)} - /> - ); - })} -
- ); - }) - ) : ( - visibleEntries.map((entry, index) => ( - setHighlightedIndex(index)} - onSelect={() => chooseAction(entry.action)} - /> - )) - )} -
+ ); + }) + ) : ( + visibleEntries.map((entry, index) => ( + setHighlightedIndex(index)} + onSelect={() => chooseAction(entry.action)} + /> + )) + )} + + ) : ( + { + setActiveModeId(null); + setQuery(""); + setHighlightedIndex(0); + }} + runAfterClose={runAfterClose} + /> + )}
); diff --git a/apps/app/src/components/commands/PaletteShell.tsx b/apps/app/src/components/commands/PaletteShell.tsx new file mode 100644 index 0000000000..7ee4bcfc05 --- /dev/null +++ b/apps/app/src/components/commands/PaletteShell.tsx @@ -0,0 +1,92 @@ +import type { KeyboardEventHandler, ReactNode, Ref } from "react"; +import { Icon } from "@bb/shared-ui/icon"; + +interface PaletteShellProps { + activeDescendantId?: string; + accessory?: ReactNode; + children: ReactNode; + footerKeys?: readonly { keys: string; label: string }[]; + inputLabel: string; + inputRef?: Ref; + listId: string; + listLabel: string; + listRef?: Ref; + modeChip?: { icon: Parameters[0]["name"]; label: string }; + onInputChange: (value: string) => void; + onInputKeyDown: KeyboardEventHandler; + placeholder: string; + value: string; +} + +/** Shared palette chrome. Command and Mode producers own only list content. */ +export function PaletteShell({ + activeDescendantId, + accessory, + children, + footerKeys = [], + inputLabel, + inputRef, + listId, + listLabel, + listRef, + modeChip, + onInputChange, + onInputKeyDown, + placeholder, + value, +}: PaletteShellProps) { + return ( + <> +
+ {modeChip === undefined ? null : ( + + + {modeChip.label} + + )} + onInputChange(event.target.value)} + onKeyDown={onInputKeyDown} + /> + {accessory} +
+
+ {children} +
+ {footerKeys.length === 0 ? null : ( +
+ {footerKeys.map((hint) => ( + + {hint.keys} + {hint.label} + + ))} +
+ )} + + ); +} diff --git a/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx b/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx new file mode 100644 index 0000000000..9027cdafc2 --- /dev/null +++ b/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx @@ -0,0 +1,467 @@ +import { + useCallback, + useEffect, + useId, + useLayoutEffect, + useMemo, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type ReactNode, +} from "react"; +import { useStore } from "jotai"; +import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport"; +import { cn } from "@bb/shared-ui/lib/utils"; +import { CHROME_SECTION_LABEL_CLASS } from "@bb/shared-ui/chrome-style-tokens"; +import { COARSE_POINTER_TEXT_SM_CLASS } from "@bb/shared-ui/coarse-pointer-sizing"; +import type { ThreadSearchHighlightRange } from "@bb/server-contract"; +import { useNewThreadDraftSlots } from "@/hooks/useNewThreadDraftSlots"; +import { useSidebarNavigation } from "@/hooks/queries/sidebar-navigation-query"; +import { + hasThreadSearchableQuery, + useThreadSearch, +} from "@/hooks/queries/thread-queries"; +import { useRouteNavigate } from "@/components/ui/app-route-anchor"; +import { getRootComposeRoutePath, getThreadRoutePath } from "@/lib/route-paths"; +import { withRootComposeDraftSlotId } from "@/lib/root-compose-location-state"; +import { openThreadInSplit } from "@/lib/split-layout/openThreadInSplit"; +import { + buildPaletteThreadSearchRows, + PALETTE_THREAD_SEARCH_SCOPES, + type PaletteThreadSearchRow, + type PaletteThreadSearchScope, +} from "@/lib/command-palette/palette-thread-search"; +import { windowPaletteThreadSearchText } from "@/lib/command-palette/palette-thread-search-window"; +import type { PaletteModeViewProps } from "@/lib/command-palette/palette-mode"; +import { PaletteShell } from "./PaletteShell"; + +export function ThreadSearchPaletteMode({ + onExit, + presentation, + runAfterClose, +}: PaletteModeViewProps) { + const listId = useId(); + const optionIdPrefix = useId(); + const inputRef = useRef(null); + const listRef = useRef(null); + const store = useStore(); + const navigate = useRouteNavigate(); + const isCompact = useIsCompactViewport(); + const [query, setQuery] = useState(""); + const [scope, setScope] = useState("all"); + const [highlightedIndex, setHighlightedIndex] = useState(0); + const [now] = useState(() => Date.now()); + const drafts = useNewThreadDraftSlots(); + const navigation = useSidebarNavigation(); + const threadSearch = useThreadSearch({ active: true, query }); + const trimmedQuery = query.trim(); + const searchable = hasThreadSearchableQuery(trimmedQuery); + const searchResultsAreCurrent = + !searchable || threadSearch.debouncedQuery === trimmedQuery; + + const projectNamesById = useMemo(() => { + const entries = [ + ...(navigation.data?.projects ?? []), + ...(navigation.data === undefined + ? [] + : [navigation.data.personalProject]), + ].map((project) => [project.id, project.name] as const); + return new Map(entries); + }, [navigation.data]); + const recentThreads = useMemo( + () => [ + ...(navigation.data?.projects.flatMap((project) => project.threads) ?? + []), + ...(navigation.data?.personalProject.threads ?? []), + ], + [navigation.data], + ); + const result = useMemo( + () => + buildPaletteThreadSearchRows({ + drafts, + now, + projectNamesById, + query, + recentThreads, + scope, + searchResponse: threadSearch.data, + searchResultsAreCurrent, + }), + [ + drafts, + now, + projectNamesById, + query, + recentThreads, + scope, + searchResultsAreCurrent, + threadSearch.data, + ], + ); + const activeIndex = + result.rows.length === 0 + ? -1 + : Math.min(highlightedIndex, result.rows.length - 1); + const activeDescendantId = + activeIndex < 0 ? undefined : `${optionIdPrefix}-${activeIndex}`; + + const scrollOnNextHighlightRef = useRef(false); + useEffect(() => { + if (!scrollOnNextHighlightRef.current) return; + scrollOnNextHighlightRef.current = false; + listRef.current + ?.querySelector('[aria-selected="true"]') + ?.scrollIntoView({ block: "nearest" }); + }, [activeIndex]); + + const openRow = useCallback( + (row: PaletteThreadSearchRow, split: boolean) => { + runAfterClose(() => { + if (row.threadId !== null) { + const state = + row.messageSeq === null + ? undefined + : { + searchMessageSeq: row.messageSeq, + searchThreadId: row.threadId, + }; + if (split) { + openThreadInSplit({ + store, + navigate, + projectId: row.projectId, + threadId: row.threadId, + isCompact, + state, + }); + return; + } + navigate( + getThreadRoutePath({ + projectId: row.projectId, + threadId: row.threadId, + }), + { state }, + ); + return; + } + if (row.draftSlotId !== null) { + navigate(getRootComposeRoutePath(), { + state: withRootComposeDraftSlotId( + { focusPrompt: true }, + row.draftSlotId, + ), + }); + } + }); + }, + [isCompact, navigate, runAfterClose, store], + ); + + const handleInputKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + onExit(); + return; + } + if (result.rows.length === 0) return; + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + scrollOnNextHighlightRef.current = true; + setHighlightedIndex((current) => { + if (event.key === "ArrowDown") { + return current + 1 >= result.rows.length ? 0 : current + 1; + } + return current <= 0 ? result.rows.length - 1 : current - 1; + }); + return; + } + if (event.key === "Home" || event.key === "End") { + event.preventDefault(); + scrollOnNextHighlightRef.current = true; + setHighlightedIndex(event.key === "Home" ? 0 : result.rows.length - 1); + return; + } + if (event.key === "Enter") { + const row = result.rows[activeIndex]; + if (row === undefined) return; + event.preventDefault(); + openRow(row, event.metaKey || event.ctrlKey); + } + }, + [activeIndex, onExit, openRow, result.rows], + ); + + const isLoading = + searchable && + (!searchResultsAreCurrent || + threadSearch.isDebouncing || + threadSearch.isLoading); + let emptyMessage: string | null = null; + if (result.rows.length === 0) { + emptyMessage = isLoading + ? "Searching threads" + : trimmedQuery.length === 1 + ? "Type at least 2 characters" + : navigation.isLoading && result.isRecent + ? "Loading recent threads" + : result.isRecent + ? "No recent threads" + : "No matching threads"; + } + + return ( + { + setScope(nextScope); + setHighlightedIndex(0); + if (listRef.current !== null) listRef.current.scrollTop = 0; + }} + /> + } + footerKeys={presentation.footerKeys} + inputLabel={presentation.placeholder} + inputRef={inputRef} + listId={listId} + listLabel="Threads" + listRef={listRef} + modeChip={presentation.chip} + onInputChange={(value) => { + setQuery(value); + setHighlightedIndex(0); + if (listRef.current !== null) listRef.current.scrollTop = 0; + }} + onInputKeyDown={handleInputKeyDown} + placeholder={presentation.placeholder} + value={query} + > + {result.isRecent ? ( +
+ Recent +
+ ) : null} + {emptyMessage === null ? ( + result.rows.map((row, index) => ( + setHighlightedIndex(index)} + onSelect={() => openRow(row, false)} + /> + )) + ) : ( +

+ {emptyMessage} +

+ )} +
+ ); +} + +function ThreadSearchScopeFilter({ + inputRef, + onScopeChange, + scope, +}: { + inputRef: React.RefObject; + onScopeChange: (scope: PaletteThreadSearchScope) => void; + scope: PaletteThreadSearchScope; +}) { + const [open, setOpen] = useState(false); + const currentIndex = PALETTE_THREAD_SEARCH_SCOPES.findIndex( + (candidate) => candidate.id === scope, + ); + const current = PALETTE_THREAD_SEARCH_SCOPES[currentIndex]; + const returnToInput = () => { + setOpen(false); + inputRef.current?.focus({ preventScroll: true }); + }; + const cycle = (direction: 1 | -1) => { + const nextIndex = + (currentIndex + direction + PALETTE_THREAD_SEARCH_SCOPES.length) % + PALETTE_THREAD_SEARCH_SCOPES.length; + const next = PALETTE_THREAD_SEARCH_SCOPES[nextIndex]; + if (next !== undefined) onScopeChange(next.id); + setOpen(true); + }; + + return ( +
+ + {open ? ( +
+ {PALETTE_THREAD_SEARCH_SCOPES.map((option) => ( +
event.preventDefault()} + onClick={() => { + onScopeChange(option.id); + returnToInput(); + }} + > + {option.label} +
+ ))} +
+ ) : null} +
+ ); +} + +function ThreadSearchPaletteRow({ + id, + isActive, + onActivate, + onSelect, + row, +}: { + id: string; + isActive: boolean; + onActivate: () => void; + onSelect: () => void; + row: PaletteThreadSearchRow; +}) { + const primaryRef = useRef(null); + const matchKey = `${row.primaryText}\u0000${row.highlightRanges + .map((range) => `${range.start}:${range.end}`) + .join(",")}`; + const [windowedMatchKey, setWindowedMatchKey] = useState(null); + const shouldWindowMatch = windowedMatchKey === matchKey; + const primary = shouldWindowMatch + ? windowPaletteThreadSearchText({ + text: row.primaryText, + highlightRanges: row.highlightRanges, + }) + : { text: row.primaryText, highlightRanges: row.highlightRanges }; + + useLayoutEffect(() => { + if (shouldWindowMatch || row.highlightRanges.length === 0) return; + const container = primaryRef.current; + if (container === null) return; + const firstMatch = container.querySelector("mark"); + if (firstMatch === null) return; + const containerRect = container.getBoundingClientRect(); + const matchRect = firstMatch.getBoundingClientRect(); + if ( + matchRect.left < containerRect.left || + matchRect.right > containerRect.right + ) { + setWindowedMatchKey(matchKey); + } + }, [matchKey, row.highlightRanges.length, shouldWindowMatch]); + + const stateLabel = + row.lifecycle === "active" + ? null + : row.lifecycle === "draft" + ? "Draft" + : "Archived"; + return ( +
+ + + + + + {row.metadataText} + + + {stateLabel === null ? null : ( + + {stateLabel} + + )} +
+ ); +} + +function HighlightedText({ + ranges, + text, +}: { + ranges: readonly ThreadSearchHighlightRange[]; + text: string; +}) { + if (ranges.length === 0) return <>{text}; + const nodes: ReactNode[] = []; + let cursor = 0; + for (const range of ranges) { + const start = Math.max(cursor, Math.min(range.start, text.length)); + const end = Math.max(start, Math.min(range.end, text.length)); + if (end <= start) continue; + if (start > cursor) nodes.push(text.slice(cursor, start)); + nodes.push( + + {text.slice(start, end)} + , + ); + cursor = end; + } + if (cursor < text.length) nodes.push(text.slice(cursor)); + return nodes; +} diff --git a/apps/app/src/lib/command-palette/palette-mode.ts b/apps/app/src/lib/command-palette/palette-mode.ts new file mode 100644 index 0000000000..4eb7e9af54 --- /dev/null +++ b/apps/app/src/lib/command-palette/palette-mode.ts @@ -0,0 +1,32 @@ +import type { ComponentType } from "react"; +import type { AppCommandId } from "@bb/domain"; +import type { IconName } from "@bb/shared-ui/icon"; + +export interface PaletteModePresentation { + chip: { + icon: IconName; + label: string; + }; + footerKeys: readonly { + keys: string; + label: string; + }[]; + placeholder: string; +} + +export interface PaletteModeViewProps { + onExit: () => void; + /** Queue work until after the dialog has restored focus and closed. */ + runAfterClose: (run: () => void) => void; + presentation: PaletteModePresentation; +} + +/** + * One content domain hosted by the palette shell. The registry is the only + * place the shell learns which existing command enters a mode. + */ +export interface PaletteModeRegistration extends PaletteModePresentation { + id: string; + entryCommand: AppCommandId; + View: ComponentType; +} diff --git a/apps/app/src/lib/command-palette/palette-modes.ts b/apps/app/src/lib/command-palette/palette-modes.ts new file mode 100644 index 0000000000..637bdf73d2 --- /dev/null +++ b/apps/app/src/lib/command-palette/palette-modes.ts @@ -0,0 +1,22 @@ +import type { PaletteModeRegistration } from "./palette-mode"; +import { ThreadSearchPaletteMode } from "@/components/commands/ThreadSearchPaletteMode"; + +export const PALETTE_MODES: readonly PaletteModeRegistration[] = [ + { + id: "thread-search", + entryCommand: "thread.search", + chip: { icon: "Search", label: "Threads" }, + placeholder: "Search threads", + footerKeys: [ + { keys: "↑↓", label: "Select" }, + { keys: "↵", label: "Open" }, + { keys: "⌘↵", label: "Open in split" }, + { keys: "Esc", label: "Back" }, + ], + View: ThreadSearchPaletteMode, + }, +]; + +export const PALETTE_MODE_ENTRY_COMMANDS = PALETTE_MODES.map( + (mode) => mode.entryCommand, +); diff --git a/apps/app/src/lib/command-palette/palette-thread-search-window.test.ts b/apps/app/src/lib/command-palette/palette-thread-search-window.test.ts new file mode 100644 index 0000000000..30826b15ea --- /dev/null +++ b/apps/app/src/lib/command-palette/palette-thread-search-window.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; +import { windowPaletteThreadSearchText } from "./palette-thread-search-window"; + +function highlightedText( + result: ReturnType, +): string[] { + return result.highlightRanges.map((range) => + result.text.slice(range.start, range.end), + ); +} + +describe("windowPaletteThreadSearchText", () => { + it("returns short text whole without ellipses", () => { + expect( + windowPaletteThreadSearchText({ + text: "A short matched message", + highlightRanges: [{ start: 8, end: 15 }], + }), + ).toEqual({ + text: "A short matched message", + highlightRanges: [{ start: 8, end: 15 }], + }); + }); + + it("marks both real cuts and preserves the first match", () => { + const text = `${"left".repeat(12)} needle ${"right".repeat(12)}`; + const matchStart = text.indexOf("needle"); + const result = windowPaletteThreadSearchText({ + text, + highlightRanges: [{ start: matchStart, end: matchStart + 6 }], + }); + + expect(result.text.startsWith("…")).toBe(true); + expect(result.text.endsWith("…")).toBe(true); + expect(highlightedText(result)).toEqual(["needle"]); + }); + + it("moves hard targets inward to whitespace word boundaries", () => { + const text = + "prefix alpha beta gamma delta MATCH one two three four five six seven trailing"; + const matchStart = text.indexOf("MATCH"); + const result = windowPaletteThreadSearchText({ + text, + highlightRanges: [{ start: matchStart, end: matchStart + 5 }], + }); + + expect(result.text).toBe( + "…gamma delta MATCH one two three four five six seven…", + ); + }); + + it("rebases every retained range and merges overlapping input", () => { + const text = `${"x".repeat(24)} first middle second ${"y".repeat(50)}`; + const firstStart = text.indexOf("first"); + const secondStart = text.indexOf("second"); + const result = windowPaletteThreadSearchText({ + text, + highlightRanges: [ + { start: secondStart + 3, end: secondStart + 6 }, + { start: firstStart + 2, end: firstStart + 5 }, + { start: firstStart, end: firstStart + 3 }, + { start: secondStart, end: secondStart + 4 }, + ], + }); + + expect(highlightedText(result)).toEqual(["first", "second"]); + expect(result.highlightRanges[0]?.start).toBe( + result.text.indexOf("first"), + ); + expect(result.highlightRanges[1]?.start).toBe( + result.text.indexOf("second"), + ); + }); + + it("clamps finite out-of-range input and drops malformed ranges", () => { + const result = windowPaletteThreadSearchText({ + text: "match remains", + highlightRanges: [ + { start: Number.NaN, end: 3 }, + { start: 9, end: 4 }, + { start: 100, end: 200 }, + { start: -20, end: 5.9 }, + ], + }); + + expect(result).toEqual({ + text: "match remains", + highlightRanges: [{ start: 0, end: 5 }], + }); + }); + + it("adds an ellipsis only on the side actually cut near either edge", () => { + const nearStartText = `match ${"tail".repeat(20)}`; + const nearStart = windowPaletteThreadSearchText({ + text: nearStartText, + highlightRanges: [{ start: 0, end: 5 }], + }); + expect(nearStart.text.startsWith("…")).toBe(false); + expect(nearStart.text.endsWith("…")).toBe(true); + + const nearEndText = `${"lead".repeat(20)} match`; + const matchStart = nearEndText.indexOf("match"); + const nearEnd = windowPaletteThreadSearchText({ + text: nearEndText, + highlightRanges: [{ start: matchStart, end: nearEndText.length }], + }); + expect(nearEnd.text.startsWith("…")).toBe(true); + expect(nearEnd.text.endsWith("…")).toBe(false); + }); + + it("never leaves either hard cut inside an emoji surrogate pair", () => { + const emoji = "\u{1f600}"; + const text = `${"a".repeat(15)}${emoji}${"b".repeat(15)}MATCH${"c".repeat( + 39, + )}${emoji}${"d".repeat(20)}`; + const matchStart = text.indexOf("MATCH"); + const result = windowPaletteThreadSearchText({ + text, + highlightRanges: [{ start: matchStart, end: matchStart + 5 }], + }); + + expect(result.text).not.toMatch(/[\uD800-\uDFFF]/u); + expect(highlightedText(result)).toEqual(["MATCH"]); + }); + + it("keeps a first match longer than the normal tail window in full", () => { + const match = "m".repeat(60); + const text = `${"lead".repeat(10)}${match}${"tail".repeat(20)}`; + const matchStart = text.indexOf(match); + const result = windowPaletteThreadSearchText({ + text, + highlightRanges: [ + { start: matchStart, end: matchStart + match.length }, + ], + }); + + expect(highlightedText(result)).toEqual([match]); + }); +}); diff --git a/apps/app/src/lib/command-palette/palette-thread-search-window.ts b/apps/app/src/lib/command-palette/palette-thread-search-window.ts new file mode 100644 index 0000000000..3b84e7d20b --- /dev/null +++ b/apps/app/src/lib/command-palette/palette-thread-search-window.ts @@ -0,0 +1,175 @@ +import type { ThreadSearchHighlightRange } from "@bb/server-contract"; + +const THREAD_SEARCH_WINDOW_LEAD_CHARS = 16; +const THREAD_SEARCH_WINDOW_TAIL_CHARS = 40; +const THREAD_SEARCH_WINDOW_ELLIPSIS = "…"; + +export interface WindowPaletteThreadSearchTextArgs { + text: string; + highlightRanges: readonly ThreadSearchHighlightRange[]; +} + +export interface WindowedPaletteThreadSearchText { + text: string; + highlightRanges: ThreadSearchHighlightRange[]; +} + +function isHighSurrogate(text: string, index: number): boolean { + const code = text.charCodeAt(index); + return code >= 0xd800 && code <= 0xdbff; +} + +function isLowSurrogate(text: string, index: number): boolean { + const code = text.charCodeAt(index); + return code >= 0xdc00 && code <= 0xdfff; +} + +function isInsideSurrogatePair(text: string, index: number): boolean { + return ( + index > 0 && + index < text.length && + isHighSurrogate(text, index - 1) && + isLowSurrogate(text, index) + ); +} + +function clampInteger(value: number, maximum: number): number | null { + if (!Number.isFinite(value)) { + return null; + } + return Math.max(0, Math.min(Math.trunc(value), maximum)); +} + +function normalizeHighlightRanges( + text: string, + ranges: readonly ThreadSearchHighlightRange[], +): ThreadSearchHighlightRange[] { + const normalized: ThreadSearchHighlightRange[] = []; + + for (const range of ranges) { + let start = clampInteger(range.start, text.length); + let end = clampInteger(range.end, text.length); + if (start === null || end === null || end <= start) { + continue; + } + + // Malformed input can point into an astral character. Expand the highlight + // to the complete code point so consumers never receive a slicing boundary + // between its high and low surrogates. + if (isInsideSurrogatePair(text, start)) { + start -= 1; + } + if (isInsideSurrogatePair(text, end)) { + end += 1; + } + + normalized.push({ start, end }); + } + + normalized.sort( + (left, right) => left.start - right.start || left.end - right.end, + ); + + const merged: ThreadSearchHighlightRange[] = []; + for (const range of normalized) { + const previous = merged.at(-1); + if (previous !== undefined && range.start < previous.end) { + previous.end = Math.max(previous.end, range.end); + continue; + } + merged.push({ ...range }); + } + return merged; +} + +function moveStartToWordBoundary( + text: string, + start: number, + firstMatchStart: number, +): number { + if (start === 0) { + return start; + } + const firstWhitespace = text.slice(start, firstMatchStart).search(/\s/u); + if (firstWhitespace === -1) { + return start; + } + + let boundary = start + firstWhitespace + 1; + while (boundary < firstMatchStart && /\s/u.test(text[boundary] ?? "")) { + boundary += 1; + } + return boundary; +} + +function moveEndToWordBoundary( + text: string, + firstMatchEnd: number, + end: number, +): number { + if (end === text.length) { + return end; + } + const lastWhitespace = text + .slice(firstMatchEnd, end) + .search(/\s\S*$/u); + return lastWhitespace > 0 ? firstMatchEnd + lastWhitespace : end; +} + +/** + * Builds the palette's compact display window around the first valid match. + * The caller decides whether its one-line clamp would hide that match; calling + * this helper explicitly requests the 16-character lead and 40-character tail. + */ +export function windowPaletteThreadSearchText({ + text, + highlightRanges, +}: WindowPaletteThreadSearchTextArgs): WindowedPaletteThreadSearchText { + const normalizedRanges = normalizeHighlightRanges(text, highlightRanges); + const firstMatch = normalizedRanges[0]; + if (firstMatch === undefined) { + return { text, highlightRanges: [] }; + } + + let start = Math.max( + 0, + firstMatch.start - THREAD_SEARCH_WINDOW_LEAD_CHARS, + ); + let end = Math.min( + text.length, + firstMatch.end + THREAD_SEARCH_WINDOW_TAIL_CHARS, + ); + + start = moveStartToWordBoundary(text, start, firstMatch.start); + end = moveEndToWordBoundary(text, firstMatch.end, end); + + // Prefer dropping the astral character straddling a hard window boundary to + // returning either half of it. Word-boundary moves already land safely. + if (isInsideSurrogatePair(text, start)) { + start += 1; + } + if (isInsideSurrogatePair(text, end)) { + end -= 1; + } + + const prefix = start > 0 ? THREAD_SEARCH_WINDOW_ELLIPSIS : ""; + const suffix = end < text.length ? THREAD_SEARCH_WINDOW_ELLIPSIS : ""; + const rebasedRanges: ThreadSearchHighlightRange[] = []; + + for (const range of normalizedRanges) { + const rangeStart = Math.max(range.start, start); + const rangeEnd = Math.min(range.end, end); + if (rangeEnd <= rangeStart) { + continue; + } + rebasedRanges.push({ + start: rangeStart - start + prefix.length, + end: rangeEnd - start + prefix.length, + }); + } + + return { + text: `${prefix}${text.slice(start, end)}${suffix}`, + highlightRanges: rebasedRanges, + }; +} diff --git a/apps/app/src/lib/command-palette/palette-thread-search.test.ts b/apps/app/src/lib/command-palette/palette-thread-search.test.ts new file mode 100644 index 0000000000..a6fc2f9f20 --- /dev/null +++ b/apps/app/src/lib/command-palette/palette-thread-search.test.ts @@ -0,0 +1,169 @@ +import { emptyPromptDraftState } from "@bb/client-core"; +import type { ThreadListEntry } from "@bb/domain"; +import type { ThreadSearchResponse } from "@bb/server-contract"; +import { describe, expect, it, vi } from "vitest"; +import type { NewThreadDraftRow } from "@/hooks/useNewThreadDraftSlots"; +import { buildPaletteThreadSearchRows } from "./palette-thread-search"; + +const NOW = 1_000_000; + +function makeThread( + id: string, + overrides: Partial = {}, +): ThreadListEntry { + return { + id, + projectId: "project-1", + environmentId: null, + providerId: "codex", + title: `Title ${id}`, + titleFallback: `Fallback ${id}`, + sectionId: null, + status: "idle", + parentThreadId: null, + sourceThreadId: null, + originKind: null, + originPluginId: null, + visibility: "visible", + archivedAt: null, + pinnedAt: null, + pinSortKey: null, + deletedAt: null, + lastReadAt: null, + latestAttentionAt: 1, + createdAt: 1, + updatedAt: NOW, + activity: { + activeWorkflowCount: 0, + activeBackgroundAgentCount: 0, + activeBackgroundCommandCount: 0, + activePlanModeCount: 0, + activeGoalCount: 0, + }, + hasPendingInteraction: false, + environmentHostId: null, + environmentName: null, + environmentBranchName: null, + environmentWorkspaceDisplayKind: "other", + runtime: { displayStatus: "idle", hostReconnectGraceExpiresAt: null }, + ...overrides, + }; +} + +function makeDraft(id: string, title: string): NewThreadDraftRow { + return { + id, + title, + draft: { ...emptyPromptDraftState(), text: title }, + lastEditedAt: NOW, + destination: { projectId: "project-1", sectionId: null }, + delete: vi.fn(), + }; +} + +function build( + overrides: Partial[0]> = {}, +) { + return buildPaletteThreadSearchRows({ + drafts: [], + now: NOW, + projectNamesById: new Map([["project-1", "Palette project"]]), + query: "match", + recentThreads: [], + scope: "all", + searchResponse: { + active: { results: [], total: 0 }, + archived: { results: [], total: 0 }, + }, + searchResultsAreCurrent: true, + ...overrides, + }); +} + +describe("buildPaletteThreadSearchRows", () => { + it("builds one active, draft, archived list with a local draft total", () => { + const active = makeThread("active"); + const archived = makeThread("archived", { archivedAt: NOW - 1 }); + const searchResponse: ThreadSearchResponse = { + active: { + total: 1, + results: [{ thread: active, matches: [] }], + }, + archived: { + total: 1, + results: [{ thread: archived, matches: [] }], + }, + }; + + const result = build({ + drafts: [makeDraft("draft", "A matching local draft")], + searchResponse, + }); + + expect(result.rows.map((row) => row.lifecycle)).toEqual([ + "active", + "draft", + "archived", + ]); + expect(result.draftMatchCount).toBe(1); + }); + + it("narrows lifecycle immediately without changing row anatomy", () => { + const result = build({ + drafts: [makeDraft("draft", "A matching local draft")], + scope: "draft", + }); + + expect(result.rows).toMatchObject([ + { + lifecycle: "draft", + metadataText: "Palette project · just now", + projectId: "project-1", + draftSlotId: "draft", + }, + ]); + }); + + it("uses the matched message as primary while retaining title, project, and time metadata", () => { + const thread = makeThread("message", { title: "Original title" }); + const result = build({ + searchResponse: { + active: { + total: 1, + results: [ + { + thread, + matches: [ + { + sourceKind: "user_message", + text: "the matching message", + highlightRanges: [{ start: 4, end: 12 }], + sourceSeq: 42, + }, + ], + }, + ], + }, + archived: { results: [], total: 0 }, + }, + }); + + expect(result.rows[0]).toMatchObject({ + primaryText: "the matching message", + metadataText: "Original title · Palette project · just now", + messageSeq: 42, + }); + }); + + it("labels only an empty query as recent and does not reuse recents for a one-character query", () => { + const recent = makeThread("recent"); + expect(build({ query: "", recentThreads: [recent] })).toMatchObject({ + isRecent: true, + rows: [{ id: "active:recent" }], + }); + expect(build({ query: "m", recentThreads: [recent] })).toMatchObject({ + isRecent: false, + rows: [], + }); + }); +}); diff --git a/apps/app/src/lib/command-palette/palette-thread-search.ts b/apps/app/src/lib/command-palette/palette-thread-search.ts new file mode 100644 index 0000000000..f89c4d04ea --- /dev/null +++ b/apps/app/src/lib/command-palette/palette-thread-search.ts @@ -0,0 +1,209 @@ +import { PERSONAL_PROJECT_ID, type ThreadListEntry } from "@bb/domain"; +import { fuzzyMatchText } from "@bb/fuzzy-match"; +import type { + ThreadSearchHighlightRange, + ThreadSearchMatch, + ThreadSearchResponse, +} from "@bb/server-contract"; +import type { NewThreadDraftRow } from "@/hooks/useNewThreadDraftSlots"; +import { formatRelativeTime } from "@/lib/relative-time"; +import { getThreadDisplayTitle } from "@/lib/thread-title"; + +export const PALETTE_THREAD_SEARCH_SCOPES = [ + { id: "all", label: "All" }, + { id: "active", label: "Active" }, + { id: "draft", label: "Drafts" }, + { id: "archived", label: "Archived" }, +] as const; + +export type PaletteThreadSearchScope = + (typeof PALETTE_THREAD_SEARCH_SCOPES)[number]["id"]; +export type PaletteThreadLifecycle = "active" | "draft" | "archived"; + +export interface PaletteThreadSearchRow { + id: string; + lifecycle: PaletteThreadLifecycle; + primaryText: string; + highlightRanges: readonly ThreadSearchHighlightRange[]; + metadataText: string; + projectId: string; + threadId: string | null; + draftSlotId: string | null; + messageSeq: number | null; +} + +interface BuildPaletteThreadSearchRowsArgs { + drafts: readonly NewThreadDraftRow[]; + now: number; + projectNamesById: ReadonlyMap; + query: string; + recentThreads: readonly ThreadListEntry[]; + scope: PaletteThreadSearchScope; + searchResponse: ThreadSearchResponse | undefined; + searchResultsAreCurrent: boolean; +} + +export interface PaletteThreadSearchRowsResult { + draftMatchCount: number; + isRecent: boolean; + rows: PaletteThreadSearchRow[]; +} + +const RECENT_THREAD_LIMIT = 20; + +function isTitleMatch(match: ThreadSearchMatch): boolean { + return match.sourceKind === "title" || match.sourceKind === "title_fallback"; +} + +function projectMetadata( + projectId: string, + projectNamesById: ReadonlyMap, +): string | null { + return projectId === PERSONAL_PROJECT_ID + ? null + : (projectNamesById.get(projectId) ?? null); +} + +function metadataText(parts: readonly (string | null)[]): string { + return parts.filter((part): part is string => Boolean(part)).join(" · "); +} + +function serverRow( + thread: ThreadListEntry, + matches: readonly ThreadSearchMatch[], + lifecycle: "active" | "archived", + projectNamesById: ReadonlyMap, + now: number, +): PaletteThreadSearchRow { + const title = getThreadDisplayTitle(thread); + const titleMatch = matches.find( + (match) => isTitleMatch(match) && match.text === title, + ); + const snippetMatch = matches.find((match) => !isTitleMatch(match)); + const primaryMatch = snippetMatch ?? titleMatch; + return { + id: `${lifecycle}:${thread.id}`, + lifecycle, + primaryText: primaryMatch?.text ?? title, + highlightRanges: primaryMatch?.highlightRanges ?? [], + metadataText: metadataText([ + snippetMatch === undefined ? null : title, + projectMetadata(thread.projectId, projectNamesById), + formatRelativeTime({ timestamp: thread.updatedAt, now }), + ]), + projectId: thread.projectId, + threadId: thread.id, + draftSlotId: null, + messageSeq: snippetMatch?.sourceSeq ?? null, + }; +} + +function draftHighlightRanges( + text: string, + positions: readonly number[], +): ThreadSearchHighlightRange[] { + const offsets = [0]; + for (const character of text) { + offsets.push((offsets.at(-1) ?? 0) + character.length); + } + const ranges: ThreadSearchHighlightRange[] = []; + for (const position of [...positions].sort((left, right) => left - right)) { + const start = offsets[position]; + const end = offsets[position + 1]; + if (start === undefined || end === undefined) continue; + const prior = ranges.at(-1); + if (prior !== undefined && prior.end === start) { + prior.end = end; + } else { + ranges.push({ start, end }); + } + } + return ranges; +} + +function includesLifecycle( + scope: PaletteThreadSearchScope, + lifecycle: PaletteThreadLifecycle, +): boolean { + return scope === "all" || scope === lifecycle; +} + +/** + * Produces the one mode list. Lifecycle rank is structural, so relevance from + * one lifecycle can never move archived content ahead of an active result. + */ +export function buildPaletteThreadSearchRows({ + drafts, + now, + projectNamesById, + query, + recentThreads, + scope, + searchResponse, + searchResultsAreCurrent, +}: BuildPaletteThreadSearchRowsArgs): PaletteThreadSearchRowsResult { + const trimmedQuery = query.trim(); + const isRecent = trimmedQuery.length === 0; + const isSearchable = trimmedQuery.length >= 2; + const activeRows = isRecent + ? recentThreads + .slice(0, RECENT_THREAD_LIMIT) + .map((thread) => serverRow(thread, [], "active", projectNamesById, now)) + : isSearchable && searchResultsAreCurrent + ? (searchResponse?.active.results ?? []).map((result) => + serverRow( + result.thread, + result.matches, + "active", + projectNamesById, + now, + ), + ) + : []; + + const draftMatches = + isRecent || isSearchable + ? fuzzyMatchText({ + items: drafts, + query: trimmedQuery, + getText: (draft) => draft.title, + limit: drafts.length, + }) + : []; + const draftRows = draftMatches.map(({ item, positions }) => ({ + id: `draft:${item.id}`, + lifecycle: "draft" as const, + primaryText: item.title, + highlightRanges: draftHighlightRanges(item.title, positions), + metadataText: metadataText([ + projectMetadata(item.destination.projectId, projectNamesById), + formatRelativeTime({ timestamp: item.lastEditedAt, now }), + ]), + projectId: item.destination.projectId, + threadId: null, + draftSlotId: item.id, + messageSeq: null, + })); + const archivedRows = + isSearchable && searchResultsAreCurrent + ? (searchResponse?.archived.results ?? []).map((result) => + serverRow( + result.thread, + result.matches, + "archived", + projectNamesById, + now, + ), + ) + : []; + + return { + draftMatchCount: draftMatches.length, + isRecent, + rows: [ + ...(includesLifecycle(scope, "active") ? activeRows : []), + ...(includesLifecycle(scope, "draft") ? draftRows : []), + ...(includesLifecycle(scope, "archived") ? archivedRows : []), + ], + }; +} diff --git a/apps/app/src/lib/split-layout/openThreadInSplit.test.ts b/apps/app/src/lib/split-layout/openThreadInSplit.test.ts new file mode 100644 index 0000000000..46534055d3 --- /dev/null +++ b/apps/app/src/lib/split-layout/openThreadInSplit.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from "vitest"; +import { openThreadInSplit } from "./openThreadInSplit"; +import type { SplitLayout } from "./index"; + +function layout(threadId = "thread-1"): SplitLayout { + return { + root: { + type: "pane", + paneId: "pane-1", + content: { kind: "thread", projectId: "project-1", threadId }, + }, + focusedPaneId: "pane-1", + }; +} + +describe("openThreadInSplit", () => { + it("preserves search deep-link state when creating a split", () => { + let current = layout(); + const navigate = vi.fn(); + openThreadInSplit({ + store: { + get: () => current, + set: (_atom, value) => { + current = value; + }, + }, + navigate, + projectId: "project-1", + threadId: "thread-2", + isCompact: false, + state: { searchMessageSeq: 42, searchThreadId: "thread-2" }, + }); + + expect(current.root.type).toBe("split"); + expect(navigate).toHaveBeenCalledWith( + "/projects/project-1/threads/thread-2", + { + state: { searchMessageSeq: 42, searchThreadId: "thread-2" }, + }, + ); + }); + + it("keeps replace semantics and state when the result is already open", () => { + const current = layout("thread-2"); + const navigate = vi.fn(); + openThreadInSplit({ + store: { get: () => current, set: vi.fn() }, + navigate, + projectId: "project-1", + threadId: "thread-2", + isCompact: false, + state: { searchMessageSeq: 7, searchThreadId: "thread-2" }, + }); + + expect(navigate).toHaveBeenCalledWith( + "/projects/project-1/threads/thread-2", + { + replace: true, + state: { searchMessageSeq: 7, searchThreadId: "thread-2" }, + }, + ); + }); +}); diff --git a/apps/app/src/lib/split-layout/openThreadInSplit.ts b/apps/app/src/lib/split-layout/openThreadInSplit.ts index f7d17f1dd6..f03f91bb3b 100644 --- a/apps/app/src/lib/split-layout/openThreadInSplit.ts +++ b/apps/app/src/lib/split-layout/openThreadInSplit.ts @@ -19,11 +19,16 @@ interface SplitLayoutStore { interface OpenThreadInSplitArgs { store: SplitLayoutStore; - navigate: (route: string, options?: { replace?: boolean }) => void; + navigate: ( + route: string, + options?: { replace?: boolean; state?: Record }, + ) => void; projectId: string; threadId: string; /** Splits are off on compact viewports. */ isCompact: boolean; + /** Optional route state, such as a search-result message deep link. */ + state?: Record; } /** @@ -41,13 +46,14 @@ export function openThreadInSplit({ projectId, threadId, isCompact, + state, }: OpenThreadInSplitArgs): void { const route = getThreadRoutePath({ projectId, threadId }); const layout = store.get(splitLayoutAtom); // No split to grow (compact viewport, or a non-thread route with no layout): // behave like an ordinary open. if (isCompact || layout === null) { - navigate(route); + navigate(route, state === undefined ? undefined : { state }); return; } const existing = findPaneByThread(layout.root, projectId, threadId); @@ -56,7 +62,10 @@ export function openThreadInSplit({ if (next !== layout) { store.set(splitLayoutAtom, next); } - navigate(route, { replace: true }); + navigate(route, { + replace: true, + ...(state === undefined ? {} : { state }), + }); return; } // Same decision as a drag with a default right-edge target. @@ -73,5 +82,5 @@ export function openThreadInSplit({ if (next !== layout) { store.set(splitLayoutAtom, next); } - navigate(route); + navigate(route, state === undefined ? undefined : { state }); } From 3d29b5a0bb93aabb096f15915220facc839c8320 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Wed, 26 Aug 2026 11:24:34 +0000 Subject: [PATCH 3/6] Remove sidebar thread search --- apps/app/src/app.css | 6 +- .../layout/app-chrome-selection.test.tsx | 4 +- .../app/src/components/sidebar/AppSidebar.tsx | 99 +--- .../src/components/sidebar/ProjectList.tsx | 193 ++----- .../sidebar/SidebarOverview.stories.tsx | 168 +----- .../sidebar/SidebarThreadSearchPanel.test.tsx | 522 ------------------ .../sidebar/SidebarThreadSearchPanel.tsx | 340 ------------ .../sidebar/ThreadSearchResultRow.stories.tsx | 180 ------ .../sidebar/ThreadSearchResultRow.tsx | 234 -------- .../components/sidebar/sidebarThreadSearch.ts | 79 --- .../sidebar/useSidebarThreadSearch.test.tsx | 183 ------ .../sidebar/useSidebarThreadSearch.ts | 203 ------- apps/app/src/lib/app-command-metadata.ts | 2 +- plans/bb-mobile-research/ui-inventory.md | 4 +- 14 files changed, 48 insertions(+), 2169 deletions(-) delete mode 100644 apps/app/src/components/sidebar/SidebarThreadSearchPanel.test.tsx delete mode 100644 apps/app/src/components/sidebar/SidebarThreadSearchPanel.tsx delete mode 100644 apps/app/src/components/sidebar/ThreadSearchResultRow.stories.tsx delete mode 100644 apps/app/src/components/sidebar/ThreadSearchResultRow.tsx delete mode 100644 apps/app/src/components/sidebar/sidebarThreadSearch.ts delete mode 100644 apps/app/src/components/sidebar/useSidebarThreadSearch.test.tsx delete mode 100644 apps/app/src/components/sidebar/useSidebarThreadSearch.ts diff --git a/apps/app/src/app.css b/apps/app/src/app.css index a56cdfbad3..14b57c072b 100644 --- a/apps/app/src/app.css +++ b/apps/app/src/app.css @@ -59,9 +59,9 @@ * App chrome (sidebar, page headers, composer toolbars) opts out of text * selection with `select-none` so drags and Select All only pick up * content. `user-select: auto` resolves from the parent, so WebKit would - * carry that opt-out into editable controls inside those regions (the - * sidebar thread search, the inline thread-title rename) and refuse to - * select their text. Restore native selection on the controls themselves. + * carry that opt-out into editable controls inside those regions (for + * example the inline thread-title rename) and refuse to select their text. + * Restore native selection on the controls themselves. */ .select-none :where(input, textarea, [contenteditable]:not([contenteditable="false"])) { diff --git a/apps/app/src/components/layout/app-chrome-selection.test.tsx b/apps/app/src/components/layout/app-chrome-selection.test.tsx index 1e1f4ec2bb..44d03b2318 100644 --- a/apps/app/src/components/layout/app-chrome-selection.test.tsx +++ b/apps/app/src/components/layout/app-chrome-selection.test.tsx @@ -81,8 +81,8 @@ describe("app chrome opts out of text selection", () => { it("restores native selection on editable controls inside opted-out chrome", () => { // `user-select: auto` resolves from the parent, so without this rule - // WebKit would refuse to select text in the sidebar thread search and the - // inline thread-title rename input. + // WebKit would refuse to select text in editable app-chrome controls such + // as the inline thread-title rename input. const css = readFileSync( join(dirname(fileURLToPath(import.meta.url)), "../../app.css"), "utf8", diff --git a/apps/app/src/components/sidebar/AppSidebar.tsx b/apps/app/src/components/sidebar/AppSidebar.tsx index 5824b3d9d0..d906406b7f 100644 --- a/apps/app/src/components/sidebar/AppSidebar.tsx +++ b/apps/app/src/components/sidebar/AppSidebar.tsx @@ -2,7 +2,6 @@ import { Fragment, useCallback, useEffect, - useMemo, useRef, useState, type ReactNode, @@ -13,7 +12,6 @@ import { THREAD_JUMP_APP_COMMAND_IDS } from "@bb/domain"; import { Link, useNavigate } from "react-router-dom"; import { Icon } from "@bb/shared-ui/icon"; import { COARSE_POINTER_CHILD_ICON_BUTTON_CLASS } from "@bb/shared-ui/coarse-pointer-sizing"; -import { usePointerCoarse } from "@bb/shared-ui/hooks/use-pointer-coarse"; import { OverflowFade } from "@/components/ui/overflow-fade.js"; import { Sidebar, @@ -23,7 +21,6 @@ import { SidebarMenuButton, SidebarMenuItem, useCloseMobileSidebar, - useSidebar, } from "@/components/ui/sidebar.js"; import { ProjectList, ProjectListActionButtons } from "./ProjectList"; import { PluginThreadList } from "./PluginThreadList"; @@ -48,8 +45,6 @@ import { getRootComposeRoutePath, getThreadRoutePath } from "@/lib/route-paths"; import { usePaneContentSplitDrag } from "./usePaneContentSplitDrag"; import { createNewThreadDraftSlotId } from "@/lib/prompt-draft-slots"; import { openUrlInExternalBrowser } from "@/lib/url-open-routing"; -import type { SidebarThreadSearchNavigationItem } from "./sidebarThreadSearch"; -import { useSidebarThreadSearch } from "./useSidebarThreadSearch"; import { EMPTY_SIDEBAR_THREAD_SHORTCUT_KEYS, getSidebarThreadNavigationTargets, @@ -154,8 +149,8 @@ export function AppSidebar({ }: AppSidebarProps) { const quickCreateProject = useQuickCreateProjectController(); // The resolved replacement owns the sidebar's scrolling thread list. It never - // replaces the chrome around it: the New-thread button, search field, - // the plugin nav rows, and the footer stay host-rendered in every sidebar. + // replaces the chrome around it: the New-thread button, plugin nav rows, and + // footer stay host-rendered in every sidebar. const threadListReplacement = useThreadListReplacement(); const { threadId: activeThreadId } = useRouteState(); const navigate = useNavigate(); @@ -172,7 +167,6 @@ export function AppSidebar({ label: "New thread", }); const closeOnMobile = useCloseMobileSidebar(); - const { isCompactViewport, setOpen, setOpenMobile } = useSidebar(); const [desktopInfo] = useState(getBbDesktopInfo); const [threadShortcutKeysById, setThreadShortcutKeysById] = useState< ReadonlyMap @@ -181,7 +175,6 @@ export function AppSidebar({ const threadShortcutTargetsRef = useRef< readonly SidebarThreadShortcutTarget[] >([]); - const isPointerCoarse = usePointerCoarse(); const usesDesktopChrome = shouldUseMacosDesktopChrome(desktopInfo); const threadJumpShortcuts = useAppCommandShortcuts( THREAD_JUMP_APP_COMMAND_IDS, @@ -194,44 +187,6 @@ export function AppSidebar({ ); const pluginNavPanels = usePluginNavPanelChrome(); - const openSidebarForThreadSearch = useCallback(() => { - if (isCompactViewport) { - setOpenMobile(true); - } else { - setOpen(true); - } - }, [isCompactViewport, setOpen, setOpenMobile]); - - const openSearchedThread = useCallback( - (item: SidebarThreadSearchNavigationItem) => { - void navigate( - getThreadRoutePath({ - projectId: item.projectId, - threadId: item.threadId, - }), - // Hand the matched message's event sequence to the timeline so it can - // scroll to and briefly highlight that message. Omitted for title-only - // matches, which just open the thread normally. - item.messageSeq !== null - ? { - state: { - searchMessageSeq: item.messageSeq, - searchThreadId: item.threadId, - }, - } - : undefined, - ); - }, - [navigate], - ); - - const threadSearch = useSidebarThreadSearch({ - isPointerCoarse, - onOpenSidebar: openSidebarForThreadSearch, - onOpenThread: openSearchedThread, - onThreadOpened: closeOnMobile, - }); - const handleNewChat = useCallback(() => { closeOnMobile(); void navigate(getRootComposeRoutePath(), { @@ -307,14 +262,8 @@ export function AppSidebar({ // While hosted-and-hidden (a Settings/Tools body is showing in the drawer) // this sidebar is not the visible one: leave its shortcuts unhandled, as // they are on wide viewports where Settings/Tools replace the sidebar, - // rather than opening the drawer onto a hidden search field or clicking - // rows the user cannot see. + // rather than clicking rows the user cannot see. const isHiddenHostedBody = mobileHosted?.hidden === true; - useAppCommandHandler("thread.search", () => { - if (isHiddenHostedBody) return false; - threadSearch.onActivate(); - return true; - }); const activateVisibleThreadShortcut = useCallback( (index: number) => isHiddenHostedBody ? false : activateThreadShortcut(index), @@ -339,29 +288,6 @@ export function AppSidebar({ hideThreadShortcuts(); }, [hideThreadShortcuts, isAppCommandModifierHeld, showThreadShortcuts]); - // Keep this object identity stable across unrelated re-renders (opening - // the mobile drawer flips useSidebar context and re-renders AppSidebar): - // a fresh object here would defeat ProjectList's memo and re-render every - // thread group on each drawer toggle. - const threadSearchPanelController = useMemo( - () => ({ - activeIndex: threadSearch.activeIndex, - isActive: threadSearch.isActive, - onActiveIndexChange: threadSearch.onActiveIndexChange, - onNavigationItemsChange: threadSearch.onNavigationItemsChange, - onSelectItem: threadSearch.onSelectItem, - query: threadSearch.query, - }), - [ - threadSearch.activeIndex, - threadSearch.isActive, - threadSearch.onActiveIndexChange, - threadSearch.onNavigationItemsChange, - threadSearch.onSelectItem, - threadSearch.query, - ], - ); - const originalThreadList = ( ); @@ -421,15 +346,6 @@ export function AppSidebar({ splitEnabled newThreadSplit={newThreadSplit} onNewChat={handleNewChat} - threadSearch={{ - activeDescendantId: threadSearch.activeDescendantId, - inputRef: threadSearch.inputRef, - isActive: threadSearch.isActive, - onActivate: threadSearch.onActivate, - onClose: threadSearch.onClose, - onQueryChange: threadSearch.onQueryChange, - query: threadSearch.query, - }} /> {toolsRoutePath ? ( ), @@ -539,14 +455,11 @@ export function AppSidebar({ data-testid="app-sidebar-body" hidden={mobileHosted.hidden} className="flex min-h-0 flex-1 flex-col" - onKeyDown={threadSearch.onKeyDown} > {body} ) : ( - - {body} - + {body} )} ); diff --git a/apps/app/src/components/sidebar/ProjectList.tsx b/apps/app/src/components/sidebar/ProjectList.tsx index 4aab103b0e..a34d0b9e91 100644 --- a/apps/app/src/components/sidebar/ProjectList.tsx +++ b/apps/app/src/components/sidebar/ProjectList.tsx @@ -74,17 +74,14 @@ import { SidebarStickyStack, } from "@/components/ui/sidebar.js"; import { - COARSE_POINTER_COMPACT_ICON_SIZE_CLASS, COARSE_POINTER_ICON_SIZE_CLASS, COARSE_POINTER_ROW_ACTION_SIZE_CLASS, COARSE_POINTER_ROW_HEIGHT_CLASS, - COARSE_POINTER_TEXT_SM_CLASS, } from "@bb/shared-ui/coarse-pointer-sizing"; import { ChronologicalSectionThreadSections, ProjectThreadTree, } from "./ProjectRow"; -import { SidebarThreadSearchPanel } from "./SidebarThreadSearchPanel"; import { SidebarArchivedThreadGroup, SidebarDraftRows, @@ -144,18 +141,12 @@ import { } from "@bb/shared-ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@bb/shared-ui/tooltip"; import { - SIDEBAR_LEADING_GLYPH_SLOT_CLASS, SIDEBAR_ROW_BASE_CLASS, SIDEBAR_ROW_INTERACTIVE_STATE_CLASS, SIDEBAR_STANDARD_ROW_PADDING_CLASS, } from "./sidebarRowClasses"; import { TopLevelSidebarSection } from "./TopLevelSidebarSection"; export { TopLevelSidebarSection }; -import { - SIDEBAR_THREAD_SEARCH_LISTBOX_ID, - type SidebarThreadSearchInputController, - type SidebarThreadSearchPanelController, -} from "./sidebarThreadSearch"; import { useAppCommandShortcut } from "@/components/commands/AppCommandProvider"; import { useNewThreadSplitIndicator } from "./paneContentSplitIndicator"; import { SplitPaneMiniMap } from "./SplitPaneMiniMap"; @@ -193,7 +184,6 @@ interface ProjectListProps { onNewProject?: () => void; onProjectSelect?: () => void; isCreatingProject?: boolean; - threadSearch?: SidebarThreadSearchPanelController; } interface ProjectListActionButtonsProps { @@ -203,7 +193,6 @@ interface ProjectListActionButtonsProps { openInSplit(): void; }; onNewChat?: () => void; - threadSearch?: SidebarThreadSearchInputController; } interface ProjectListShellProps { @@ -255,26 +244,6 @@ export const PROJECT_LIST_ACTION_BUTTON_CLASS = cn( "min-w-0 cursor-pointer justify-start overflow-hidden font-normal ring-sidebar-ring focus-visible:ring-2 disabled:cursor-default disabled:opacity-70 max-md:pointer-coarse:[&_svg]:size-5", ); -const PROJECT_LIST_ACTION_ICON_BUTTON_CLASS = cn( - "inline-flex shrink-0 cursor-pointer items-center justify-center rounded-md text-sidebar-foreground/85 outline-none ring-sidebar-ring transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 disabled:cursor-default disabled:opacity-50", - COARSE_POINTER_ROW_ACTION_SIZE_CLASS, -); - -const PROJECT_LIST_SEARCH_INPUT_ROW_CLASS = cn( - SIDEBAR_ROW_BASE_CLASS, - SIDEBAR_STANDARD_ROW_PADDING_CLASS, - COARSE_POINTER_ROW_HEIGHT_CLASS, - "min-w-0 overflow-hidden bg-sidebar-accent pr-1 font-normal text-sidebar-foreground shadow-[0_0_0_1px_var(--sidebar-accent)] transition-shadow focus-within:shadow-[0_0_0_1px_var(--sidebar-border)]", -); - -const PROJECT_LIST_SEARCH_INPUT_CLASS = cn( - "min-w-0 flex-1 bg-transparent outline-none placeholder:text-muted-foreground", - COARSE_POINTER_TEXT_SM_CLASS, -); - -const PROJECT_LIST_SEARCH_CLOSE_BUTTON_CLASS = - "h-6 w-6 shrink-0 rounded-md p-0 text-muted-foreground ring-sidebar-ring hover:bg-sidebar-border/60 hover:text-sidebar-foreground focus-visible:ring-2 max-md:pointer-coarse:h-8 max-md:pointer-coarse:w-8"; - const PROJECT_LIST_SECTION_ACTION_BUTTON_CLASS = cn( "inline-flex items-center justify-center rounded-md text-muted-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-foreground focus-visible:ring-2 disabled:opacity-50", LIST_HOVER_TRANSITION, @@ -988,120 +957,46 @@ export function ProjectListActionButtons({ splitEnabled = false, newThreadSplit, onNewChat, - threadSearch, }: ProjectListActionButtonsProps) { const isNewChatDisabled = !onNewChat; const newThreadShortcut = useAppCommandShortcut("thread.new"); - const threadSearchShortcut = useAppCommandShortcut("thread.search"); const newThreadSplitIndicator = useNewThreadSplitIndicator(splitEnabled); - // One click on the X fully dismisses search — it clears the query and closes - // the input in a single step (onClose resets the query too). Previously this - // was a two-step clear-then-close, which felt like the X "needed two presses". - const handleSearchClose = useCallback(() => { - threadSearch?.onClose(); - }, [threadSearch]); return (
- {threadSearch?.isActive ? ( -
- -
- ) : ( -
- - {threadSearch ? ( - - - - ) : null} -
- )} + + +
); } @@ -1681,7 +1576,6 @@ function ProjectListComponent({ onNewProject, onProjectSelect, isCreatingProject = false, - threadSearch, }: ProjectListProps) { const navigate = useNavigate(); const setRootComposeProjectId = useSetRootComposeProjectId(); @@ -1722,15 +1616,13 @@ function ProjectListComponent({ [archivedThreadsQuery.data], ); useEffect(() => { - const rowsAreVisible = showDrafts && threadSearch?.isActive !== true; - setBuiltInDraftRowsVisible(rowsAreVisible); + setBuiltInDraftRowsVisible(showDrafts); return () => setBuiltInDraftRowsVisible(false); - }, [setBuiltInDraftRowsVisible, showDrafts, threadSearch?.isActive]); + }, [setBuiltInDraftRowsVisible, showDrafts]); // Provided once by AppLayout from the same sidebar payload (with value // retention across refetches); building a second copy here re-rendered every // row twice per sidebar update. const titleMentionResources = useThreadTitleMentionResources(); - const { sectionNamesById, projectNamesById } = titleMentionResources; const threadById = useMemo(() => { const map = new Map(); for (const thread of threads) { @@ -2225,25 +2117,6 @@ function ProjectListComponent({ selection: lifecycleSelection, }); - if (threadSearch?.isActive) { - return ( - - - - ); - } - if (projectsState.status === "loading") { return ( diff --git a/apps/app/src/components/sidebar/SidebarOverview.stories.tsx b/apps/app/src/components/sidebar/SidebarOverview.stories.tsx index 8a651df538..79e4442035 100644 --- a/apps/app/src/components/sidebar/SidebarOverview.stories.tsx +++ b/apps/app/src/components/sidebar/SidebarOverview.stories.tsx @@ -2,7 +2,6 @@ import { Suspense, useEffect, useLayoutEffect, - useRef, useState, type ReactNode, } from "react"; @@ -13,10 +12,7 @@ import { } from "@tanstack/react-query"; import { createStore, Provider } from "jotai"; import { PERSONAL_PROJECT_ID } from "@bb/domain"; -import type { - SidebarBootstrapResponse, - ThreadSearchResponse, -} from "@bb/server-contract"; +import type { SidebarBootstrapResponse } from "@bb/server-contract"; import { BRANCH_NAMES, HOST_IDS, @@ -34,14 +30,10 @@ import { ProjectListNavigationLoadingState, ProjectListShell, } from "./ProjectList"; -import { SidebarThreadSearchPanel } from "./SidebarThreadSearchPanel"; -import type { SidebarThreadSearchNavigationItem } from "./sidebarThreadSearch"; import { hostsQueryKey, sidebarNavigationQueryKey, - threadSearchQueryKey, } from "@/hooks/queries/query-keys"; -import { THREAD_SEARCH_LIMIT_PER_GROUP } from "@/hooks/queries/thread-queries"; import { StoryCard, StoryRow } from "../../../.ladle/story-card"; import { ExtensionsNavSidebarItem, @@ -273,78 +265,6 @@ const machineSidebarNavigation = { })), } satisfies SidebarBootstrapResponse; -const searchResponse = { - active: { - total: 2, - results: [ - { - thread: makeThreadListEntry({ - id: "thr_story_search_active", - projectId: bbProject.id, - title: "Search result handoff", - titleFallback: "Search result handoff", - environmentName: "Sidebar polish", - environmentBranchName: BRANCH_NAMES.feature, - environmentWorkspaceDisplayKind: "managed-worktree", - }), - matches: [ - { - sourceKind: "user_message", - text: "needle appears in the original request", - highlightRanges: [{ start: 0, end: 6 }], - sourceSeq: 2, - }, - ], - }, - { - thread: makeThreadListEntry({ - id: "thr_story_search_pending", - projectId: docsProject.id, - title: "Needle follow-up", - titleFallback: "Needle follow-up", - hasPendingInteraction: true, - }), - matches: [ - { - sourceKind: "title", - text: "Needle follow-up", - highlightRanges: [{ start: 0, end: 6 }], - sourceSeq: null, - }, - ], - }, - ], - }, - archived: { - total: 1, - results: [ - { - thread: makeThreadListEntry({ - archivedAt: 220, - id: "thr_story_search_archived", - projectId: bbProject.id, - title: "Archived needle investigation", - titleFallback: "Archived needle investigation", - }), - matches: [ - { - sourceKind: "assistant_message", - text: "The archived thread contains the matching needle.", - highlightRanges: [{ start: 42, end: 48 }], - sourceSeq: 7, - }, - ], - }, - ], - }, -} satisfies ThreadSearchResponse; - -const searchProjectNamesById = new Map([ - [bbProject.id, bbProject.name], - [docsProject.id, docsProject.name], - [PERSONAL_PROJECT_ID, personalProject.name], -]); - function SidebarFrame({ children }: SidebarFrameProps) { return ( @@ -589,89 +509,6 @@ function OrganizationSidebar({ ); } -function SearchSidebar() { - const queryClient = useQueryClient(); - const inputRef = useRef(null); - const [isSeeded, setIsSeeded] = useState(false); - const [activeIndex, setActiveIndex] = useState(0); - const [navigationItems, setNavigationItems] = useState< - readonly SidebarThreadSearchNavigationItem[] - >([]); - - useEffect(() => { - queryClient.setQueryData( - threadSearchQueryKey({ - limitPerGroup: THREAD_SEARCH_LIMIT_PER_GROUP, - query: "needle", - }), - searchResponse, - ); - setIsSeeded(true); - - return () => { - queryClient.removeQueries({ - queryKey: threadSearchQueryKey({ - limitPerGroup: THREAD_SEARCH_LIMIT_PER_GROUP, - query: "needle", - }), - exact: true, - }); - }; - }, [queryClient]); - - if (!isSeeded) { - return ; - } - - return ( - - -
-
- -
-
- - - -
-
- -
-
- {navigationItems.length} search rows -
-
- ); -} - export function Overview() { return ( @@ -685,9 +522,6 @@ export function Overview() { - - - ); } diff --git a/apps/app/src/components/sidebar/SidebarThreadSearchPanel.test.tsx b/apps/app/src/components/sidebar/SidebarThreadSearchPanel.test.tsx deleted file mode 100644 index 9d860943e4..0000000000 --- a/apps/app/src/components/sidebar/SidebarThreadSearchPanel.test.tsx +++ /dev/null @@ -1,522 +0,0 @@ -// @vitest-environment jsdom - -import { createRef } from "react"; -import { cleanup, render, screen } from "@testing-library/react"; -import { createStore, Provider } from "jotai"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { ThreadListEntry } from "@bb/domain"; -import type { - ThreadSearchMatch, - ThreadSearchResponse, -} from "@bb/server-contract"; -import { - useThreadSearch, - type UseThreadSearchResult, -} from "@/hooks/queries/thread-queries"; -import { ProjectListActionButtons } from "./ProjectList"; -import { SidebarThreadSearchPanel } from "./SidebarThreadSearchPanel"; -import { splitLayoutAtom } from "@/lib/split-layout/atoms"; -import { - getSidebarThreadSearchOptionId, - haveSameSidebarThreadSearchNavigationItems, - isThreadSearchKeyboardEventTarget, - type SidebarThreadSearchNavigationItem, -} from "./sidebarThreadSearch"; - -vi.mock("@/hooks/queries/thread-queries", () => ({ - hasThreadSearchableQuery: (value: string) => - value.replace(/\s/g, "").length >= 2, - useThreadSearch: vi.fn(), -})); - -const mockUseThreadSearch = vi.mocked(useThreadSearch); - -function createThreadListEntry({ - sectionId = null, - id, - title, -}: { - sectionId?: string | null; - id: string; - title: string; -}): ThreadListEntry { - return { - activity: { - activeWorkflowCount: 0, - activeBackgroundAgentCount: 0, - activeBackgroundCommandCount: 0, - activePlanModeCount: 0, - activeGoalCount: 0, - }, - archivedAt: null, - createdAt: 1000, - deletedAt: null, - environmentBranchName: null, - environmentHostId: null, - environmentId: null, - environmentName: null, - environmentWorkspaceDisplayKind: "other", - hasPendingInteraction: false, - id, - lastReadAt: null, - latestAttentionAt: 1000, - originKind: null, - originPluginId: null, - visibility: "visible", - parentThreadId: null, - pinSortKey: null, - pinnedAt: null, - projectId: "proj_search", - providerId: "codex", - runtime: { - displayStatus: "idle", - hostReconnectGraceExpiresAt: null, - }, - sourceThreadId: null, - status: "idle", - title, - titleFallback: null, - sectionId, - updatedAt: 1000, - }; -} - -function createSearchResponse( - thread: ThreadListEntry, - matches: readonly ThreadSearchMatch[] = [], -): ThreadSearchResponse { - return { - active: { - results: [ - { - matches: [...matches], - thread, - }, - ], - total: 1, - }, - archived: { - results: [], - total: 0, - }, - }; -} - -function mockThreadSearch(result: UseThreadSearchResult): void { - mockUseThreadSearch.mockReturnValue(result); -} - -afterEach(() => { - cleanup(); - window.localStorage.clear(); - vi.clearAllMocks(); - vi.restoreAllMocks(); -}); - -describe("SidebarThreadSearchPanel", () => { - it("clears stale search rows while the visible query is debouncing", () => { - mockThreadSearch({ - data: createSearchResponse( - createThreadListEntry({ - id: "thr_previous", - title: "Previous needle", - }), - ), - debouncedQuery: "needle", - hasSearchableQuery: true, - isDebouncing: true, - isError: false, - isFetching: false, - isLoading: false, - }); - - render( - , - ); - - expect(screen.getByText("Searching threads...")).not.toBeNull(); - expect(screen.queryByRole("option")).toBeNull(); - }); - - it("uses a stable option id and scrolls the active search row into view", () => { - const scrollIntoView = vi.spyOn(Element.prototype, "scrollIntoView"); - const thread = createThreadListEntry({ - id: "thr_current", - title: "Current needle", - }); - const optionId = getSidebarThreadSearchOptionId("active:thr_current"); - mockThreadSearch({ - data: createSearchResponse(thread), - debouncedQuery: "needle", - hasSearchableQuery: true, - isDebouncing: false, - isError: false, - isFetching: false, - isLoading: false, - }); - - render( - , - ); - - expect(screen.getByRole("option").id).toBe(optionId); - expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest" }); - }); - - it("uses shared runtime precedence for search results", () => { - const thread = createThreadListEntry({ - id: "thr_plan_goal", - title: "Concurrent Plan and Goal", - }); - thread.status = "active"; - thread.runtime = { - displayStatus: "active", - hostReconnectGraceExpiresAt: null, - }; - thread.activity = { - ...thread.activity, - activePlanModeCount: 1, - activeGoalCount: 1, - }; - mockThreadSearch({ - data: createSearchResponse(thread), - debouncedQuery: "plan", - hasSearchableQuery: true, - isDebouncing: false, - isError: false, - isFetching: false, - isLoading: false, - }); - - render( - , - ); - - expect(screen.getByLabelText("Plan mode active")).not.toBeNull(); - expect(screen.queryByLabelText("Thread working")).toBeNull(); - expect(screen.queryByLabelText("Goal active")).toBeNull(); - }); - - it("subscribes search results to working draft state", () => { - const thread = createThreadListEntry({ - id: "thr_search_draft", - title: "Working draft", - }); - thread.activity = { ...thread.activity, activePlanModeCount: 1 }; - window.localStorage.setItem( - "bb.promptbox.contents-proj_search-thr_search_draft-3", - JSON.stringify({ text: "Keep editing", attachments: [] }), - ); - mockThreadSearch({ - data: createSearchResponse(thread), - debouncedQuery: "draft", - hasSearchableQuery: true, - isDebouncing: false, - isError: false, - isFetching: false, - isLoading: false, - }); - - render( - , - ); - - expect( - screen.getByLabelText("Thread working with unsubmitted draft"), - ).not.toBeNull(); - expect(screen.queryByLabelText("Plan mode active")).toBeNull(); - }); - - it("includes an idle draft in the search result accessible name", () => { - const thread = createThreadListEntry({ - id: "thr_search_idle_draft", - title: "Idle draft", - }); - window.localStorage.setItem( - "bb.promptbox.contents-proj_search-thr_search_idle_draft-3", - JSON.stringify({ text: "Keep editing", attachments: [] }), - ); - mockThreadSearch({ - data: createSearchResponse(thread), - debouncedQuery: "draft", - hasSearchableQuery: true, - isDebouncing: false, - isError: false, - isFetching: false, - isLoading: false, - }); - - render( - , - ); - - expect( - screen.getByLabelText("Thread has unsubmitted draft"), - ).not.toBeNull(); - expect( - screen.getByRole("option", { - name: /Idle draft.*Thread has unsubmitted draft/, - }), - ).not.toBeNull(); - }); - - it("shows section metadata instead of project metadata in section mode", () => { - const thread = createThreadListEntry({ - sectionId: "sec_ci", - id: "thr_section", - title: "CI cleanup", - }); - mockThreadSearch({ - data: createSearchResponse(thread), - debouncedQuery: "needle", - hasSearchableQuery: true, - isDebouncing: false, - isError: false, - isFetching: false, - isLoading: false, - }); - - render( - , - ); - - const rowText = screen.getByRole("option").textContent ?? ""; - expect(rowText).toContain("Infra / CI"); - expect(rowText).not.toContain("Search project"); - }); - - it("shows overflow counts for capped archived search results", () => { - const archivedThread = createThreadListEntry({ - id: "thr_archived", - title: "Archived cleanup", - }); - mockThreadSearch({ - data: { - active: { - results: [], - total: 0, - }, - archived: { - results: [ - { - matches: [], - thread: archivedThread, - }, - ], - total: 3, - }, - }, - debouncedQuery: "cleanup", - hasSearchableQuery: true, - isDebouncing: false, - isError: false, - isFetching: false, - isLoading: false, - }); - - render( - , - ); - - expect(screen.getByText("Archived")).not.toBeNull(); - expect(screen.getByText("1/3")).not.toBeNull(); - }); -}); - -describe("sidebar thread search navigation items", () => { - it("treats rows with different message matches as different items", () => { - const optionId = getSidebarThreadSearchOptionId("active:thr_search"); - const baseItem: SidebarThreadSearchNavigationItem = { - id: "active:thr_search", - optionId, - projectId: "proj_search", - threadId: "thr_search", - messageSeq: 3, - }; - - expect( - haveSameSidebarThreadSearchNavigationItems( - [baseItem], - [ - { - ...baseItem, - messageSeq: 7, - }, - ], - ), - ).toBe(false); - }); -}); - -describe("ProjectListActionButtons", () => { - it("shows the compose pane position when New thread is open in a split", () => { - const store = createStore(); - store.set(splitLayoutAtom, { - focusedPaneId: "pane-thread", - root: { - type: "split", - dir: "row", - sizes: [0.5, 0.5], - children: [ - { - type: "pane", - paneId: "pane-compose", - content: { kind: "new-thread", draftSlotId: "draft-compose" }, - }, - { - type: "pane", - paneId: "pane-thread", - content: { - kind: "thread", - projectId: "proj_test", - threadId: "thr_test", - }, - }, - ], - }, - }); - - render( - - - , - ); - - const splitMap = screen.getByRole("img", { - name: "New thread — open in split", - }); - const label = screen.getByText("New thread"); - expect(label.nextElementSibling).toBe(splitMap); - }); - - it("exposes the active search option on the combobox input", () => { - const inputRef = createRef(); - - render( - , - ); - - expect( - screen.getByRole("combobox").getAttribute("aria-activedescendant"), - ).toBe("active-option"); - }); - - it("labels the search close button as a close-and-clear action when a query exists", () => { - const inputRef = createRef(); - - render( - , - ); - - expect( - screen.getByRole("button", { name: "Clear and close search" }), - ).not.toBeNull(); - }); -}); - -describe("AppSidebar thread search keyboard routing", () => { - it("handles search keys only from the input or search options", () => { - const input = document.createElement("input"); - const closeButton = document.createElement("button"); - const option = document.createElement("button"); - const optionLabel = document.createElement("span"); - option.setAttribute("role", "option"); - option.append(optionLabel); - - expect(isThreadSearchKeyboardEventTarget(input, input)).toBe(true); - expect(isThreadSearchKeyboardEventTarget(option, input)).toBe(true); - expect(isThreadSearchKeyboardEventTarget(optionLabel, input)).toBe(true); - expect(isThreadSearchKeyboardEventTarget(closeButton, input)).toBe(false); - }); -}); diff --git a/apps/app/src/components/sidebar/SidebarThreadSearchPanel.tsx b/apps/app/src/components/sidebar/SidebarThreadSearchPanel.tsx deleted file mode 100644 index c4f2a36c9c..0000000000 --- a/apps/app/src/components/sidebar/SidebarThreadSearchPanel.tsx +++ /dev/null @@ -1,340 +0,0 @@ -import { useEffect, useMemo } from "react"; -import type { ThreadListEntry } from "@bb/domain"; -import type { ThreadSearchMatch } from "@bb/server-contract"; -import { CHROME_SECTION_LABEL_CLASS } from "@bb/shared-ui/chrome-style-tokens"; -import { - COARSE_POINTER_TEXT_SM_CLASS, - COARSE_POINTER_ICON_SIZE_CLASS, -} from "@bb/shared-ui/coarse-pointer-sizing"; -import { Icon, type IconName } from "@bb/shared-ui/icon"; -import { useThreadSearch } from "@/hooks/queries/thread-queries"; -import { hasThreadSearchableQuery } from "@/hooks/queries/thread-queries"; -import { cn } from "@bb/shared-ui/lib/utils"; -import { - getSidebarThreadSearchOptionId, - isSidebarThreadTitleMatch, - SIDEBAR_THREAD_SEARCH_LISTBOX_ID, - type SidebarThreadSearchNavigationItem, -} from "./sidebarThreadSearch"; -import { ThreadSearchResultRow } from "./ThreadSearchResultRow"; - -interface SidebarThreadSearchPanelProps { - activeIndex: number; - sectionNamesById?: ReadonlyMap; - isRecentsLoading: boolean; - onActiveIndexChange: (index: number) => void; - onNavigationItemsChange: ( - items: readonly SidebarThreadSearchNavigationItem[], - ) => void; - onSelect: (item: SidebarThreadSearchNavigationItem) => void; - projectNamesById: ReadonlyMap; - query: string; - recentThreads: readonly ThreadListEntry[]; - showSectionLabels?: boolean; -} - -interface ThreadSearchRenderableRow { - id: string; - matches: readonly ThreadSearchMatch[]; - thread: ThreadListEntry; -} - -interface ThreadSearchSection { - id: "active" | "archived"; - label: string; - rows: readonly ThreadSearchRenderableRow[]; - total: number; -} - -interface ThreadSearchMessageProps { - iconName: IconName; - isLoading?: boolean; - text: string; -} - -const RECENT_THREAD_LIMIT = 20; -const EMPTY_MATCHES: readonly ThreadSearchMatch[] = []; -const EMPTY_SECTION_NAMES_BY_ID = new Map(); -// The message (non-title) match drives the deep-link target. Mirrors the row's -// snippet selection so clicking a result lands on the message shown in the row. -function getMessageMatchSeq( - matches: readonly ThreadSearchMatch[], -): number | null { - for (const match of matches) { - if (!isSidebarThreadTitleMatch(match) && match.sourceSeq !== null) { - return match.sourceSeq; - } - } - return null; -} - -function toNavigationItem( - row: ThreadSearchRenderableRow, -): SidebarThreadSearchNavigationItem { - return { - id: row.id, - optionId: getSidebarThreadSearchOptionId(row.id), - projectId: row.thread.projectId, - threadId: row.thread.id, - messageSeq: getMessageMatchSeq(row.matches), - }; -} - -function ThreadSearchMessage({ - iconName, - isLoading = false, - text, -}: ThreadSearchMessageProps) { - return ( -
- - {text} -
- ); -} - -function renderSectionRows({ - activeIndex, - sectionNamesById, - onActiveIndexChange, - onSelect, - projectNamesById, - section, - showSectionLabels, - startIndex, -}: { - activeIndex: number; - sectionNamesById: ReadonlyMap; - onActiveIndexChange: (index: number) => void; - onSelect: (item: SidebarThreadSearchNavigationItem) => void; - projectNamesById: ReadonlyMap; - section: ThreadSearchSection; - showSectionLabels: boolean; - startIndex: number; -}) { - if (section.rows.length === 0) { - return null; - } - - return ( -
-
- {section.label} - {section.total > section.rows.length ? ( - - {section.rows.length}/{section.total} - - ) : null} -
-
- {section.rows.map((row, rowIndex) => { - const index = startIndex + rowIndex; - const item = toNavigationItem(row); - return ( - onActiveIndexChange(index)} - onSelect={() => onSelect(item)} - /> - ); - })} -
-
- ); -} - -export function SidebarThreadSearchPanel({ - activeIndex, - sectionNamesById = EMPTY_SECTION_NAMES_BY_ID, - isRecentsLoading, - onActiveIndexChange, - onNavigationItemsChange, - onSelect, - projectNamesById, - query, - recentThreads, - showSectionLabels = false, -}: SidebarThreadSearchPanelProps) { - const trimmedQuery = query.trim(); - const liveQueryIsSearchable = hasThreadSearchableQuery(trimmedQuery); - const threadSearch = useThreadSearch({ active: true, query }); - const searchResultsAreCurrent = - !liveQueryIsSearchable || threadSearch.debouncedQuery === trimmedQuery; - const sections = useMemo(() => { - if (!liveQueryIsSearchable) { - const rows = recentThreads - .slice(0, RECENT_THREAD_LIMIT) - .map((thread) => ({ - id: `recent:${thread.id}`, - matches: EMPTY_MATCHES, - thread, - })); - return [ - { - id: "active", - label: "Recent", - rows, - total: rows.length, - }, - ]; - } - - if (!searchResultsAreCurrent) { - return [ - { - id: "active", - label: "Threads", - rows: [], - total: 0, - }, - { - id: "archived", - label: "Archived", - rows: [], - total: 0, - }, - ]; - } - - const activeRows = - threadSearch.data?.active.results.map((result) => ({ - id: `active:${result.thread.id}`, - matches: result.matches, - thread: result.thread, - })) ?? []; - const archivedRows = - threadSearch.data?.archived.results.map((result) => ({ - id: `archived:${result.thread.id}`, - matches: result.matches, - thread: result.thread, - })) ?? []; - return [ - { - id: "active", - label: "Threads", - rows: activeRows, - total: threadSearch.data?.active.total ?? 0, - }, - { - id: "archived", - label: "Archived", - rows: archivedRows, - total: threadSearch.data?.archived.total ?? 0, - }, - ]; - }, [ - liveQueryIsSearchable, - recentThreads, - searchResultsAreCurrent, - threadSearch.data, - ]); - const rows = useMemo( - () => sections.flatMap((section) => section.rows), - [sections], - ); - const navigationItems = useMemo(() => rows.map(toNavigationItem), [rows]); - - useEffect(() => { - onNavigationItemsChange(navigationItems); - }, [navigationItems, onNavigationItemsChange]); - - const isLoading = - liveQueryIsSearchable && - (!searchResultsAreCurrent || - threadSearch.isDebouncing || - (threadSearch.isLoading && threadSearch.data === undefined)); - const hasRows = rows.length > 0; - const showRecentLoading = !liveQueryIsSearchable && isRecentsLoading; - const showError = - liveQueryIsSearchable && threadSearch.isError && !isLoading && !hasRows; - const showNoSearchResults = - liveQueryIsSearchable && !isLoading && !showError && !hasRows; - const showTypeToSearch = - !liveQueryIsSearchable && !showRecentLoading && recentThreads.length === 0; - let startIndex = 0; - - return ( -
- {showRecentLoading ? ( - - ) : null} - {isLoading ? ( - - ) : null} - {showError ? ( - - ) : null} - {showNoSearchResults ? ( - - ) : null} - {showTypeToSearch ? ( - - ) : null} - {sections.map((section) => { - const renderedSection = renderSectionRows({ - activeIndex, - sectionNamesById, - onActiveIndexChange, - onSelect, - projectNamesById, - section, - showSectionLabels, - startIndex, - }); - startIndex += section.rows.length; - return renderedSection; - })} -
- ); -} diff --git a/apps/app/src/components/sidebar/ThreadSearchResultRow.stories.tsx b/apps/app/src/components/sidebar/ThreadSearchResultRow.stories.tsx deleted file mode 100644 index f6d9e6f204..0000000000 --- a/apps/app/src/components/sidebar/ThreadSearchResultRow.stories.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import type { ReactNode } from "react"; -import { PERSONAL_PROJECT_ID } from "@bb/domain"; -import type { ThreadSearchMatch } from "@bb/server-contract"; -import { makeThreadListEntry } from "../../../.ladle/story-fixtures"; -import { StoryCard, StoryRow } from "../../../.ladle/story-card"; -import { getThreadDisplayTitle } from "@/lib/thread-title"; -import { ThreadSearchResultRow } from "./ThreadSearchResultRow"; - -export default { - title: "sidebar/Thread search result", -}; - -const noop = () => {}; -const HOUR_MS = 60 * 60 * 1000; - -// Highlight the first occurrence of `term` (case-insensitive), mirroring how the -// server returns highlight ranges for a match. -function highlight( - text: string, - term: string, -): ThreadSearchMatch["highlightRanges"] { - const index = text.toLowerCase().indexOf(term.toLowerCase()); - return index < 0 ? [] : [{ start: index, end: index + term.length }]; -} - -function Stage({ children }: { children: ReactNode }) { - return ( -
- {children} -
- ); -} - -const recentThread = makeThreadListEntry({ - id: "thr_recent", - title: "Refactor the sidebar search panel", - titleFallback: "Refactor the sidebar search panel", - updatedAt: Date.now() - 2 * HOUR_MS, -}); -const titleMatchThread = makeThreadListEntry({ - id: "thr_title", - title: "Audit recurring permission failures", - titleFallback: "Audit recurring permission failures", - updatedAt: Date.now() - 26 * HOUR_MS, -}); -const messageMatchThread = makeThreadListEntry({ - id: "thr_message", - title: "Worktree cleanup", - titleFallback: "Worktree cleanup", - updatedAt: Date.now() - 3 * HOUR_MS, -}); -const personalThread = makeThreadListEntry({ - id: "thr_personal", - projectId: PERSONAL_PROJECT_ID, - title: "Plan the offsite", - titleFallback: "Plan the offsite", - updatedAt: Date.now() - 5 * HOUR_MS, -}); - -const messageSnippet = - "The permission prompts keep recurring after the worktree is recreated — here is the fix I landed and why it works."; - -// The redesigned result row: matched text first, with thread metadata underneath. -export function Overview() { - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -} diff --git a/apps/app/src/components/sidebar/ThreadSearchResultRow.tsx b/apps/app/src/components/sidebar/ThreadSearchResultRow.tsx deleted file mode 100644 index 9e43a0a5d5..0000000000 --- a/apps/app/src/components/sidebar/ThreadSearchResultRow.tsx +++ /dev/null @@ -1,234 +0,0 @@ -import { - memo, - useCallback, - useEffect, - useRef, - type MouseEventHandler, - type ReactNode, -} from "react"; -import type { ThreadListEntry } from "@bb/domain"; -import type { ThreadSearchMatch } from "@bb/server-contract"; -import { PERSONAL_PROJECT_ID } from "@bb/domain"; -import { Icon } from "@bb/shared-ui/icon"; -import { formatRelativeTime } from "@/lib/relative-time"; -import { - hasActiveBackgroundAgentActivity, - hasActiveBackgroundCommandActivity, - hasActiveGoalActivity, - hasActivePlanModeActivity, - hasActiveWorkflowActivity, - isRuntimeBusyThread, - isUnreadDoneThread, - resolveThreadListIndicator, - type ThreadListIndicatorState, -} from "@bb/client-core"; -import { getThreadDisplayTitle } from "@/lib/thread-title"; -import { cn } from "@bb/shared-ui/lib/utils"; -import { ThreadStatusGlyph } from "./ThreadRow"; -import { isSidebarThreadTitleMatch } from "./sidebarThreadSearch"; -import { usePromptDraftHasInput } from "@/hooks/usePromptDraftStorage"; -import { - SIDEBAR_ROW_BASE_CLASS, - SIDEBAR_ROW_INTERACTIVE_STATE_CLASS, - SIDEBAR_STANDARD_ROW_PADDING_CLASS, -} from "./sidebarRowClasses"; - -interface ThreadSearchResultRowProps { - id: string; - isActive: boolean; - matches: readonly ThreadSearchMatch[]; - onActive: () => void; - onSelect: () => void; - projectName: string | undefined; - /** - * Pre-formatted section path (e.g. "Infra › CI") shown in place of the project - * when the sidebar is organized by section. The caller derives it from the - * thread's section + the Organize-by setting; absent → falls back to project. - */ - sectionLabel?: string | null; - thread: ThreadListEntry; -} - -interface HighlightedTextProps { - ranges: ThreadSearchMatch["highlightRanges"]; - text: string; -} - -function clampRange( - range: ThreadSearchMatch["highlightRanges"][number], - textLength: number, -): ThreadSearchMatch["highlightRanges"][number] | null { - const start = Math.max(0, Math.min(range.start, textLength)); - const end = Math.max(start, Math.min(range.end, textLength)); - return end > start ? { start, end } : null; -} - -function HighlightedText({ ranges, text }: HighlightedTextProps) { - if (ranges.length === 0 || text.length === 0) { - return text; - } - - const nodes: ReactNode[] = []; - let cursor = 0; - const sortedRanges = ranges - .map((range) => clampRange(range, text.length)) - .filter((range): range is NonNullable => range !== null) - .sort((left, right) => left.start - right.start || left.end - right.end); - - for (const range of sortedRanges) { - if (range.start < cursor) { - continue; - } - if (range.start > cursor) { - nodes.push(text.slice(cursor, range.start)); - } - nodes.push( - - {text.slice(range.start, range.end)} - , - ); - cursor = range.end; - } - if (cursor < text.length) { - nodes.push(text.slice(cursor)); - } - - return nodes; -} - -function getTitleMatch( - title: string, - matches: readonly ThreadSearchMatch[], -): ThreadSearchMatch | undefined { - return matches.find( - (match) => isSidebarThreadTitleMatch(match) && match.text === title, - ); -} - -function getSnippetMatch( - matches: readonly ThreadSearchMatch[], -): ThreadSearchMatch | undefined { - return matches.find((match) => !isSidebarThreadTitleMatch(match)); -} - -function isNonEmptyMetadataPart(value: string | null): value is string { - return value !== null && value.length > 0; -} - -function ThreadSearchResultRowComponent({ - id, - isActive, - matches, - onActive, - onSelect, - projectName, - sectionLabel, - thread, -}: ThreadSearchResultRowProps) { - const rowRef = useRef(null); - const title = getThreadDisplayTitle(thread); - const titleMatch = getTitleMatch(title, matches); - const snippetMatch = getSnippetMatch(matches); - const primaryMatch = snippetMatch ?? titleMatch; - const primaryText = primaryMatch?.text ?? title; - const primaryHighlightRanges = primaryMatch?.highlightRanges ?? []; - const hasPendingInteraction = thread.hasPendingInteraction; - const threadUnreadDone = isUnreadDoneThread(thread); - const hasUnsubmittedDraft = usePromptDraftHasInput({ - kind: "thread", - projectId: thread.projectId, - threadId: thread.id, - }); - const indicatorState: ThreadListIndicatorState = { - hasPendingInteraction, - hasUnsubmittedDraft, - hasUnreadError: threadUnreadDone && thread.status === "error", - hasUnreadSuccess: threadUnreadDone && thread.status !== "error", - isBackgroundAgentActive: hasActiveBackgroundAgentActivity(thread), - isBackgroundCommandActive: hasActiveBackgroundCommandActivity(thread), - isGoalActive: hasActiveGoalActivity(thread), - isPlanModeActive: hasActivePlanModeActivity(thread), - isRuntimeActive: isRuntimeBusyThread(thread), - isWorkflowActive: hasActiveWorkflowActivity(thread), - }; - const indicatorKind = resolveThreadListIndicator(indicatorState); - // For recents and title-only matches, the second line shows the project and - // when the thread was last active. - const projectMetadata = - thread.projectId !== PERSONAL_PROJECT_ID && projectName - ? projectName - : null; - // Section takes the project's place on the metadata line when the sidebar is - // organized by section (the caller supplies a sectionLabel only then). - const contextLabel = sectionLabel ?? projectMetadata; - const relativeTime = formatRelativeTime({ - timestamp: thread.updatedAt, - now: Date.now(), - }); - const metadataText = [snippetMatch ? title : null, contextLabel, relativeTime] - .filter(isNonEmptyMetadataPart) - .join(" · "); - const handleMouseEnter = useCallback< - MouseEventHandler - >(() => { - onActive(); - }, [onActive]); - - useEffect(() => { - if (!isActive) { - return; - } - rowRef.current?.scrollIntoView({ block: "nearest" }); - }, [isActive]); - - return ( - - ); -} - -export const ThreadSearchResultRow = memo(ThreadSearchResultRowComponent); diff --git a/apps/app/src/components/sidebar/sidebarThreadSearch.ts b/apps/app/src/components/sidebar/sidebarThreadSearch.ts deleted file mode 100644 index 70274e52d9..0000000000 --- a/apps/app/src/components/sidebar/sidebarThreadSearch.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { RefObject } from "react"; -import type { ThreadSearchMatch } from "@bb/server-contract"; - -export const SIDEBAR_THREAD_SEARCH_LISTBOX_ID = - "bb-sidebar-thread-search-results"; - -export interface SidebarThreadSearchNavigationItem { - id: string; - optionId: string; - projectId: string; - threadId: string; - /** - * Event sequence of the matched message, so selecting the result can scroll - * to that message in the thread. Null when the match is a title or the row is - * a recent (no-query) entry with no message match. - */ - messageSeq: number | null; -} - -export interface SidebarThreadSearchInputController { - activeDescendantId: string | undefined; - inputRef: RefObject; - isActive: boolean; - onActivate: () => void; - onClose: () => void; - onQueryChange: (query: string) => void; - query: string; -} - -export interface SidebarThreadSearchPanelController { - activeIndex: number; - isActive: boolean; - onActiveIndexChange: (index: number) => void; - onNavigationItemsChange: ( - items: readonly SidebarThreadSearchNavigationItem[], - ) => void; - onSelectItem: (item: SidebarThreadSearchNavigationItem) => void; - query: string; -} - -/** - * The sidebar-wide key handler only owns keys typed in the search field or on a - * result row. Every other sidebar control keeps its own key behavior. - */ -export function isThreadSearchKeyboardEventTarget( - target: EventTarget | null, - input: HTMLInputElement | null, -): boolean { - if (!(target instanceof HTMLElement)) { - return false; - } - if (target === input) { - return true; - } - return target.closest('[role="option"]') !== null; -} - -export function getSidebarThreadSearchOptionId(rowId: string): string { - return `${SIDEBAR_THREAD_SEARCH_LISTBOX_ID}-option-${rowId}`; -} - -export function isSidebarThreadTitleMatch(match: ThreadSearchMatch): boolean { - return match.sourceKind === "title" || match.sourceKind === "title_fallback"; -} - -export function haveSameSidebarThreadSearchNavigationItems( - left: readonly SidebarThreadSearchNavigationItem[], - right: readonly SidebarThreadSearchNavigationItem[], -): boolean { - if (left.length !== right.length) { - return false; - } - return left.every( - (item, index) => - item.id === right[index]?.id && - item.optionId === right[index]?.optionId && - item.messageSeq === right[index]?.messageSeq, - ); -} diff --git a/apps/app/src/components/sidebar/useSidebarThreadSearch.test.tsx b/apps/app/src/components/sidebar/useSidebarThreadSearch.test.tsx deleted file mode 100644 index 47d814e95f..0000000000 --- a/apps/app/src/components/sidebar/useSidebarThreadSearch.test.tsx +++ /dev/null @@ -1,183 +0,0 @@ -// @vitest-environment jsdom - -import { - act, - cleanup, - fireEvent, - render, - screen, -} from "@testing-library/react"; -import { useEffect } from "react"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - getSidebarThreadSearchOptionId, - type SidebarThreadSearchNavigationItem, -} from "./sidebarThreadSearch"; -import { - useSidebarThreadSearch, - type SidebarThreadSearchController, -} from "./useSidebarThreadSearch"; - -function createNavigationItem( - threadId: string, -): SidebarThreadSearchNavigationItem { - return { - id: `active:${threadId}`, - optionId: getSidebarThreadSearchOptionId(`active:${threadId}`), - projectId: "proj_search", - threadId, - messageSeq: null, - }; -} - -const FIRST_ITEM = createNavigationItem("thr_first"); -const SECOND_ITEM = createNavigationItem("thr_second"); - -function renderSearch() { - const onOpenSidebar = vi.fn(); - const onOpenThread = vi.fn(); - const onThreadOpened = vi.fn(); - let controller: SidebarThreadSearchController | null = null; - - function Harness({ - onController, - }: { - onController: (next: SidebarThreadSearchController) => void; - }) { - const search = useSidebarThreadSearch({ - isPointerCoarse: false, - onOpenSidebar, - onOpenThread, - onThreadOpened, - }); - // Publish the controller of every render, so each assertion reads the - // state the sidebar currently shows. - useEffect(() => { - onController(search); - }); - const { inputRef, onKeyDown, onQueryChange, query } = search; - return ( -
- onQueryChange(event.currentTarget.value)} - /> -
- ); - } - - render( - { - controller = next; - }} - />, - ); - - const getController = () => { - if (controller === null) { - throw new Error("The search controller is not ready."); - } - return controller; - }; - - return { getController, onOpenSidebar, onOpenThread, onThreadOpened }; -} - -function openSearchWithResults( - getController: () => SidebarThreadSearchController, -) { - act(() => { - getController().onActivate(); - }); - act(() => { - getController().onQueryChange("needle"); - }); - act(() => { - getController().onNavigationItemsChange([FIRST_ITEM, SECOND_ITEM]); - }); -} - -function expectSearchIsReset(controller: SidebarThreadSearchController): void { - expect(controller.isActive).toBe(false); - expect(controller.query).toBe(""); - expect(controller.activeIndex).toBe(0); - expect(controller.activeDescendantId).toBeUndefined(); -} - -afterEach(() => { - cleanup(); - vi.clearAllMocks(); -}); - -describe("useSidebarThreadSearch", () => { - it("clears the search state after the keyboard opens a thread", () => { - const { getController, onOpenThread } = renderSearch(); - openSearchWithResults(getController); - const input = screen.getByLabelText("Search threads"); - - fireEvent.keyDown(input, { key: "ArrowDown" }); - expect(getController().activeIndex).toBe(1); - - fireEvent.keyDown(input, { key: "Enter" }); - - expect(onOpenThread).toHaveBeenCalledWith(SECOND_ITEM); - expectSearchIsReset(getController()); - }); - - it("clears the search state after a pointer click opens a thread", () => { - const { getController, onOpenThread, onThreadOpened } = renderSearch(); - openSearchWithResults(getController); - - act(() => { - getController().onSelectItem(FIRST_ITEM); - }); - - expect(onOpenThread).toHaveBeenCalledWith(FIRST_ITEM); - expect(onThreadOpened).toHaveBeenCalledTimes(1); - expectSearchIsReset(getController()); - }); - - // A plugin thread list filters by the host query and opens threads itself, so - // its `onNavigate` must end search on every viewport, not only on mobile. - it("clears the search state when a plugin thread list opens a thread", () => { - const { getController, onOpenThread, onThreadOpened } = renderSearch(); - openSearchWithResults(getController); - - act(() => { - getController().onExternalThreadOpen(); - }); - - expect(onThreadOpened).toHaveBeenCalledTimes(1); - expect(onOpenThread).not.toHaveBeenCalled(); - expectSearchIsReset(getController()); - }); - - it("keeps search open when Escape only clears the query", () => { - const { getController } = renderSearch(); - openSearchWithResults(getController); - const input = screen.getByLabelText("Search threads"); - - fireEvent.keyDown(input, { key: "Escape" }); - - expect(getController().isActive).toBe(true); - expect(getController().query).toBe(""); - - fireEvent.keyDown(input, { key: "Escape" }); - - expect(getController().isActive).toBe(false); - }); - - it("opens the sidebar when search activates", () => { - const { getController, onOpenSidebar } = renderSearch(); - - act(() => { - getController().onActivate(); - }); - - expect(onOpenSidebar).toHaveBeenCalledTimes(1); - expect(getController().isActive).toBe(true); - }); -}); diff --git a/apps/app/src/components/sidebar/useSidebarThreadSearch.ts b/apps/app/src/components/sidebar/useSidebarThreadSearch.ts deleted file mode 100644 index d2556712c1..0000000000 --- a/apps/app/src/components/sidebar/useSidebarThreadSearch.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { - useCallback, - useRef, - useState, - type KeyboardEventHandler, - type RefObject, -} from "react"; -import { - haveSameSidebarThreadSearchNavigationItems, - isThreadSearchKeyboardEventTarget, - type SidebarThreadSearchNavigationItem, -} from "./sidebarThreadSearch"; - -interface UseSidebarThreadSearchOptions { - /** - * Coarse pointers pop the on-screen keyboard on focus, which hides the - * results, so search opens there without stealing focus. - */ - isPointerCoarse: boolean; - /** Reveals the sidebar, so the search field is on screen when search opens. */ - onOpenSidebar: () => void; - /** Opens the thread behind a selected result. */ - onOpenThread: (item: SidebarThreadSearchNavigationItem) => void; - /** Runs after any sidebar thread opens; closes the mobile sidebar drawer. */ - onThreadOpened: () => void; -} - -export interface SidebarThreadSearchController { - activeDescendantId: string | undefined; - activeIndex: number; - inputRef: RefObject; - isActive: boolean; - onActivate: () => void; - onActiveIndexChange: (index: number) => void; - /** Clears the query and returns the sidebar to its pre-search state. */ - onClose: () => void; - /** - * Ends search after a thread opens outside the result list, such as from a - * plugin thread list that filters by the host query. - */ - onExternalThreadOpen: () => void; - onKeyDown: KeyboardEventHandler; - onNavigationItemsChange: ( - items: readonly SidebarThreadSearchNavigationItem[], - ) => void; - onQueryChange: (query: string) => void; - onSelectItem: (item: SidebarThreadSearchNavigationItem) => void; - query: string; -} - -/** - * Owns the sidebar thread-search state: the query, the result list, and the - * keyboard cursor over it. Selecting a result resets all of it, so the sidebar - * shows the normal thread list again after the thread opens. - */ -export function useSidebarThreadSearch({ - isPointerCoarse, - onOpenSidebar, - onOpenThread, - onThreadOpened, -}: UseSidebarThreadSearchOptions): SidebarThreadSearchController { - const [isActive, setIsActive] = useState(false); - const [query, setQuery] = useState(""); - const [activeIndex, setActiveIndex] = useState(0); - const [navigationItems, setNavigationItems] = useState< - readonly SidebarThreadSearchNavigationItem[] - >([]); - const inputRef = useRef(null); - const activeDescendantId = navigationItems[activeIndex]?.optionId; - - const focusInput = useCallback(() => { - if (isPointerCoarse) return; - - window.requestAnimationFrame(() => { - inputRef.current?.focus(); - }); - }, [isPointerCoarse]); - - const handleActivate = useCallback(() => { - setIsActive(true); - onOpenSidebar(); - focusInput(); - }, [focusInput, onOpenSidebar]); - - const handleClose = useCallback(() => { - setIsActive(false); - setQuery(""); - setActiveIndex(0); - setNavigationItems([]); - }, []); - - const handleNavigationItemsChange = useCallback( - (items: readonly SidebarThreadSearchNavigationItem[]) => { - setNavigationItems((current) => - haveSameSidebarThreadSearchNavigationItems(current, items) - ? current - : items, - ); - setActiveIndex((current) => { - if (items.length === 0) { - return 0; - } - return Math.min(current, items.length - 1); - }); - }, - [], - ); - - // Search is a transient mode over the thread list. Once a thread opens, the - // query has done its work, so drop it and show the list again. Every sidebar - // thread selection ends here: a result row, and a plugin list that filters by - // the host query. - const handleThreadOpened = useCallback(() => { - onThreadOpened(); - handleClose(); - }, [handleClose, onThreadOpened]); - - const handleSelectItem = useCallback( - (item: SidebarThreadSearchNavigationItem) => { - onOpenThread(item); - handleThreadOpened(); - }, - [handleThreadOpened, onOpenThread], - ); - - const handleKeyDown = useCallback>( - (event) => { - if (!isActive || event.defaultPrevented) { - return; - } - if (!isThreadSearchKeyboardEventTarget(event.target, inputRef.current)) { - return; - } - - if (event.key === "ArrowDown") { - if (navigationItems.length === 0) { - return; - } - event.preventDefault(); - setActiveIndex((current) => - current >= navigationItems.length - 1 ? 0 : current + 1, - ); - return; - } - - if (event.key === "ArrowUp") { - if (navigationItems.length === 0) { - return; - } - event.preventDefault(); - setActiveIndex((current) => - current <= 0 ? navigationItems.length - 1 : current - 1, - ); - return; - } - - if (event.key === "Enter") { - const item = navigationItems[activeIndex]; - if (!item) { - return; - } - event.preventDefault(); - handleSelectItem(item); - return; - } - - if (event.key === "Escape") { - event.preventDefault(); - if (query.length > 0) { - setQuery(""); - focusInput(); - return; - } - handleClose(); - } - }, - [ - activeIndex, - focusInput, - handleClose, - handleSelectItem, - isActive, - navigationItems, - query.length, - ], - ); - - return { - activeDescendantId, - activeIndex, - inputRef, - isActive, - onActivate: handleActivate, - onActiveIndexChange: setActiveIndex, - onClose: handleClose, - onExternalThreadOpen: handleThreadOpened, - onKeyDown: handleKeyDown, - onNavigationItemsChange: handleNavigationItemsChange, - onQueryChange: setQuery, - onSelectItem: handleSelectItem, - query, - }; -} diff --git a/apps/app/src/lib/app-command-metadata.ts b/apps/app/src/lib/app-command-metadata.ts index 26a5f1f00d..3a01020518 100644 --- a/apps/app/src/lib/app-command-metadata.ts +++ b/apps/app/src/lib/app-command-metadata.ts @@ -51,7 +51,7 @@ export const APP_COMMAND_GROUPS: readonly AppCommandGroup[] = [ command( "thread.search", "Search threads", - "Focus the sidebar thread search.", + "Search threads in the quick palette.", ), command("thread.rename", "Rename thread", "Rename the focused thread."), command( diff --git a/plans/bb-mobile-research/ui-inventory.md b/plans/bb-mobile-research/ui-inventory.md index 21ca8dcdb0..836e496583 100644 --- a/plans/bb-mobile-research/ui-inventory.md +++ b/plans/bb-mobile-research/ui-inventory.md @@ -20,7 +20,7 @@ Providers, outer→inner: `AppErrorBoundary` (main.tsx:60; class boundary, fallb `SidebarProvider` + `AppLayoutSidebar` (mode app/settings/tools, AppLayoutSidebar.tsx:47-77) + `SidebarInset` + `AppHeader` (hidden on thread/root/plugin-panel routes, AppLayout.tsx:576) + fixed `SidebarTriggerOverlay` (AppLayout.tsx:218) + global `ProjectPathDialog` (AppLayout.tsx:877). Handles commands `sidebar.toggle`, `thread.new`, `settings.open`, `settings.openServers` (AppLayout.tsx:174,481-495), `wsManager.onThreadOpen` navigation, favicon badge, `document.title`. Sidebar width/open persisted (`bb.sidebar.width|open`, AppLayout.tsx:96-144); resize handle desktop-only (`hidden md:block`, AppSidebar.tsx:404); mouse-only resize (AppLayout.tsx:742-812). Compact viewport = `(max-width: 767px)` (`useIsCompactViewport`, shared-ui hooks/use-compact-viewport.tsx:10); sidebar becomes swipe-open/drag-close overlay drawer (`SidebarMobilePanel`, components/ui/sidebar.tsx:20,732-754,1065). iOS keyboard viewport fixups: `useMobileVisualViewportHeight` (AppLayout.tsx:410). ## App sidebar (components/sidebar/AppSidebar.tsx) -Rows: history back/forward (top reserve), "New thread" (+ split mini-map desktop) + thread search (AppSidebar.tsx:314-327; `useSidebarThreadSearch`), `PluginNavSidebarItems` (Extensions row + plugin navPanels, reorder/hide via `bb.sidebar.pluginPanelOrder|hiddenPluginPanels`), `PluginThreadList` → `ProjectList` (org modes project/machine/manual + sort updated/created/alpha via `SidebarDisplayOptionsMenu`, ProjectList.tsx:621-739; section create/rename/delete dialogs ProjectList.tsx:1979-2012; Pinned section; dnd-kit drag reorder), footer: Settings link, plugin footer actions, Report bug (external), `SidebarUpdatesBadge`. ThreadRow kebab hidden on compact+coarse (`max-md:pointer-coarse:hidden`, ThreadRow.tsx:821); Radix ContextMenu (long-press) still wraps rows (ThreadRow.tsx:854). Thread menu items: Open in split, Mark read/unread, Pin/Unpin, Rename, Archive/Unarchive, Delete (ThreadActionsMenu.tsx:175-248). Project menu: Project settings, Rename, Add local path, Remove (ProjectActionsMenu.tsx:130-169). Section header actions: display options, New project, New section, New thread (ProjectList.tsx:566-617). Keyboard: `thread.search`, `thread.jump.N`, `thread.previous/next` handled here (AppSidebar.tsx:221-230). +Rows: history back/forward (top reserve), "New thread" (+ split mini-map desktop), `PluginNavSidebarItems` (Extensions row + plugin navPanels, reorder/hide via `bb.sidebar.pluginPanelOrder|hiddenPluginPanels`), `PluginThreadList` → `ProjectList` (org modes project/machine/manual + sort updated/created/alpha via `SidebarDisplayOptionsMenu`, ProjectList.tsx:621-739; section create/rename/delete dialogs ProjectList.tsx:1979-2012; Pinned section; dnd-kit drag reorder), footer: Settings link, plugin footer actions, Report bug (external), `SidebarUpdatesBadge`. Thread search is a command-palette mode entered by `thread.search`. ThreadRow kebab hidden on compact+coarse (`max-md:pointer-coarse:hidden`, ThreadRow.tsx:821); Radix ContextMenu (long-press) still wraps rows (ThreadRow.tsx:854). Thread menu items: Open in split, Mark read/unread, Pin/Unpin, Rename, Archive/Unarchive, Delete (ThreadActionsMenu.tsx:175-248). Project menu: Project settings, Rename, Add local path, Remove (ProjectActionsMenu.tsx:130-169). Section header actions: display options, New project, New section, New thread (ProjectList.tsx:566-617). Keyboard: `thread.jump.N` and `thread.previous/next` are handled here (AppSidebar.tsx:221-230). ## Root compose `/` (views/RootComposeView.tsx) `NewThreadComposer` (project/env/branch/worktree/machine/model/permission pickers, NewThreadPromptBox.tsx:418-573) with fork/handoff seeds from `location.state`; empty-projects welcome (RootComposeEmptyWelcome.tsx: New thread / Import projects / New project / Learn); `RootComposeMobileRecents` (`md:hidden`, 3 recent threads, RootComposeMobileRecents.tsx:181); `RootComposeSecondaryContent` = same right panel as threads (files, terminal, new-tab, browser[desktop], plugin tabs; no Info/Diff, RootComposeView.tsx:2345-2381) rendered as bottom drawer on compact (SecondaryPanelLayout.tsx:105); pinned panel toggle; `ProjectMachineSetupDialog`; `PluginHomepageSections`. Commands: panel.newTab, file.quickOpen, terminal.open, workspace.openPreferred, panel.toggle/close (RootComposeView.tsx:1471-1908, RootComposePanelCommandHandlers.tsx). @@ -108,7 +108,7 @@ Path params via `useRouteState` (hooks/useRouteState.ts). Query: `?view=browse|i - apps/app/src/components/commands/AppCommandProvider.tsx: **headless-logic-only** — Registers `window.addEventListener('keydown')`, queries `document.querySelector` for open modals (AppCommandProvider.tsx:93-99,179,330), uses `navigator.platform`, `HTMLElement.closest`. The handler registry/dispatch/priority pattern is portable; the key-event plumbing is web-only (RN has no global keydown; only hardware-keyboard events). - apps/app/src/components/layout/AppLayout.tsx: **not-reusable** — Radix-based SidebarProvider, CSS variables (`--sidebar-width`), mouse resize on document.body, `document.title`, `window.requestAnimationFrame`, MutationObserver, Electron chrome classes, env(safe-area-inset) Tailwind classes. - apps/app/src/components/ui/sidebar.tsx: **not-reusable** — DOM touch/pointer swipe implementation writing `panel.style.translate`, `inert`, `aria-modal`, Tailwind group-data variants; must be re-implemented with a native drawer (e.g. react-native-reanimated/gesture-handler). -- apps/app/src/components/sidebar/ProjectList.tsx + ThreadRow.tsx + ProjectRow.tsx: **headless-logic-only** — Heavy on @dnd-kit, Radix DropdownMenu/ContextMenu/Tooltip, CSS hover-action classes (theme.css:259-333). Reusable pieces: projectThreadGroups.ts, machineThreadGroups.ts, sortComparator.ts, threadReadState.ts, pinnedSidebarThreads.ts, sidebarThreadSearch.ts, sidebarSectionOrder.ts (pure TS). +- apps/app/src/components/sidebar/ProjectList.tsx + ThreadRow.tsx + ProjectRow.tsx: **headless-logic-only** — Heavy on @dnd-kit, Radix DropdownMenu/ContextMenu/Tooltip, CSS hover-action classes (theme.css:259-333). Reusable pieces: projectThreadGroups.ts, machineThreadGroups.ts, sortComparator.ts, threadReadState.ts, pinnedSidebarThreads.ts, sidebarSectionOrder.ts (pure TS). - apps/app/src/views/RootComposeView.tsx: **headless-logic-only** — 2400-line DOM view: react-router location.state, react-resizable-panels, `window`, Tiptap composer, xterm terminal, @pierre diffs. Exported pure helpers (readSectionIdFromLocationState, shouldNavigateAfterThreadCreate, buildMobileRecentThreads, canCreateRootComposeTerminal, root-compose-branch-selection.ts, root-compose-environment-selection.ts) are portable. - apps/app/src/views/RootComposeMobileRecents.tsx: **headless-logic-only** — getMobileRecentThreads sort/limit is pure; rendering uses react-router Link + Tailwind + ThreadStatusGlyph (SVG icons). - apps/app/src/views/thread-detail/ThreadDetailView.tsx: **headless-logic-only** — ~3000 lines wiring DOM-only panels (xterm terminal, @pierre/diffs with Web Workers, Tiptap, react-resizable-panels, iframe/BrowserView). Data hooks (thread-queries, timeline controller, useThreadGitActions, threadQueuedMessages.ts, threadDetailPromptSubmission.ts, splitThreadNavigation.ts) are largely portable. From ea33def6b4b492c61437f69c500beee2a5ac6d3c Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Wed, 26 Aug 2026 11:26:42 +0000 Subject: [PATCH 4/6] Cover palette mode keyboard contract --- .../commands/CommandPalette.test.tsx | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/apps/app/src/components/commands/CommandPalette.test.tsx b/apps/app/src/components/commands/CommandPalette.test.tsx index bc8b9eeb05..ad8276f4b1 100644 --- a/apps/app/src/components/commands/CommandPalette.test.tsx +++ b/apps/app/src/components/commands/CommandPalette.test.tsx @@ -93,6 +93,8 @@ const modeState = vi.hoisted(() => ({ drafts: [] as NewThreadDraftRow[], searchResponse: undefined as ThreadSearchResponse | undefined, })); +const openThreadInSplitMock = vi.hoisted(() => vi.fn()); +const routeNavigateMock = vi.hoisted(() => vi.fn()); vi.mock("@/hooks/queries/system-queries", () => ({ useSystemConfig: () => ({ @@ -124,6 +126,14 @@ vi.mock("@bb/shared-ui/hooks/use-compact-viewport", () => ({ useIsCompactViewport: () => false, })); +vi.mock("@/lib/split-layout/openThreadInSplit", () => ({ + openThreadInSplit: openThreadInSplitMock, +})); + +vi.mock("@/components/ui/app-route-anchor", () => ({ + useRouteNavigate: () => routeNavigateMock, +})); + vi.mock("@/hooks/useNewThreadDraftSlots", () => ({ useNewThreadDraftSlots: () => modeState.drafts, })); @@ -261,6 +271,8 @@ afterEach(() => { testState.calls.length = 0; modeState.drafts = []; modeState.searchResponse = undefined; + openThreadInSplitMock.mockReset(); + routeNavigateMock.mockReset(); window.localStorage.clear(); }); @@ -401,6 +413,99 @@ describe("CommandPalette", () => { ); }); + it("makes scope the input's only sibling tab stop and applies every keyboard choice immediately", async () => { + modeState.searchResponse = { + active: { + total: 1, + results: [{ thread: makeThread("matching-active"), matches: [] }], + }, + archived: { + total: 1, + results: [ + { + thread: makeThread("matching-archived", { + archivedAt: Date.now(), + }), + matches: [], + }, + ], + }, + }; + modeState.drafts = [ + { + id: "matching-draft", + title: "matching draft", + draft: { ...emptyPromptDraftState(), text: "matching draft" }, + lastEditedAt: Date.now(), + destination: { projectId: "project-1", sectionId: null }, + delete: vi.fn(), + }, + ]; + renderPalette(); + openThreadSearch(); + await waitFor(() => + expect( + screen.getByRole("combobox", { name: "Search threads" }), + ).toBeTruthy(), + ); + const input = screen.getByRole("combobox", { name: "Search threads" }); + const scope = screen.getByRole("button", { name: "Thread scope" }); + const palette = screen.getByTestId("command-palette"); + expect( + Array.from( + palette.querySelectorAll( + 'input:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])', + ), + ), + ).toEqual([input, scope]); + + fireEvent.change(input, { target: { value: "match" } }); + const results = screen.getByRole("listbox", { name: "Threads" }); + await waitFor(() => + expect(within(results).getAllByRole("option")).toHaveLength(3), + ); + + scope.focus(); + fireEvent.keyDown(scope, { key: "ArrowDown" }); + expect(scope.textContent).toContain("Active"); + const scopeOptions = screen.getByRole("listbox", { + name: "Thread scope options", + }); + expect( + within(scopeOptions) + .getAllByRole("option") + .map((option) => option.textContent), + ).toEqual(["All", "Active", "Drafts", "Archived"]); + expect(within(results).getAllByRole("option")).toHaveLength(1); + expect(within(results).getByRole("option").textContent).toContain( + "matching-active", + ); + fireEvent.keyDown(scope, { key: "Enter" }); + expect(document.activeElement).toBe(input); + expect( + screen.queryByRole("listbox", { name: "Thread scope options" }), + ).toBeNull(); + + scope.focus(); + fireEvent.keyDown(scope, { key: "ArrowDown" }); + expect(scope.textContent).toContain("Drafts"); + expect(within(results).getAllByRole("option")).toHaveLength(1); + expect(within(results).getByRole("option").textContent).toContain( + "matching draft", + ); + fireEvent.keyDown(scope, { key: "Escape" }); + expect(document.activeElement).toBe(input); + + fireEvent.click(within(results).getByRole("option")); + await waitFor(() => expect(screen.queryByRole("combobox")).toBeNull()); + openThreadSearch(); + await waitFor(() => + expect( + screen.getByRole("button", { name: "Thread scope" }).textContent, + ).toContain("All"), + ); + }); + it("renders search matches as one unlabelled active, draft, archived list", async () => { const active = makeThread("active"); const archived = makeThread("archived", { archivedAt: Date.now() }); @@ -448,6 +553,37 @@ describe("CommandPalette", () => { ).toHaveLength(1); }); + it("opens a persisted thread result in a split with Command-Enter", async () => { + modeState.searchResponse = { + active: { + total: 1, + results: [{ thread: makeThread("matching-split"), matches: [] }], + }, + archived: { total: 0, results: [] }, + }; + renderPalette(); + openThreadSearch(); + const input = await screen.findByRole("combobox", { + name: "Search threads", + }); + fireEvent.change(input, { target: { value: "match" } }); + await waitFor(() => + expect( + screen.getByRole("option").textContent, + ).toContain("matching-split"), + ); + + fireEvent.keyDown(input, { key: "Enter", metaKey: true }); + + await waitFor(() => expect(openThreadInSplitMock).toHaveBeenCalledTimes(1)); + expect(openThreadInSplitMock).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: "project-1", + threadId: "matching-split", + }), + ); + }); + it("filters as the user types and keeps the selection on a live row", async () => { renderPalette(); openPalette(); From 790ea0756ce5fd8e0304c51be2c759423d24ce48 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Wed, 26 Aug 2026 11:38:03 +0000 Subject: [PATCH 5/6] Update command provider test mocks --- .../components/layout/AppLayout.root-compose-project.test.tsx | 1 + apps/app/src/components/layout/AppLayout.sidebar-resize.test.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx b/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx index 5f5e19a1b3..847676a690 100644 --- a/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx +++ b/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx @@ -26,6 +26,7 @@ vi.mock("@/components/commands/AppCommandProvider", () => ({ if (enabled) commandHandlers.set(command, handler); else commandHandlers.delete(command); }, + useIndexedAppCommandHandlers: () => {}, useAppCommandShortcut: () => null, useAppCommandShortcuts: () => new Map(), useAppCommandRunner: () => ({ diff --git a/apps/app/src/components/layout/AppLayout.sidebar-resize.test.tsx b/apps/app/src/components/layout/AppLayout.sidebar-resize.test.tsx index 55b7d18c2f..324eb462f0 100644 --- a/apps/app/src/components/layout/AppLayout.sidebar-resize.test.tsx +++ b/apps/app/src/components/layout/AppLayout.sidebar-resize.test.tsx @@ -10,6 +10,7 @@ const SIDEBAR_WIDTH_STORAGE_KEY = "bb.sidebar.width"; vi.mock("@/components/commands/AppCommandProvider", () => ({ useAppCommandHandler: () => {}, + useIndexedAppCommandHandlers: () => {}, useAppCommandShortcut: () => null, useAppCommandShortcuts: () => new Map(), useAppCommandRunner: () => ({ From 0019718926e35b9e5e15198bb9c786b53bd91f28 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Wed, 26 Aug 2026 14:03:15 -0700 Subject: [PATCH 6/6] Fix palette thread recents lifecycle order --- .../commands/CommandPalette.test.tsx | 60 ++++++++++++++++++- .../commands/ThreadSearchPaletteMode.tsx | 13 +++- .../palette-thread-search.test.ts | 22 +++++-- .../command-palette/palette-thread-search.ts | 11 +++- 4 files changed, 96 insertions(+), 10 deletions(-) diff --git a/apps/app/src/components/commands/CommandPalette.test.tsx b/apps/app/src/components/commands/CommandPalette.test.tsx index ad8276f4b1..b6fea971ba 100644 --- a/apps/app/src/components/commands/CommandPalette.test.tsx +++ b/apps/app/src/components/commands/CommandPalette.test.tsx @@ -90,6 +90,8 @@ function defaults(...commands: AppCommandId[]): AppDefaultKeybinding[] { const testState = vi.hoisted(() => ({ calls: [] as string[] })); const modeState = vi.hoisted(() => ({ + activeRecents: [] as ThreadListEntry[], + archivedRecents: [] as ThreadListEntry[], drafts: [] as NewThreadDraftRow[], searchResponse: undefined as ThreadSearchResponse | undefined, })); @@ -139,7 +141,19 @@ vi.mock("@/hooks/useNewThreadDraftSlots", () => ({ })); vi.mock("@/hooks/queries/sidebar-navigation-query", () => ({ - useSidebarNavigation: () => ({ data: undefined, isLoading: false }), + useSidebarNavigation: () => ({ + data: { + projects: [ + { + id: "project-1", + name: "Palette project", + threads: modeState.activeRecents, + }, + ], + personalProject: { id: "proj_personal", name: "Personal", threads: [] }, + }, + isLoading: false, + }), })); vi.mock("@/hooks/queries/thread-queries", async (importOriginal) => { @@ -147,6 +161,10 @@ vi.mock("@/hooks/queries/thread-queries", async (importOriginal) => { await importOriginal(); return { ...actual, + useArchivedThreads: () => ({ + data: { pages: [modeState.archivedRecents] }, + isLoading: false, + }), useThreadSearch: ({ query }: { query: string }) => ({ data: modeState.searchResponse, debouncedQuery: query.trim(), @@ -269,6 +287,8 @@ afterEach(() => { removePluginSlotRegistrations("linear"); resetPluginLogoStoreForTest(); testState.calls.length = 0; + modeState.activeRecents = []; + modeState.archivedRecents = []; modeState.drafts = []; modeState.searchResponse = undefined; openThreadInSplitMock.mockReset(); @@ -506,6 +526,44 @@ describe("CommandPalette", () => { ); }); + it("renders the resting thread mode as one unlabelled active, draft, archived list", async () => { + modeState.activeRecents = [makeThread("recent-active")]; + modeState.archivedRecents = [ + makeThread("recent-archived", { archivedAt: Date.now() }), + ]; + modeState.drafts = [ + { + id: "recent-draft", + title: "recent draft", + draft: { ...emptyPromptDraftState(), text: "recent draft" }, + lastEditedAt: Date.now(), + destination: { projectId: "project-1", sectionId: null }, + delete: vi.fn(), + }, + ]; + renderPalette(); + openThreadSearch(); + await waitFor(() => + expect( + screen.getByRole("combobox", { name: "Search threads" }), + ).toBeTruthy(), + ); + + const results = screen.getByRole("listbox", { name: "Threads" }); + await waitFor(() => + expect(within(results).getAllByRole("option")).toHaveLength(3), + ); + const rows = within(results).getAllByRole("option"); + expect(rows[0]?.textContent).toContain("Title recent-active"); + expect(rows[0]?.textContent).not.toContain("Active"); + expect(rows[1]?.textContent).toContain("recent draft"); + expect(rows[1]?.textContent).toContain("Draft"); + expect(rows[2]?.textContent).toContain("Title recent-archived"); + expect(rows[2]?.textContent).toContain("Archived"); + expect(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 archived = makeThread("archived", { archivedAt: Date.now() }); diff --git a/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx b/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx index 9027cdafc2..06146a2698 100644 --- a/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx +++ b/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx @@ -19,6 +19,7 @@ import { useNewThreadDraftSlots } from "@/hooks/useNewThreadDraftSlots"; import { useSidebarNavigation } from "@/hooks/queries/sidebar-navigation-query"; import { hasThreadSearchableQuery, + useArchivedThreads, useThreadSearch, } from "@/hooks/queries/thread-queries"; import { useRouteNavigate } from "@/components/ui/app-route-anchor"; @@ -53,6 +54,7 @@ export function ThreadSearchPaletteMode({ const [now] = useState(() => Date.now()); const drafts = useNewThreadDraftSlots(); const navigation = useSidebarNavigation(); + const archivedThreads = useArchivedThreads({}); const threadSearch = useThreadSearch({ active: true, query }); const trimmedQuery = query.trim(); const searchable = hasThreadSearchableQuery(trimmedQuery); @@ -76,6 +78,10 @@ export function ThreadSearchPaletteMode({ ], [navigation.data], ); + const recentArchivedThreads = useMemo( + () => archivedThreads.data?.pages.flatMap((page) => page) ?? [], + [archivedThreads.data], + ); const result = useMemo( () => buildPaletteThreadSearchRows({ @@ -83,6 +89,7 @@ export function ThreadSearchPaletteMode({ now, projectNamesById, query, + recentArchivedThreads, recentThreads, scope, searchResponse: threadSearch.data, @@ -93,6 +100,7 @@ export function ThreadSearchPaletteMode({ now, projectNamesById, query, + recentArchivedThreads, recentThreads, scope, searchResultsAreCurrent, @@ -206,7 +214,8 @@ export function ThreadSearchPaletteMode({ ? "Searching threads" : trimmedQuery.length === 1 ? "Type at least 2 characters" - : navigation.isLoading && result.isRecent + : (navigation.isLoading || archivedThreads.isLoading) && + result.isRecent ? "Loading recent threads" : result.isRecent ? "No recent threads" @@ -243,7 +252,7 @@ export function ThreadSearchPaletteMode({ placeholder={presentation.placeholder} value={query} > - {result.isRecent ? ( + {result.isRecent && result.rows.length === 0 ? (
Recent
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 a6fc2f9f20..f35a6e00a5 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 @@ -69,6 +69,7 @@ function build( now: NOW, projectNamesById: new Map([["project-1", "Palette project"]]), query: "match", + recentArchivedThreads: [], recentThreads: [], scope: "all", searchResponse: { @@ -155,13 +156,24 @@ describe("buildPaletteThreadSearchRows", () => { }); }); - it("labels only an empty query as recent and does not reuse recents for a one-character query", () => { - const recent = makeThread("recent"); - expect(build({ query: "", recentThreads: [recent] })).toMatchObject({ + it("orders active, draft, and archived recents and does not reuse them for a one-character query", () => { + const active = makeThread("recent-active"); + const archived = makeThread("recent-archived", { archivedAt: NOW - 1 }); + const recents = build({ + drafts: [makeDraft("recent-draft", "Recent draft")], + query: "", + recentArchivedThreads: [archived], + recentThreads: [active], + }); + expect(recents).toMatchObject({ isRecent: true, - rows: [{ id: "active:recent" }], + rows: [ + { id: "active:recent-active" }, + { id: "draft:recent-draft" }, + { id: "archived:recent-archived" }, + ], }); - expect(build({ query: "m", recentThreads: [recent] })).toMatchObject({ + expect(build({ query: "m", recentThreads: [active] })).toMatchObject({ isRecent: false, rows: [], }); 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 f89c4d04ea..6cfd3dd629 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 @@ interface BuildPaletteThreadSearchRowsArgs { now: number; projectNamesById: ReadonlyMap; query: string; + recentArchivedThreads: readonly ThreadListEntry[]; recentThreads: readonly ThreadListEntry[]; scope: PaletteThreadSearchScope; searchResponse: ThreadSearchResponse | undefined; @@ -137,6 +138,7 @@ export function buildPaletteThreadSearchRows({ now, projectNamesById, query, + recentArchivedThreads, recentThreads, scope, searchResponse, @@ -184,8 +186,13 @@ export function buildPaletteThreadSearchRows({ draftSlotId: item.id, messageSeq: null, })); - const archivedRows = - isSearchable && searchResultsAreCurrent + const archivedRows = isRecent + ? recentArchivedThreads + .slice(0, RECENT_THREAD_LIMIT) + .map((thread) => + serverRow(thread, [], "archived", projectNamesById, now), + ) + : isSearchable && searchResultsAreCurrent ? (searchResponse?.archived.results ?? []).map((result) => serverRow( result.thread,