diff --git a/apps/app/src/components/commands/CommandPalette.test.tsx b/apps/app/src/components/commands/CommandPalette.test.tsx index 1b863daf99..49ee9033ce 100644 --- a/apps/app/src/components/commands/CommandPalette.test.tsx +++ b/apps/app/src/components/commands/CommandPalette.test.tsx @@ -11,6 +11,8 @@ import { } from "@testing-library/react"; import { MemoryRouter, useLocation } from "react-router-dom"; import { createStore, Provider } from "jotai"; +import { paletteThreadLifecyclesAtom } from "@/lib/command-palette/palette-preferences"; +import { sidebarThreadLifecyclesAtom } from "@/components/sidebar/sidebarCollapsedAtoms"; import { splitLayoutAtom } from "@/lib/split-layout/atoms"; import { MAX_PANES, type SplitLayout } from "@/lib/split-layout"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -23,6 +25,7 @@ import { type ThreadListEntry, } from "@bb/domain"; import type { ThreadSearchResponse } from "@bb/server-contract"; +import type { ThreadArchiveFilter } from "@/lib/thread-lifecycle-filter"; import { CompactViewportOverrideProvider } from "@bb/shared-ui/hooks/use-compact-viewport"; import { AppCommandProvider, useAppCommandHandler } from "./AppCommandProvider"; import { @@ -112,6 +115,7 @@ const testState = vi.hoisted(() => ({ })); const modeState = vi.hoisted(() => ({ activeRecents: [] as ThreadListEntry[], + archivedRecents: [] as ThreadListEntry[], threadDraftIds: new Set(), searchResponse: undefined as ThreadSearchResponse | undefined, recentLoading: false, @@ -233,6 +237,14 @@ vi.mock("@/hooks/queries/sidebar-navigation-query", () => ({ }), })); +vi.mock("@/hooks/queries/palette-thread-queries", () => ({ + usePaletteRecentArchivedThreads: () => ({ + data: modeState.archivedRecents, + isLoading: false, + isError: false, + }), +})); + vi.mock("@/hooks/queries/thread-queries", async (importOriginal) => { const actual = await importOriginal(); @@ -315,12 +327,15 @@ function makeThread( function renderPalette({ compact = false, layout = null, + lifecycles = ["active"], }: { compact?: boolean; layout?: SplitLayout | null; + lifecycles?: ThreadArchiveFilter[]; } = {}) { const store = createStore(); store.set(splitLayoutAtom, layout); + store.set(paletteThreadLifecyclesAtom, lifecycles); const result = render( @@ -394,6 +409,7 @@ afterEach(() => { testState.showKeyboardHints = true; testState.plugins.length = 0; modeState.activeRecents = []; + modeState.archivedRecents = []; modeState.threadDraftIds.clear(); modeState.searchResponse = undefined; modeState.recentLoading = false; @@ -487,6 +503,15 @@ describe("CommandPalette", () => { expect( screen.queryByRole("button", { name: "Open in split" }), ).toBeNull(); + if (reason === "compact") { + expect(document.querySelector("kbd")).toBeNull(); + const close = screen.getByRole("button", { + name: "Return to commands", + }); + act(() => close.focus()); + expectText(await screen.findByRole("tooltip"), "Return to commands"); + expect(screen.getByRole("tooltip").textContent).not.toContain("Esc"); + } }, ); @@ -540,7 +565,7 @@ describe("CommandPalette", () => { ], }, }; - renderPalette({ layout: splitLayout }); + renderPalette({ layout: splitLayout, lifecycles: ["archived"] }); openThreadSearch(); await screen.findByRole("combobox", { name: "Search threads" }); fireEvent.change(searchField(), { target: { value: "matching" } }); @@ -990,6 +1015,7 @@ describe("CommandPalette", () => { it("shows active recents in update order with project metadata and follow-up status", async () => { modeState.activeRecents = [ + makeThread("saved-draft", { status: "pending", updatedAt: 1 }), makeThread("older", { updatedAt: Date.now() - 100 }), makeThread("newer", { updatedAt: Date.now(), lastReadAt: Date.now() }), ]; @@ -1014,6 +1040,7 @@ describe("CommandPalette", () => { expect(rows.map((row) => row.textContent)).toEqual([ expect.stringContaining("Title newer"), expect.stringContaining("Title older"), + expect.stringContaining("Title saved-draft"), ]); expect(screen.queryByRole("button", { name: "Thread scope" })).toBeNull(); expect( @@ -1022,9 +1049,9 @@ describe("CommandPalette", () => { }), ).toBeTruthy(); expect(rows[0].querySelector('[data-icon="Edit"]')).not.toBeNull(); - expect(results.querySelectorAll('[data-icon="Folder"]')).toHaveLength(2); + expect(results.querySelectorAll('[data-icon="Folder"]')).toHaveLength(3); expectClasses(results, "p-1"); - expectClasses(within(results).getByText("Recent"), "px-2", "py-1"); + expectClasses(within(results).getByText("Active"), "px-2", "py-1"); for (const row of rows) { const metadata = row.querySelector("[data-palette-thread-metadata]"); expectText(metadata, "Palette project"); @@ -1033,6 +1060,108 @@ describe("CommandPalette", () => { } }); + it("keeps the highlighted thread across filter changes and clamps it when removed", async () => { + modeState.activeRecents = [ + makeThread("first"), + makeThread("selected", { updatedAt: 1 }), + ]; + modeState.archivedRecents = [makeThread("archived", { archivedAt: 1 })]; + const { store } = renderPalette(); + openThreadSearch(); + const input = await screen.findByRole("combobox", { + name: "Search threads", + }); + fireEvent.keyDown(input, { key: "ArrowDown" }); + expectText(selectedOption(), "Title selected"); + act(() => + store.set(paletteThreadLifecyclesAtom, ["archived", "active"]), + ); + expect( + screen + .getAllByRole("group") + .map((group) => group.textContent?.split("Title")[0]), + ).toEqual(["Active", "Archived"]); + expectText(selectedOption(), "Title selected"); + act(() => store.set(paletteThreadLifecyclesAtom, ["archived"])); + expectText(selectedOption(), "Title archived"); + expect(input.getAttribute("aria-activedescendant")).toBe( + selectedOption()?.id, + ); + expect(store.get(sidebarThreadLifecyclesAtom)).toEqual(["active"]); + }); + + it("operates the lifecycle filter with the keyboard without selecting a result", async () => { + modeState.activeRecents = [makeThread("active")]; + const { store } = renderPalette(); + openThreadSearch(); + await screen.findByRole("combobox", { name: "Search threads" }); + const trigger = screen.getByRole("button", { + name: "Filter: Active", + }); + act(() => trigger.focus()); + fireEvent.keyDown(trigger, { key: "ArrowDown" }); + const archived = await screen.findByRole("menuitemcheckbox", { + name: "Archived", + }); + act(() => archived.focus()); + fireEvent.keyDown(archived, { key: "Enter" }); + expect(store.get(paletteThreadLifecyclesAtom)).toEqual(["active", "archived"]); + expectText(trigger, "All"); + expect(trigger.getAttribute("aria-label")).toBe("Filter: All"); + expect(routeNavigateMock).not.toHaveBeenCalled(); + fireEvent.keyDown(archived, { key: "Escape" }); + await waitFor(() => expect(screen.queryByRole("menu")).toBeNull()); + expect( + screen.getByRole("combobox", { name: "Search threads" }), + ).toBeTruthy(); + }); + + it.each(["", "match"])( + "keeps saved messages with Active and budgets two groups for query '%s'", + async (query) => { + const active = Array.from({ length: 7 }, (_, index) => + makeThread(`active-${index}`, { status: index === 1 ? "pending" : "idle" }), + ); + const archived = Array.from({ length: 4 }, (_, index) => + makeThread(`archived-${index}`, { archivedAt: 1 }), + ); + modeState.activeRecents = active; + modeState.archivedRecents = archived; + modeState.searchResponse = { + active: { total: 7, results: active.map((thread) => ({ thread, matches: [] })) }, + archived: { total: 4, results: archived.map((thread) => ({ thread, matches: [] })) }, + }; + const { store } = renderPalette({ lifecycles: ["active", "archived"] }); + openThreadSearch(); + const input = await screen.findByRole("combobox", { name: "Search threads" }); + fireEvent.change(input, { target: { value: query } }); + expect(screen.queryByRole("group", { name: "Drafts" })).toBeNull(); + for (const name of ["Active", "Archived"]) { + expect(within(screen.getByRole("group", { name })).getAllByRole("option")).toHaveLength(4); + } + fireEvent.click(screen.getByRole("option", { name: "Show more threads" })); + expect(within(screen.getByRole("group", { name: "Active" })).getAllByRole("option")).toHaveLength(7); + expect(within(screen.getByRole("group", { name: "Archived" })).getAllByRole("option")).toHaveLength(4); + expectText(selectedOption(), "Title active-3"); + act(() => store.set(paletteThreadLifecyclesAtom, ["active"])); + expect(screen.getByRole("option", { name: "Show more threads" })).toBeTruthy(); + expect(document.querySelector("[data-palette-footer]")).toBeNull(); + }, + ); + + it("uses the shared empty treatment for selected populations and search with no matches", async () => { + renderPalette({ lifecycles: ["active", "archived"] }); + openThreadSearch(); + const input = await screen.findByRole("combobox", { + name: "Search threads", + }); + expect(screen.getByText("No threads")).toBeTruthy(); + fireEvent.change(input, { target: { value: "unmatched" } }); + expect(screen.getByText("No matching threads")).toBeTruthy(); + expect(screen.queryAllByRole("option")).toHaveLength(0); + expect(screen.queryByRole("button", { name: /create/i })).toBeNull(); + }); + it("groups lifecycle with headings while preserving highlights and attention status", async () => { const active = makeThread("active", { title: "Matching active thread", @@ -1058,7 +1187,7 @@ describe("CommandPalette", () => { }, archived: { total: 1, results: [{ thread: archived, matches: [] }] }, }; - renderPalette(); + renderPalette({ lifecycles: ["active", "archived"] }); openThreadSearch(); const input = await screen.findByRole("combobox", { name: "Search threads", @@ -1075,11 +1204,11 @@ describe("CommandPalette", () => { within(rows[1]).getByRole("img", { name: "Unread thread succeeded" }), ).toBeTruthy(); expect(results.querySelector('[data-icon="Archive"]')).toBeNull(); - expectClasses(within(results).getByText("Threads"), "px-2", "py-1"); + expectClasses(within(results).getByText("Active"), "px-2", "py-1"); expectClasses(within(results).getByText("Archived"), "px-2", "py-1"); const groups = within(results).getAllByRole("group"); expect(groups).toHaveLength(2); - for (const [index, name] of ["Threads", "Archived"].entries()) { + for (const [index, name] of ["Active", "Archived"].entries()) { const group = within(results).getByRole("group", { name }); expect(within(group).getAllByRole("option")).toEqual([rows[index]]); const label = within(group).getByText(name); @@ -1119,15 +1248,15 @@ describe("CommandPalette", () => { results: archived.map((thread) => ({ thread, matches: [] })), }, }; - renderPalette(); + renderPalette({ lifecycles: ["active", "archived"] }); openThreadSearch(); const input = await screen.findByRole("combobox", { name: "Search threads", }); - expect(screen.getAllByRole("option")).toHaveLength(8); - expect(screen.queryByText("Show more")).toBeNull(); + expect(screen.getAllByRole("option")).toHaveLength(7); + expect(screen.getByText("Show more")).toBeTruthy(); fireEvent.change(input, { target: { value: "match" } }); - const activeGroup = screen.getByRole("group", { name: "Threads" }); + const activeGroup = screen.getByRole("group", { name: "Active" }); const archivedGroup = screen.getByRole("group", { name: "Archived" }); expect( within(activeGroup) @@ -1147,7 +1276,7 @@ describe("CommandPalette", () => { expectClasses(more, "text-xs", "text-subtle-foreground"); expectNoClasses(more, "font-medium"); expectClasses( - within(activeGroup).getByText("Threads", { selector: "div" }), + within(activeGroup).getByText("Active", { selector: "div" }), "text-xs", "font-normal", "text-subtle-foreground", @@ -1184,9 +1313,9 @@ describe("CommandPalette", () => { within(archivedGroup).getAllByRole("option")[3].id, ); fireEvent.change(input, { target: { value: "" } }); - expect(screen.getByRole("group", { name: "Recent" })).toBeTruthy(); - expect(screen.getAllByRole("option")).toHaveLength(8); - expect(screen.queryByText("Show more")).toBeNull(); + expect(screen.getByRole("group", { name: "Active" })).toBeTruthy(); + expect(screen.getAllByRole("option")).toHaveLength(7); + expect(screen.getByText("Show more")).toBeTruthy(); }); it.each(["active", "archived"] as const)( @@ -1205,7 +1334,7 @@ describe("CommandPalette", () => { results: threads.map((thread) => ({ thread, matches: [] })), }, }; - renderPalette(); + renderPalette({ lifecycles: [lifecycle] }); openThreadSearch(); const input = await screen.findByRole("combobox", { name: "Search threads", @@ -1246,7 +1375,7 @@ describe("CommandPalette", () => { ], }, }; - renderPalette(); + renderPalette({ lifecycles: ["active", "archived"] }); openThreadSearch(); const input = await screen.findByRole("combobox", { name: "Search threads", @@ -1274,10 +1403,10 @@ describe("CommandPalette", () => { expect( within(results).getByRole("option").getAttribute("aria-selected"), ).toBe("true"); - expect(within(results).getByText("Recent")).toBeTruthy(); + expect(within(results).getByText("Active")).toBeTruthy(); expect(within(results).getAllByRole("group")).toHaveLength(1); expect( - within(within(results).getByRole("group", { name: "Recent" })).getByRole( + within(within(results).getByRole("group", { name: "Active" })).getByRole( "option", ), ).toBe(within(results).getByRole("option")); @@ -1375,10 +1504,7 @@ describe("CommandPalette", () => { row.querySelector("[data-palette-thread-metadata]")?.textContent, ).toContain("Palette project"); } - expect(within(results).queryByText("Recent") !== null).toBe(query === ""); - expect( - within(results).queryByText("Threads", { exact: true }) !== null, - ).toBe(query !== ""); + expect(within(results).getByText("Active")).toBeTruthy(); expect(within(results).queryByText("Archived")).toBeNull(); }, ); @@ -1444,7 +1570,7 @@ describe("CommandPalette", () => { ], }, }; - renderPalette(); + renderPalette({ lifecycles: ["archived"] }); openThreadSearch(); const input = await screen.findByRole("combobox", { name: "Search threads", diff --git a/apps/app/src/components/commands/PaletteShell.tsx b/apps/app/src/components/commands/PaletteShell.tsx index 19ee8ca3f4..51c813cfe2 100644 --- a/apps/app/src/components/commands/PaletteShell.tsx +++ b/apps/app/src/components/commands/PaletteShell.tsx @@ -18,6 +18,7 @@ interface PaletteModeChipProps { icon: Parameters[0]["name"]; label: string; onClear: () => void; + hideShortcut?: boolean; } interface PaletteShellProps { @@ -30,6 +31,7 @@ interface PaletteShellProps { listLabel: string; listRef?: Ref; modeChip?: PaletteModeChipProps; + inputAccessory?: ReactNode; onInputChange: (value: string) => void; onInputKeyDown: KeyboardEventHandler; placeholder: string; @@ -46,6 +48,7 @@ export function PaletteShell({ listLabel, listRef, modeChip, + inputAccessory, onInputChange, onInputKeyDown, placeholder, @@ -93,6 +96,7 @@ export function PaletteShell({ {inputDescription} + {inputAccessory}
diff --git a/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx b/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx index 207ad0ce45..b2e4c85142 100644 --- a/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx +++ b/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx @@ -9,7 +9,7 @@ import { type KeyboardEvent as ReactKeyboardEvent, type ReactNode, } from "react"; -import { useAtomValue, useStore } from "jotai"; +import { useAtom, useAtomValue, useStore } from "jotai"; import { isMacKeyboardPlatform } from "@bb/domain"; import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport"; import { Icon } from "@bb/shared-ui/icon"; @@ -23,7 +23,17 @@ import { resolveThreadStatus, } from "@/components/thread/ThreadStatusGlyph"; import { usePluginThreadRowStatus } from "@/lib/plugin-thread-row-status"; +import { + ThreadLifecycleFilter, + THREAD_LIFECYCLE_OPTIONS, +} from "@/components/thread/ThreadLifecycleFilter"; +import { paletteThreadLifecyclesAtom } from "@/lib/command-palette/palette-preferences"; +import { + normalizeThreadLifecycleFilter, + type ThreadArchiveFilter, +} from "@/lib/thread-lifecycle-filter"; import { useSidebarNavigation } from "@/hooks/queries/sidebar-navigation-query"; +import { usePaletteRecentArchivedThreads } from "@/hooks/queries/palette-thread-queries"; import { hasThreadSearchableQuery, useThreadSearch, @@ -39,17 +49,20 @@ import { splitLayoutAtom } from "@/lib/split-layout/atoms"; import { countPanes, findPaneByContent, MAX_PANES } from "@/lib/split-layout"; import { buildPaletteThreadSearchRows, - type PaletteThreadLifecycle, type PaletteThreadSearchRow, } from "@/lib/command-palette/palette-thread-search"; import { windowPaletteThreadSearchText } from "@/lib/command-palette/palette-thread-search-window"; import { PALETTE_SECTION_LABEL_CLASS, PaletteShell } from "./PaletteShell"; interface ThreadSearchOption { - lifecycle: PaletteThreadLifecycle; + lifecycle: ThreadArchiveFilter; row: PaletteThreadSearchRow | null; } +function optionKey(option: ThreadSearchOption): string { + return option.row?.id ?? `more:${option.lifecycle}`; +} + export function ThreadSearchPaletteMode({ onExit, runAfterClose, @@ -65,15 +78,30 @@ export function ThreadSearchPaletteMode({ const store = useStore(); const splitLayout = useAtomValue(splitLayoutAtom); const isCompact = useIsCompactViewport(); + const [selectedLifecycles, setLifecycles] = useAtom( + paletteThreadLifecyclesAtom, + ); + const lifecycles = useMemo( + () => normalizeThreadLifecycleFilter(selectedLifecycles), + [selectedLifecycles], + ); const [query, setQuery] = useState(""); const [highlightedIndex, setHighlightedIndex] = useState(0); - const [expandedGroups, setExpandedGroups] = useState< - PaletteThreadLifecycle[] - >([]); + const [highlightedKey, setHighlightedKey] = useState(null); + const [expandedGroups, setExpandedGroups] = useState([]); + const filterKey = lifecycles.join(","); + const [previousFilterKey, setPreviousFilterKey] = useState(filterKey); + if (previousFilterKey !== filterKey) { + setPreviousFilterKey(filterKey); + setExpandedGroups([]); + } const [now] = useState(() => Date.now()); const navigation = useSidebarNavigation(); const threadSearch = useThreadSearch({ active: true, query }); const trimmedQuery = query.trim(); + const archived = usePaletteRecentArchivedThreads({ + enabled: trimmedQuery.length === 0 && lifecycles.includes("archived"), + }); const searchable = hasThreadSearchableQuery(trimmedQuery); const searchResultsAreCurrent = !searchable || threadSearch.debouncedQuery === trimmedQuery; @@ -89,15 +117,19 @@ export function ThreadSearchPaletteMode({ }, [navigation.data]); const recentThreads = useMemo( () => [ - ...(navigation.data?.projects.flatMap((project) => project.threads) ?? - []), - ...(navigation.data?.personalProject.threads ?? []), + ...[ + ...(navigation.data?.projects.flatMap((project) => project.threads) ?? + []), + ...(navigation.data?.personalProject.threads ?? []), + ], + ...(lifecycles.includes("archived") ? (archived.data ?? []) : []), ], - [navigation.data], + [archived.data, lifecycles, navigation.data], ); const result = useMemo( () => buildPaletteThreadSearchRows({ + lifecycles, now, projectNamesById, query, @@ -106,6 +138,7 @@ export function ThreadSearchPaletteMode({ searchResultsAreCurrent, }), [ + lifecycles, now, projectNamesById, query, @@ -115,18 +148,15 @@ export function ThreadSearchPaletteMode({ ], ); const options = useMemo(() => { - const lifecycles = ["active", "archived"] as const; - const limit = lifecycles.every((lifecycle) => + const nonemptyGroups = lifecycles.filter((lifecycle) => result.rows.some((row) => row.lifecycle === lifecycle), - ) - ? 3 - : 6; - return lifecycles.flatMap((lifecycle) => { + ); + const limit = nonemptyGroups.length === 2 ? 3 : 6; + return nonemptyGroups.flatMap((lifecycle) => { const rows = result.rows.filter((row) => row.lifecycle === lifecycle); - const visible = - result.isRecent || expandedGroups.includes(lifecycle) - ? rows - : rows.slice(0, limit); + const visible = expandedGroups.includes(lifecycle) + ? rows + : rows.slice(0, limit); const groupOptions: ThreadSearchOption[] = visible.map((row) => ({ lifecycle, row, @@ -135,12 +165,36 @@ export function ThreadSearchPaletteMode({ groupOptions.push({ row: null, lifecycle }); return groupOptions; }); - }, [expandedGroups, result]); + }, [expandedGroups, lifecycles, result]); + const retainedIndex = options.findIndex( + (option) => optionKey(option) === highlightedKey, + ); const activeIndex = - options.length === 0 ? -1 : Math.min(highlightedIndex, options.length - 1); - const isRecentLoading = result.isRecent && navigation.isLoading; + retainedIndex >= 0 + ? retainedIndex + : options.length === 0 + ? -1 + : Math.min(highlightedIndex, options.length - 1); + useLayoutEffect(() => { + setHighlightedIndex(Math.max(activeIndex, 0)); + setHighlightedKey(activeIndex < 0 ? null : optionKey(options[activeIndex])); + }, [activeIndex, options]); + const highlightOption = useCallback( + (index: number) => { + setHighlightedIndex(index); + setHighlightedKey( + options[index] === undefined ? null : optionKey(options[index]), + ); + }, + [options], + ); + const recentQueries = lifecycles.map((lifecycle) => + lifecycle === "active" ? navigation : archived, + ); + const isRecentLoading = + result.isRecent && recentQueries.some((result) => result.isLoading); const hasLoadError = result.isRecent - ? navigation.isError + ? recentQueries.some((result) => result.isError) : searchResultsAreCurrent && threadSearch.isError; const showThreadListEmptyState = result.rows.length === 0 && @@ -178,6 +232,7 @@ export function ThreadSearchPaletteMode({ scrollOnNextHighlightRef.current = true; setExpandedGroups((current) => [...current, lifecycle]); setHighlightedIndex(index); + setHighlightedKey(null); inputRef.current?.focus(); return; } @@ -231,18 +286,19 @@ export function ThreadSearchPaletteMode({ if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); scrollOnNextHighlightRef.current = true; - setHighlightedIndex((current) => { - if (event.key === "ArrowDown") { - return current + 1 >= options.length ? 0 : current + 1; - } - return current <= 0 ? options.length - 1 : current - 1; - }); + highlightOption( + event.key === "ArrowDown" + ? (activeIndex + 1) % options.length + : activeIndex <= 0 + ? options.length - 1 + : activeIndex - 1, + ); return; } if (event.key === "Home" || event.key === "End") { event.preventDefault(); scrollOnNextHighlightRef.current = true; - setHighlightedIndex(event.key === "Home" ? 0 : options.length - 1); + highlightOption(event.key === "Home" ? 0 : options.length - 1); return; } if (event.key === "Enter") { @@ -252,7 +308,7 @@ export function ThreadSearchPaletteMode({ selectOption(option, activeIndex, event.metaKey || event.ctrlKey); } }, - [activeIndex, onExit, options, query.length, selectOption], + [activeIndex, highlightOption, onExit, options, query.length, selectOption], ); const isLoading = @@ -285,6 +341,11 @@ export function ThreadSearchPaletteMode({ : "Use Escape to return to commands." } inputLabel="Search threads" + inputAccessory={ +
+ +
+ } inputRef={inputRef} listId={listId} listLabel="Threads" @@ -294,10 +355,12 @@ export function ThreadSearchPaletteMode({ label: "Threads", clearLabel: "Return to commands", onClear: onExit, + hideShortcut: isCompact, }} onInputChange={(value) => { setQuery(value); setHighlightedIndex(0); + setHighlightedKey(null); setExpandedGroups([]); if (listRef.current !== null) listRef.current.scrollTop = 0; }} @@ -306,7 +369,7 @@ export function ThreadSearchPaletteMode({ value={query} > {emptyMessage === null ? ( - (["active", "archived"] as const).map((lifecycle) => { + THREAD_LIFECYCLE_OPTIONS.map(({ value: lifecycle, label }) => { if (!result.rows.some((row) => row.lifecycle === lifecycle)) { return null; } @@ -319,11 +382,7 @@ export function ThreadSearchPaletteMode({ className="not-last:mb-2" >
- {result.isRecent - ? "Recent" - : lifecycle === "archived" - ? "Archived" - : "Threads"} + {label}
{options.map((option, index) => option.lifecycle !== lifecycle ? null : ( @@ -337,7 +396,7 @@ export function ThreadSearchPaletteMode({ "flex min-w-0 items-center rounded-md", index === activeIndex && "bg-state-hover text-foreground", )} - onPointerMove={() => setHighlightedIndex(index)} + onPointerMove={() => highlightOption(index)} >
{ + it("keeps bounded recents server-owned and included in list invalidation", () => { + const queryClient = new QueryClient(); + const key = threadListQueryKey({ archived: true, limit: 20 }); + const archived = Array.from({ length: 20 }, (_, index) => + makeThreadListEntry({ id: `archived-${index}`, archivedAt: 1 }), + ); + queryClient.setQueryData(key, archived); + optimisticallyInsertThread(queryClient, makeThreadResponse({ id: "new" })); + expect(queryClient.getQueryData(key)).toEqual(archived); + expect( + getCachedGlobalThreadListInvalidationQueryKeys({ queryClient }), + ).toContainEqual(key); + }); +}); diff --git a/apps/app/src/hooks/queries/palette-thread-queries.ts b/apps/app/src/hooks/queries/palette-thread-queries.ts new file mode 100644 index 0000000000..e8b146605e --- /dev/null +++ b/apps/app/src/hooks/queries/palette-thread-queries.ts @@ -0,0 +1,23 @@ +import { useQuery } from "@tanstack/react-query"; +import type { ThreadListResponse } from "@bb/server-contract"; +import { useThreadListRealtimeSubscription } from "@/hooks/useRealtimeSubscription"; +import { sdk } from "@/lib/sdk"; +import { threadListQueryKey } from "./query-keys"; +import { + THREAD_LIST_STALE_TIME_MS, + THREAD_SEARCH_LIMIT_PER_GROUP, +} from "./thread-queries"; + +export function usePaletteRecentArchivedThreads({ enabled }: { enabled: boolean }) { + useThreadListRealtimeSubscription({ enabled }); + const filters = { + archived: true, + limit: THREAD_SEARCH_LIMIT_PER_GROUP, + }; + return useQuery({ + queryKey: threadListQueryKey(filters), + queryFn: ({ signal }) => sdk.threads.list({ ...filters, signal }), + enabled, + staleTime: THREAD_LIST_STALE_TIME_MS, + }); +} diff --git a/apps/app/src/hooks/queries/thread-queries.test.tsx b/apps/app/src/hooks/queries/thread-queries.test.tsx index 1f02b5bf4b..4aadae7948 100644 --- a/apps/app/src/hooks/queries/thread-queries.test.tsx +++ b/apps/app/src/hooks/queries/thread-queries.test.tsx @@ -23,6 +23,7 @@ import { threadQueryKey, threadTimelineQueryKey, } from "./query-keys"; +import { usePaletteRecentArchivedThreads } from "./palette-thread-queries"; import { COMPACT_THREAD_TIMELINE_SEGMENT_LIMIT, didThreadDetailBootstrapRefreshAfterMount, @@ -60,6 +61,7 @@ vi.mock("@/lib/sdk", () => ({ threads: { get: vi.fn(), list: vi.fn(), + search: vi.fn(), queuedMessages: { list: vi.fn() }, interactions: { list: vi.fn() }, storageLocation: vi.fn(), @@ -853,3 +855,24 @@ describe("useThreadTimeline segment limit", () => { }); }); }); + +describe("palette lifecycle queries", () => { + it("loads bounded archived recents only while selected before typing", async () => { + const { wrapper } = createQueryClientTestHarness(); + const archived = makeThreadListEntry({ id: "archived", archivedAt: 1 }); + vi.mocked(sdk.threads.list).mockResolvedValue([archived]); + const { result, rerender } = renderHook( + ({ recent, selected }) => usePaletteRecentArchivedThreads({ enabled: recent && selected }), + { wrapper, initialProps: { recent: true, selected: false } }, + ); + expect(sdk.threads.list).not.toHaveBeenCalled(); + rerender({ recent: false, selected: true }); + expect(sdk.threads.list).not.toHaveBeenCalled(); + rerender({ recent: true, selected: true }); + await waitFor(() => expect(result.current.data).toEqual([archived])); + expect(sdk.threads.list).toHaveBeenCalledExactlyOnceWith({ + archived: true, limit: 20, signal: expect.any(AbortSignal), + }); + }); + +}); diff --git a/apps/app/src/hooks/queries/thread-queries.ts b/apps/app/src/hooks/queries/thread-queries.ts index 0922143f95..1cd2f08975 100644 --- a/apps/app/src/hooks/queries/thread-queries.ts +++ b/apps/app/src/hooks/queries/thread-queries.ts @@ -9,7 +9,10 @@ import { import { useCallback, useMemo } from "react"; import { COMPACT_VIEWPORT_QUERY } from "@bb/shared-ui/hooks/use-compact-viewport"; import { getMediaQuerySnapshot } from "@bb/shared-ui/hooks/use-media-query"; -import type { PendingInteraction, ThreadListEntry } from "@bb/domain"; +import type { + PendingInteraction, + ThreadListEntry, +} from "@bb/domain"; import type { PromptHistoryResponse, ThreadQueuedMessageListResponse, @@ -94,7 +97,7 @@ interface QueryOptions { staleTime?: number; } -const THREAD_LIST_STALE_TIME_MS = 10_000; +export const THREAD_LIST_STALE_TIME_MS = 10_000; const THREAD_SEARCH_STALE_TIME_MS = 10_000; const THREAD_DETAIL_STALE_TIME_MS = 5_000; const THREAD_MENTION_CANDIDATE_LIMIT = 200; @@ -592,7 +595,10 @@ export function useThreadSearch({ active && liveQueryIsSearchable && trimmedQuery !== debouncedQuery; const enabled = active && liveQueryIsSearchable && hasSearchableQuery; const threadSearchQuery = useQuery({ - queryKey: threadSearchQueryKey({ limitPerGroup, query: debouncedQuery }), + queryKey: threadSearchQueryKey({ + limitPerGroup, + query: debouncedQuery, + }), queryFn: ({ signal }) => sdk.threads.search({ limitPerGroup: String(limitPerGroup), diff --git a/apps/app/src/lib/command-palette/palette-preferences.ts b/apps/app/src/lib/command-palette/palette-preferences.ts new file mode 100644 index 0000000000..748ca42dd4 --- /dev/null +++ b/apps/app/src/lib/command-palette/palette-preferences.ts @@ -0,0 +1,5 @@ +import { createThreadArchiveFilterAtom } from "@/lib/thread-lifecycle-filter"; + +export const paletteThreadLifecyclesAtom = createThreadArchiveFilterAtom( + "bb.palette.threadArchiveFilter", +); 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 8ce19f522e..f5d1408e8f 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 @@ -57,6 +57,7 @@ function build( overrides: Partial[0]> = {}, ) { return buildPaletteThreadSearchRows({ + lifecycles: ["active"], now: NOW, projectNamesById: new Map([["project-1", "Palette project"]]), query: "match", @@ -71,6 +72,50 @@ function build( } describe("buildPaletteThreadSearchRows", () => { + it("orders archived recents by archive time instead of last update", () => { + const result = build({ + query: "", + lifecycles: ["archived"], + recentThreads: [ + makeThread("updated-latest", { archivedAt: 1, updatedAt: NOW }), + makeThread("archived-latest", { archivedAt: 2, updatedAt: 1 }), + ], + }); + expect(result.rows.map((row) => row.threadId)).toEqual([ + "archived-latest", + "updated-latest", + ]); + }); + + it("keeps saved-message threads in Active recents", () => { + const saved = makeThread("saved", { status: "pending", updatedAt: NOW }); + const archived = makeThread("archived", { archivedAt: 1, updatedAt: 2 }); + const active = Array.from({ length: 25 }, (_, index) => makeThread(`active-${index}`, { updatedAt: 1 })); + const recentThreads = [...active, saved, archived]; + const result = build({ query: "", recentThreads, lifecycles: ["active", "archived"] }); + expect(result.rows).toHaveLength(21); + expect(result.rows[0]).toMatchObject({ threadId: "saved", lifecycle: "active" }); + expect(result.rows[20]).toMatchObject({ threadId: "archived", lifecycle: "archived" }); + }); + + it("keeps saved-message snippets in the owning thread result without inventing an event anchor", () => { + const result = build({ + lifecycles: ["active"], + searchResponse: { + active: { + total: 1, + results: [{ + thread: makeThread("saved", { status: "pending" }), + matches: [{ sourceKind: "user_message", text: "matching saved message", highlightRanges: [{ start: 0, end: 5 }], sourceSeq: null }], + }], + }, + archived: { total: 0, results: [] }, + }, + }); + expect(result.rows).toHaveLength(1); + expect(result.rows[0]).toMatchObject({ threadId: "saved", lifecycle: "active", primaryText: "matching saved message", messageSeq: null }); + }); + it("preserves active and archived server matches in their ranked order", () => { const active = makeThread("active"); const archived = makeThread("archived", { archivedAt: NOW - 1 }); @@ -86,6 +131,7 @@ describe("buildPaletteThreadSearchRows", () => { }; const result = build({ + lifecycles: ["active", "archived"], searchResponse, }); 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 cb22ece65f..0944ed31a2 100644 --- a/apps/app/src/lib/command-palette/palette-thread-search.ts +++ b/apps/app/src/lib/command-palette/palette-thread-search.ts @@ -1,16 +1,21 @@ -import { PERSONAL_PROJECT_ID, type ThreadListEntry } from "@bb/domain"; +import { + PERSONAL_PROJECT_ID, + type ThreadListEntry, +} from "@bb/domain"; import type { ThreadSearchMatch, ThreadSearchResponse, } from "@bb/server-contract"; import { formatRelativeTime } from "@/lib/relative-time"; import { getThreadDisplayTitle } from "@/lib/thread-title"; - -export type PaletteThreadLifecycle = "active" | "archived"; +import { + normalizeThreadLifecycleFilter, + type ThreadArchiveFilter, +} from "@/lib/thread-lifecycle-filter"; export interface PaletteThreadSearchRow { id: string; - lifecycle: PaletteThreadLifecycle; + lifecycle: ThreadArchiveFilter; primaryText: string; highlightRanges: readonly ThreadSearchMatch["highlightRanges"][number][]; secondaryTitle: string | null; @@ -23,6 +28,7 @@ export interface PaletteThreadSearchRow { } interface BuildPaletteThreadSearchRowsArgs { + lifecycles: readonly ThreadArchiveFilter[]; now: number; projectNamesById: ReadonlyMap; query: string; @@ -54,7 +60,7 @@ function projectMetadata( function serverRow( thread: ThreadListEntry, matches: readonly ThreadSearchMatch[], - lifecycle: "active" | "archived", + lifecycle: ThreadArchiveFilter, projectNamesById: ReadonlyMap, now: number, ): PaletteThreadSearchRow { @@ -80,6 +86,7 @@ function serverRow( } export function buildPaletteThreadSearchRows({ + lifecycles, now, projectNamesById, query, @@ -90,38 +97,36 @@ export function buildPaletteThreadSearchRows({ const trimmedQuery = query.trim(); const isRecent = trimmedQuery.length === 0; const isSearchable = trimmedQuery.length >= 2; - const activeRows = isRecent - ? [...recentThreads] - .sort((left, right) => right.updatedAt - left.updatedAt) - .slice(0, RECENT_THREAD_LIMIT) - .map((thread) => serverRow(thread, [], "active", projectNamesById, now)) - : isSearchable && searchResultsAreCurrent - ? (searchResponse?.active.results ?? []).map((result) => - serverRow( - result.thread, - result.matches, - "active", - projectNamesById, - now, - ), - ) - : []; - - const archivedRows = - isSearchable && searchResultsAreCurrent - ? (searchResponse?.archived.results ?? []).map((result) => - serverRow( - result.thread, - result.matches, - "archived", - projectNamesById, - now, - ), - ) - : []; - return { isRecent, - rows: [...activeRows, ...archivedRows], + rows: normalizeThreadLifecycleFilter(lifecycles).flatMap((lifecycle) => + isRecent + ? recentThreads + .filter((thread) => + lifecycle === "archived" + ? thread.archivedAt !== null + : thread.archivedAt === null, + ) + .sort((left, right) => + lifecycle === "archived" + ? (right.archivedAt ?? 0) - (left.archivedAt ?? 0) + : right.updatedAt - left.updatedAt, + ) + .slice(0, RECENT_THREAD_LIMIT) + .map((thread) => + serverRow(thread, [], lifecycle, projectNamesById, now), + ) + : isSearchable && searchResultsAreCurrent + ? (searchResponse?.[lifecycle]?.results ?? []).map((result) => + serverRow( + result.thread, + result.matches, + lifecycle, + projectNamesById, + now, + ), + ) + : [], + ), }; } diff --git a/docs/configuration.md b/docs/configuration.md index 186b398ead..108497333c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -741,6 +741,16 @@ that restores the thread without navigating away. Archived loads pages only while selected. Plugin sidebar replacements own their rendering. +The palette's Filter independently selects Active and Archived before +and after typing. It defaults to Active and remembers its selection in this +browser only; it is not configurable through SDK/CLI. +Active includes threads with saved messages. Search threads retains the existing +title and conversation search behavior and opens the owning thread. +Archived loads a bounded list in most-recently-archived order only while selected. +Search uses the existing +ranked Active/Archived response and displays the selected groups, with six initial +rows in one group or three each when both are nonempty, plus Show more. + `sidebar.threadGrouping.environment` decides whether two or more sibling threads that share one worktree environment collapse into a single worktree row inside their section. `true` groups them and `false` keeps every thread on its own row, diff --git a/packages/templates/src/templates/bb-guide-customization.md b/packages/templates/src/templates/bb-guide-customization.md index 13c9e7f333..559749148f 100644 --- a/packages/templates/src/templates/bb-guide-customization.md +++ b/packages/templates/src/templates/bb-guide-customization.md @@ -312,6 +312,12 @@ Drafts section or filter. Archived threads use their preserved placement and a restore action. Archived pages load only while selected. Plugin sidebar replacements own their filters. +The palette's Filter uses Active and Archived independently of the +sidebar, defaulting to Active. Its selection is browser-local, not configurable +through SDK/CLI. Active includes threads with saved messages; Search threads retains +the existing title and conversation search behavior. Archived fetches bounded recent rows only when +selected. + Every thread-list header's actions menu offers New project, New section, Organize, Sort by, and Filter. Organize selects By project, By machine, or Custom and retains Groups → By environment. diff --git a/plugins/bb-guide/skills/bb-cli/references/app-settings.md b/plugins/bb-guide/skills/bb-cli/references/app-settings.md index 0be967a793..bca7cded3a 100644 --- a/plugins/bb-guide/skills/bb-cli/references/app-settings.md +++ b/plugins/bb-guide/skills/bb-cli/references/app-settings.md @@ -24,6 +24,11 @@ every window and client sees the same value. a server-backed preference or SDK/CLI setting. Selected archived rows retain their hierarchy placement and offer a restore action. Archived pages load only while selected; plugin sidebar replacements keep ownership of their rendering. +- The palette's Filter selects Active and Archived independently of the + sidebar, defaulting to Active. This selection is browser-local, not configurable + through SDK/CLI. Active includes threads with saved messages; Search threads + retains existing title and conversation matching. Archived recents load only while selected and are + bounded at the server. - `sidebar.organizationMode` defaults to Custom (`chronological`) on new installs. Migrated installs with existing projects, threads, or UI preferences fall back to By project (`project`). Saved server choices win over legacy browser choices,