onActivate(index)}
+ onSelect={row.drillIn ? onEnterThreads : undefined}
+ />
+ ));
+ }
+
+ let visibleIndex = 0;
+ return (["Threads", "Actions", "Plugins"] as const).map((bucket) => {
+ const bucketRows = rows.filter((row) => row.bucket === bucket);
+ if (bucketRows.length === 0) return null;
+ const startIndex = visibleIndex;
+ visibleIndex += bucketRows.length;
+ return (
+
+
+ {bucket}
+
+ {bucketRows.map((row, index) => {
+ const indexInList = startIndex + index;
+ return (
+
onActivate(indexInList)}
+ onSelect={row.drillIn ? onEnterThreads : undefined}
+ />
+ );
+ })}
+
+ );
+ });
+}
+
+function CommandOption({
+ id,
+ onActivate,
+ onSelect,
+ row,
+ selected,
+}: {
+ id: string;
+ onActivate: () => void;
+ onSelect?: () => void;
+ row: CommandRow;
+ selected: boolean;
+}) {
+ const metadata = row.group === row.bucket ? null : row.group;
+ return (
+
+ {row.label}
+
+ {metadata === null ? null : (
+ {metadata}
+ )}
+ {row.shortcut === undefined ? null : (
+ {row.shortcut}
+ )}
+
+ {row.drillIn ? (
+ Opens a search view
+ ) : null}
+
+ );
+}
+
+function ThreadResults({
+ onActivate,
+ rows,
+ selectedIndex,
+}: {
+ onActivate: (index: number) => void;
+ rows: readonly ThreadRow[];
+ selectedIndex: number;
+}) {
+ if (rows.length === 0) return No matching threads;
+ return rows.map((row, index) => {
+ const presentation = THREAD_STATE[row.state];
+ return (
+ onActivate(index)}
+ >
+
+ {row.title}
+
+ {row.metadata}
+
+
+ {row.state === "active" ? null : (
+
+ {presentation.label}
+
+ )}
+
+ );
+ });
+}
+
+function EmptyMessage({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+function PaletteFooter({ mode }: { mode: Mode }) {
+ const hints =
+ mode === "threads"
+ ? ([
+ { keys: ["↑↓"], label: "Select" },
+ { keys: ["↵"], label: "Open" },
+ { keys: ["⌘↵"], label: "Split" },
+ { keys: ["Backspace", "Esc"], label: "Back" },
+ ] as const)
+ : ([
+ { keys: ["↑↓"], label: "Select" },
+ { keys: ["↵"], label: "Run" },
+ ] as const);
+ return (
+
+ {hints.map((hint) => (
+
+
+ {hint.keys.map((keys, index) => (
+
+ {index === 0 ? null : (
+
+ /
+
+ )}
+
+ {keys}
+
+
+ ))}
+
+ {hint.label}
+
+ ))}
+
+ );
+}
+
+export function Review() {
+ return ;
+}
diff --git a/apps/app/src/components/commands/PaletteShell.tsx b/apps/app/src/components/commands/PaletteShell.tsx
new file mode 100644
index 0000000000..73d6e25048
--- /dev/null
+++ b/apps/app/src/components/commands/PaletteShell.tsx
@@ -0,0 +1,180 @@
+import type { KeyboardEventHandler, ReactNode, Ref } from "react";
+import { useComposedRefs } from "@radix-ui/react-compose-refs";
+import { Icon } from "@bb/shared-ui/icon";
+import { useScrollOverflowState } from "@/components/thread/timeline/useScrollOverflowState";
+import { TabPill } from "@/components/ui/tab-pill";
+
+export const PALETTE_FOOTER_CONTROL_SURFACE_CLASS =
+ "border border-border/70 bg-background/70 shadow-xs";
+export const PALETTE_FOOTER_KEYCAP_CLASS = `inline-flex min-w-5 items-center justify-center rounded px-1.5 py-0.5 font-mono text-xs leading-none text-muted-foreground ${PALETTE_FOOTER_CONTROL_SURFACE_CLASS}`;
+export const PALETTE_FOOTER_LABEL_CLASS = "text-subtle-foreground opacity-70";
+
+interface PaletteModeChipProps {
+ clearLabel: string;
+ icon: Parameters[0]["name"];
+ label: string;
+ onClear: () => void;
+}
+
+interface PaletteShellProps {
+ activeDescendantId?: string;
+ accessory?: ReactNode;
+ children: ReactNode;
+ footerKeys: readonly { keys: readonly string[]; label: string }[];
+ inputLabel: string;
+ inputRef?: Ref;
+ listId: string;
+ listLabel: string;
+ listRef?: Ref;
+ modeChip?: PaletteModeChipProps;
+ onInputChange: (value: string) => void;
+ onInputKeyDown: KeyboardEventHandler;
+ placeholder: string;
+ value: string;
+}
+
+export function PaletteShell({
+ activeDescendantId,
+ accessory,
+ children,
+ footerKeys,
+ inputLabel,
+ inputRef,
+ listId,
+ listLabel,
+ listRef,
+ modeChip,
+ onInputChange,
+ onInputKeyDown,
+ placeholder,
+ value,
+}: PaletteShellProps) {
+ const overflow = useScrollOverflowState({
+ measureOverflow: true,
+ });
+ const composedListRef = useComposedRefs(listRef, overflow.scrollRef);
+ const resultsMask =
+ overflow.aboveOverflow && overflow.belowOverflow
+ ? "linear-gradient(to bottom, transparent 0, black 1.5rem, black calc(100% - 1.5rem), transparent 100%)"
+ : overflow.aboveOverflow
+ ? "linear-gradient(to bottom, transparent 0, black 1.5rem, black 100%)"
+ : overflow.belowOverflow
+ ? "linear-gradient(to bottom, black 0, black calc(100% - 1.5rem), transparent 100%)"
+ : undefined;
+
+ return (
+ <>
+
+
+ {modeChip === undefined ? null :
}
+
onInputChange(event.target.value)}
+ onKeyDown={onInputKeyDown}
+ />
+ {accessory}
+
+
+
+
+ {footerKeys.map((hint) => (
+
+
+ {hint.keys.map((keys, index) => (
+
+ {index === 0 ? null : (
+
+ /
+
+ )}
+ {keys}
+
+ ))}
+
+
+ {hint.label}
+
+
+ ))}
+
+ >
+ );
+}
+
+function PaletteModeChip({
+ clearLabel,
+ icon,
+ label,
+ onClear,
+}: PaletteModeChipProps) {
+ return (
+
+ undefined}
+ leadingVisual={}
+ closeAction={{ onClose: onClear, closeLabel: clearLabel }}
+ />
+
+ );
+}
diff --git a/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx b/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx
new file mode 100644
index 0000000000..ada14a68c4
--- /dev/null
+++ b/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx
@@ -0,0 +1,534 @@
+import {
+ useCallback,
+ useEffect,
+ useId,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+ type KeyboardEvent as ReactKeyboardEvent,
+ type ReactNode,
+} from "react";
+import { useStore } from "jotai";
+import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport";
+import { cn } from "@bb/shared-ui/lib/utils";
+import { COARSE_POINTER_TEXT_SM_CLASS } from "@bb/shared-ui/coarse-pointer-sizing";
+import { isPromptDraftEmpty } from "@bb/client-core";
+import type { ThreadSearchHighlightRange } from "@bb/server-contract";
+import { usePromptDraftStorage } from "@/hooks/usePromptDraftStorage";
+import { useSidebarNavigation } from "@/hooks/queries/sidebar-navigation-query";
+import {
+ hasThreadSearchableQuery,
+ useArchivedThreads,
+ useThreadSearch,
+} from "@/hooks/queries/thread-queries";
+import { useRouteNavigate } from "@/components/ui/app-route-anchor";
+import { getRootComposeRoutePath, getThreadRoutePath } from "@/lib/route-paths";
+import { useRootComposeProjectId } from "@/lib/root-compose-selection";
+import { openPaneContentInSplit } from "@/lib/split-layout/openPaneContentInSplit";
+import { openThreadInSplit } from "@/lib/split-layout/openThreadInSplit";
+import {
+ buildPaletteThreadSearchRows,
+ PALETTE_THREAD_SEARCH_SCOPES,
+ type PaletteThreadSearchRow,
+ type PaletteThreadSearchScope,
+} from "@/lib/command-palette/palette-thread-search";
+import { windowPaletteThreadSearchText } from "@/lib/command-palette/palette-thread-search-window";
+import type { PaletteModeViewProps } from "@/lib/command-palette/palette-mode";
+import { PALETTE_FOOTER_LABEL_CLASS, PaletteShell } from "./PaletteShell";
+
+export function ThreadSearchPaletteMode({
+ onExit,
+ presentation,
+ runAfterClose,
+}: PaletteModeViewProps) {
+ const listId = useId();
+ const optionIdPrefix = useId();
+ const inputRef = useRef(null);
+ const listRef = useRef(null);
+ const store = useStore();
+ const navigate = useRouteNavigate();
+ const isCompact = useIsCompactViewport();
+ const [query, setQuery] = useState("");
+ const [scope, setScope] = useState("all");
+ const [highlightedIndex, setHighlightedIndex] = useState(0);
+ const [now] = useState(() => Date.now());
+ const rootComposeDraft = usePromptDraftStorage({ kind: "new-thread" });
+ const [rootComposeProjectId] = useRootComposeProjectId();
+ const drafts = useMemo(() => {
+ const draft = {
+ text: rootComposeDraft.text,
+ mentions: rootComposeDraft.mentions,
+ attachments: rootComposeDraft.attachments,
+ };
+ if (isPromptDraftEmpty(draft)) return [];
+ const title = draft.text.replace(/\s+/gu, " ").trim();
+ return [
+ {
+ id: "root-compose",
+ draft,
+ title: title.length > 0 ? title : "New thread",
+ lastEditedAt: null,
+ destination: {
+ projectId: rootComposeProjectId,
+ sectionId: null,
+ },
+ },
+ ];
+ }, [
+ rootComposeDraft.attachments,
+ rootComposeDraft.mentions,
+ rootComposeDraft.text,
+ rootComposeProjectId,
+ ]);
+ const navigation = useSidebarNavigation();
+ const archivedThreads = useArchivedThreads({});
+ const threadSearch = useThreadSearch({ active: true, query });
+ const trimmedQuery = query.trim();
+ const searchable = hasThreadSearchableQuery(trimmedQuery);
+ const searchResultsAreCurrent =
+ !searchable || threadSearch.debouncedQuery === trimmedQuery;
+
+ const projectNamesById = useMemo(() => {
+ const entries = [
+ ...(navigation.data?.projects ?? []),
+ ...(navigation.data === undefined
+ ? []
+ : [navigation.data.personalProject]),
+ ].map((project) => [project.id, project.name] as const);
+ return new Map(entries);
+ }, [navigation.data]);
+ const recentThreads = useMemo(
+ () => [
+ ...(navigation.data?.projects.flatMap((project) => project.threads) ??
+ []),
+ ...(navigation.data?.personalProject.threads ?? []),
+ ],
+ [navigation.data],
+ );
+ const recentArchivedThreads = useMemo(
+ () => archivedThreads.data?.pages.flatMap((page) => page) ?? [],
+ [archivedThreads.data],
+ );
+ const result = useMemo(
+ () =>
+ buildPaletteThreadSearchRows({
+ drafts,
+ now,
+ projectNamesById,
+ query,
+ recentArchivedThreads,
+ recentThreads,
+ scope,
+ searchResponse: threadSearch.data,
+ searchResultsAreCurrent,
+ }),
+ [
+ drafts,
+ now,
+ projectNamesById,
+ query,
+ recentArchivedThreads,
+ recentThreads,
+ scope,
+ searchResultsAreCurrent,
+ threadSearch.data,
+ ],
+ );
+ const activeIndex =
+ result.rows.length === 0
+ ? -1
+ : Math.min(highlightedIndex, result.rows.length - 1);
+ const activeDescendantId =
+ activeIndex < 0 ? undefined : `${optionIdPrefix}-${activeIndex}`;
+
+ const scrollOnNextHighlightRef = useRef(false);
+ useEffect(() => {
+ if (!scrollOnNextHighlightRef.current) return;
+ scrollOnNextHighlightRef.current = false;
+ listRef.current
+ ?.querySelector('[aria-selected="true"]')
+ ?.scrollIntoView({ block: "nearest" });
+ }, [activeIndex]);
+
+ const openRow = useCallback(
+ (row: PaletteThreadSearchRow, split: boolean) => {
+ runAfterClose(() => {
+ if (row.threadId !== null) {
+ const state =
+ row.messageSeq === null
+ ? undefined
+ : {
+ searchMessageSeq: row.messageSeq,
+ searchThreadId: row.threadId,
+ };
+ if (split) {
+ openThreadInSplit({
+ store,
+ navigate,
+ projectId: row.projectId,
+ threadId: row.threadId,
+ isCompact,
+ state,
+ });
+ return;
+ }
+ navigate(
+ getThreadRoutePath({
+ projectId: row.projectId,
+ threadId: row.threadId,
+ }),
+ { state },
+ );
+ return;
+ }
+ if (row.draftSlotId !== null) {
+ if (split) {
+ openPaneContentInSplit({
+ store,
+ navigate,
+ content: { kind: "new-thread" },
+ route: getRootComposeRoutePath(),
+ enabled: !isCompact,
+ });
+ return;
+ }
+ navigate(getRootComposeRoutePath(), { state: { focusPrompt: true } });
+ }
+ });
+ },
+ [isCompact, navigate, runAfterClose, store],
+ );
+
+ const handleInputKeyDown = useCallback(
+ (event: ReactKeyboardEvent) => {
+ if (event.key === "Backspace" && query.length === 0) {
+ event.preventDefault();
+ event.stopPropagation();
+ onExit();
+ return;
+ }
+ if (event.key === "Escape") {
+ event.preventDefault();
+ event.stopPropagation();
+ onExit();
+ return;
+ }
+ if (result.rows.length === 0) return;
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
+ event.preventDefault();
+ scrollOnNextHighlightRef.current = true;
+ setHighlightedIndex((current) => {
+ if (event.key === "ArrowDown") {
+ return current + 1 >= result.rows.length ? 0 : current + 1;
+ }
+ return current <= 0 ? result.rows.length - 1 : current - 1;
+ });
+ return;
+ }
+ if (event.key === "Home" || event.key === "End") {
+ event.preventDefault();
+ scrollOnNextHighlightRef.current = true;
+ setHighlightedIndex(event.key === "Home" ? 0 : result.rows.length - 1);
+ return;
+ }
+ if (event.key === "Enter") {
+ const row = result.rows[activeIndex];
+ if (row === undefined) return;
+ event.preventDefault();
+ openRow(row, event.metaKey || event.ctrlKey);
+ }
+ },
+ [activeIndex, onExit, openRow, query.length, result.rows],
+ );
+
+ const isLoading =
+ searchable &&
+ (!searchResultsAreCurrent ||
+ threadSearch.isDebouncing ||
+ threadSearch.isLoading);
+ let emptyMessage: string | null = null;
+ if (result.rows.length === 0) {
+ emptyMessage = isLoading
+ ? "Searching threads"
+ : trimmedQuery.length === 1
+ ? "Type at least 2 characters"
+ : (navigation.isLoading || archivedThreads.isLoading) && result.isRecent
+ ? "Loading recent threads"
+ : result.isRecent
+ ? "No recent threads"
+ : "No matching threads";
+ }
+
+ return (
+ {
+ setScope(nextScope);
+ setHighlightedIndex(0);
+ if (listRef.current !== null) listRef.current.scrollTop = 0;
+ }}
+ />
+ }
+ footerKeys={presentation.footerKeys}
+ inputLabel="Search threads"
+ inputRef={inputRef}
+ listId={listId}
+ listLabel="Threads"
+ listRef={listRef}
+ modeChip={{
+ ...presentation.chip,
+ clearLabel: "Return to commands",
+ onClear: onExit,
+ }}
+ onInputChange={(value) => {
+ setQuery(value);
+ setHighlightedIndex(0);
+ if (listRef.current !== null) listRef.current.scrollTop = 0;
+ }}
+ onInputKeyDown={handleInputKeyDown}
+ placeholder={presentation.placeholder}
+ value={query}
+ >
+ {emptyMessage === null ? (
+ result.rows.map((row, index) => (
+ setHighlightedIndex(index)}
+ onSelect={() => openRow(row, false)}
+ />
+ ))
+ ) : (
+
+ {emptyMessage}
+
+ )}
+
+ );
+}
+
+function ThreadSearchScopeFilter({
+ inputRef,
+ onScopeChange,
+ scope,
+}: {
+ inputRef: React.RefObject;
+ onScopeChange: (scope: PaletteThreadSearchScope) => void;
+ scope: PaletteThreadSearchScope;
+}) {
+ const [open, setOpen] = useState(false);
+ const currentIndex = PALETTE_THREAD_SEARCH_SCOPES.findIndex(
+ (candidate) => candidate.id === scope,
+ );
+ const current = PALETTE_THREAD_SEARCH_SCOPES[currentIndex];
+ const returnToInput = () => {
+ setOpen(false);
+ inputRef.current?.focus({ preventScroll: true });
+ };
+ const cycle = (direction: 1 | -1) => {
+ const nextIndex =
+ (currentIndex + direction + PALETTE_THREAD_SEARCH_SCOPES.length) %
+ PALETTE_THREAD_SEARCH_SCOPES.length;
+ const next = PALETTE_THREAD_SEARCH_SCOPES[nextIndex];
+ if (next !== undefined) onScopeChange(next.id);
+ setOpen(true);
+ };
+
+ return (
+
+
+ {open ? (
+
+ {PALETTE_THREAD_SEARCH_SCOPES.map((option) => (
+
event.preventDefault()}
+ onClick={() => {
+ onScopeChange(option.id);
+ returnToInput();
+ }}
+ >
+ {option.label}
+
+ ))}
+
+ ) : null}
+
+ );
+}
+
+function ThreadSearchPaletteRow({
+ id,
+ isActive,
+ onActivate,
+ onSelect,
+ row,
+}: {
+ id: string;
+ isActive: boolean;
+ onActivate: () => void;
+ onSelect: () => void;
+ row: PaletteThreadSearchRow;
+}) {
+ const primaryRef = useRef(null);
+ const matchKey = `${row.primaryText}\u0000${row.highlightRanges
+ .map((range) => `${range.start}:${range.end}`)
+ .join(",")}`;
+ const [windowedMatchKey, setWindowedMatchKey] = useState(null);
+ const shouldWindowMatch = windowedMatchKey === matchKey;
+ const primary = shouldWindowMatch
+ ? windowPaletteThreadSearchText({
+ text: row.primaryText,
+ highlightRanges: row.highlightRanges,
+ })
+ : { text: row.primaryText, highlightRanges: row.highlightRanges };
+
+ useLayoutEffect(() => {
+ if (shouldWindowMatch || row.highlightRanges.length === 0) return;
+ const container = primaryRef.current;
+ if (container === null) return;
+ const firstMatch = container.querySelector("mark");
+ if (firstMatch === null) return;
+ const containerRect = container.getBoundingClientRect();
+ const matchRect = firstMatch.getBoundingClientRect();
+ if (
+ matchRect.left < containerRect.left ||
+ matchRect.right > containerRect.right
+ ) {
+ setWindowedMatchKey(matchKey);
+ }
+ }, [matchKey, row.highlightRanges.length, shouldWindowMatch]);
+
+ const stateLabel =
+ row.lifecycle === "active"
+ ? null
+ : row.lifecycle === "draft"
+ ? "Draft"
+ : "Archived";
+ return (
+
+
+
+
+
+
+ {row.metadataText}
+
+
+ {stateLabel === null ? null : (
+
+ {stateLabel}
+
+ )}
+
+ );
+}
+
+function HighlightedText({
+ ranges,
+ text,
+}: {
+ ranges: readonly ThreadSearchHighlightRange[];
+ text: string;
+}) {
+ if (ranges.length === 0) return <>{text}>;
+ const nodes: ReactNode[] = [];
+ let cursor = 0;
+ for (const range of ranges) {
+ const start = Math.max(cursor, Math.min(range.start, text.length));
+ const end = Math.max(start, Math.min(range.end, text.length));
+ if (end <= start) continue;
+ if (start > cursor) nodes.push(text.slice(cursor, start));
+ nodes.push(
+
+ {text.slice(start, end)}
+ ,
+ );
+ cursor = end;
+ }
+ if (cursor < text.length) nodes.push(text.slice(cursor));
+ return nodes;
+}
diff --git a/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx b/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx
index 1839a45e95..87f131a54e 100644
--- a/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx
+++ b/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx
@@ -16,6 +16,7 @@ vi.mock("@/components/commands/AppCommandProvider", () => ({
useAppCommandHandler: (command: string, handler: () => boolean) => {
commandHandlers.set(command, handler);
},
+ useIndexedAppCommandHandlers: () => {},
useAppCommandShortcut: () => null,
useAppCommandShortcuts: () => new Map(),
useAppCommandRunner: () => ({
diff --git a/apps/app/src/components/layout/AppLayout.sidebar-resize.test.tsx b/apps/app/src/components/layout/AppLayout.sidebar-resize.test.tsx
index a9fe9b2272..f2f2fc6249 100644
--- a/apps/app/src/components/layout/AppLayout.sidebar-resize.test.tsx
+++ b/apps/app/src/components/layout/AppLayout.sidebar-resize.test.tsx
@@ -10,6 +10,7 @@ const SIDEBAR_WIDTH_STORAGE_KEY = "bb.sidebar.width";
vi.mock("@/components/commands/AppCommandProvider", () => ({
useAppCommandHandler: () => {},
+ useIndexedAppCommandHandlers: () => {},
useAppCommandShortcut: () => null,
useAppCommandShortcuts: () => new Map(),
useAppCommandRunner: () => ({
diff --git a/apps/app/src/components/ui/tab-pill.tsx b/apps/app/src/components/ui/tab-pill.tsx
index 99ffdb98d5..7bdc0c4a94 100644
--- a/apps/app/src/components/ui/tab-pill.tsx
+++ b/apps/app/src/components/ui/tab-pill.tsx
@@ -23,6 +23,7 @@ interface TabPillCloseAction {
interface TabPillProps {
label: string;
+ className?: string;
ariaLabel?: string;
ariaKeyshortcuts?: string;
iconOnly?: boolean;
@@ -39,6 +40,7 @@ interface TabPillProps {
export function TabPill({
label,
+ className,
ariaLabel,
ariaKeyshortcuts,
iconOnly = false,
@@ -72,6 +74,7 @@ export function TabPill({
isActive
? cn(CONTEXT_SELECTION_SURFACE_CLASS, "text-foreground")
: "text-muted-foreground hover:bg-state-hover",
+ className,
)}
>