From 68b3d9c52dce10ddd8f976fb9a45358c5c812b7e Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Tue, 25 Aug 2026 09:08:00 -0700 Subject: [PATCH 01/11] Add session lifecycle state and draft leave toast --- .../sidebar/sidebarThreadLifecycle.test.ts | 41 ++++++++++++ .../sidebar/sidebarThreadLifecycle.ts | 57 +++++++++++++++++ .../hooks/useNewThreadDraftLeaveToast.test.ts | 46 ++++++++++++++ .../src/hooks/useNewThreadDraftLeaveToast.ts | 62 +++++++++++++++++++ apps/app/src/views/RootComposeView.tsx | 5 ++ 5 files changed, 211 insertions(+) create mode 100644 apps/app/src/components/sidebar/sidebarThreadLifecycle.test.ts create mode 100644 apps/app/src/components/sidebar/sidebarThreadLifecycle.ts create mode 100644 apps/app/src/hooks/useNewThreadDraftLeaveToast.test.ts create mode 100644 apps/app/src/hooks/useNewThreadDraftLeaveToast.ts diff --git a/apps/app/src/components/sidebar/sidebarThreadLifecycle.test.ts b/apps/app/src/components/sidebar/sidebarThreadLifecycle.test.ts new file mode 100644 index 0000000000..5631282075 --- /dev/null +++ b/apps/app/src/components/sidebar/sidebarThreadLifecycle.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_SIDEBAR_THREAD_LIFECYCLE_SELECTION, + isDefaultSidebarThreadLifecycleSelection, + toggleSidebarThreadLifecycleState, +} from "./sidebarThreadLifecycle"; + +describe("sidebar thread lifecycle selection", () => { + it("starts active-only and recognizes only that selection as the default", () => { + expect([...DEFAULT_SIDEBAR_THREAD_LIFECYCLE_SELECTION]).toEqual([ + "active", + ]); + expect( + isDefaultSidebarThreadLifecycleSelection( + DEFAULT_SIDEBAR_THREAD_LIFECYCLE_SELECTION, + ), + ).toBe(true); + expect( + isDefaultSidebarThreadLifecycleSelection( + new Set(["active", "drafts"]), + ), + ).toBe(false); + }); + + it("builds clean unions while refusing to remove the final state", () => { + const activeAndDrafts = toggleSidebarThreadLifecycleState( + DEFAULT_SIDEBAR_THREAD_LIFECYCLE_SELECTION, + "drafts", + ); + expect([...activeAndDrafts]).toEqual(["active", "drafts"]); + + const draftsOnly = toggleSidebarThreadLifecycleState( + activeAndDrafts, + "active", + ); + expect([...draftsOnly]).toEqual(["drafts"]); + expect(toggleSidebarThreadLifecycleState(draftsOnly, "drafts")).toBe( + draftsOnly, + ); + }); +}); diff --git a/apps/app/src/components/sidebar/sidebarThreadLifecycle.ts b/apps/app/src/components/sidebar/sidebarThreadLifecycle.ts new file mode 100644 index 0000000000..eecd05efec --- /dev/null +++ b/apps/app/src/components/sidebar/sidebarThreadLifecycle.ts @@ -0,0 +1,57 @@ +import { atom } from "jotai"; + +export const SIDEBAR_THREAD_LIFECYCLE_STATES = [ + "active", + "drafts", + "archived", +] as const; + +export type SidebarThreadLifecycleState = + (typeof SIDEBAR_THREAD_LIFECYCLE_STATES)[number]; + +export type SidebarThreadLifecycleSelection = ReadonlySet< + SidebarThreadLifecycleState +>; + +export const DEFAULT_SIDEBAR_THREAD_LIFECYCLE_SELECTION = new Set< + SidebarThreadLifecycleState +>(["active"]); + +/** + * Session-only by design. A fresh app launch always starts from the safe + * active-only view so an old preference can never make every current thread + * appear missing. + */ +export const sidebarThreadLifecycleSelectionAtom = + atom( + DEFAULT_SIDEBAR_THREAD_LIFECYCLE_SELECTION, + ); + +/** + * Whether the built-in list is currently rendering its draft cluster. This is + * deliberately separate from the selection: plugin replacements do not mount + * ProjectList and therefore never claim that bb's draft rows are visible. + */ +export const builtInSidebarDraftRowsVisibleAtom = atom(false); + +export function toggleSidebarThreadLifecycleState( + current: SidebarThreadLifecycleSelection, + state: SidebarThreadLifecycleState, +): SidebarThreadLifecycleSelection { + if (current.has(state)) { + if (current.size === 1) return current; + const next = new Set(current); + next.delete(state); + return next; + } + + const next = new Set(current); + next.add(state); + return next; +} + +export function isDefaultSidebarThreadLifecycleSelection( + selection: SidebarThreadLifecycleSelection, +): boolean { + return selection.size === 1 && selection.has("active"); +} diff --git a/apps/app/src/hooks/useNewThreadDraftLeaveToast.test.ts b/apps/app/src/hooks/useNewThreadDraftLeaveToast.test.ts new file mode 100644 index 0000000000..cb140ef10b --- /dev/null +++ b/apps/app/src/hooks/useNewThreadDraftLeaveToast.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import type { PromptDraftState } from "@bb/client-core"; +import { shouldAnnounceNewThreadDraftLeave } from "./useNewThreadDraftLeaveToast"; + +const EMPTY_DRAFT: PromptDraftState = { + attachments: [], + mentions: [], + text: "", +}; + +describe("new-thread draft leave toast", () => { + it("announces a page-composer draft only when its built-in row is hidden", () => { + const draft = { ...EMPTY_DRAFT, text: "Keep this work" }; + expect( + shouldAnnounceNewThreadDraftLeave({ + draft, + draftRowsVisible: false, + isSplitPane: false, + }), + ).toBe(true); + expect( + shouldAnnounceNewThreadDraftLeave({ + draft, + draftRowsVisible: true, + isSplitPane: false, + }), + ).toBe(false); + }); + + it("does not announce empty or split-pane drafts in Phase 2", () => { + expect( + shouldAnnounceNewThreadDraftLeave({ + draft: EMPTY_DRAFT, + draftRowsVisible: false, + isSplitPane: false, + }), + ).toBe(false); + expect( + shouldAnnounceNewThreadDraftLeave({ + draft: { ...EMPTY_DRAFT, text: "Split work" }, + draftRowsVisible: false, + isSplitPane: true, + }), + ).toBe(false); + }); +}); diff --git a/apps/app/src/hooks/useNewThreadDraftLeaveToast.ts b/apps/app/src/hooks/useNewThreadDraftLeaveToast.ts new file mode 100644 index 0000000000..e602cb09f5 --- /dev/null +++ b/apps/app/src/hooks/useNewThreadDraftLeaveToast.ts @@ -0,0 +1,62 @@ +import { useEffect, useRef } from "react"; +import { useAtomValue } from "jotai"; +import { isPromptDraftEmpty, type PromptDraftState } from "@bb/client-core"; +import { appToast } from "@/components/ui/app-toast"; +import { builtInSidebarDraftRowsVisibleAtom } from "@/components/sidebar/sidebarThreadLifecycle"; + +export function shouldAnnounceNewThreadDraftLeave({ + draft, + draftRowsVisible, + isSplitPane, +}: { + draft: PromptDraftState; + draftRowsVisible: boolean; + isSplitPane: boolean; +}): boolean { + return !isSplitPane && !draftRowsVisible && !isPromptDraftEmpty(draft); +} + +/** + * Announces a persisted page-composer draft when navigation removes it from + * view. Split-pane replacement/close owns the same rule in Phase 4, so this + * hook intentionally ignores composers currently hosted in a split pane. + */ +export function useNewThreadDraftLeaveToast({ + getCurrentDraft, + isSplitPane, +}: { + getCurrentDraft: () => PromptDraftState; + isSplitPane: boolean; +}): void { + const draftRowsVisible = useAtomValue(builtInSidebarDraftRowsVisibleAtom); + const draftRowsVisibleRef = useRef(draftRowsVisible); + const getCurrentDraftRef = useRef(getCurrentDraft); + const isSplitPaneRef = useRef(isSplitPane); + const mountedRef = useRef(false); + + draftRowsVisibleRef.current = draftRowsVisible; + getCurrentDraftRef.current = getCurrentDraft; + isSplitPaneRef.current = isSplitPane; + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + const shouldAnnounce = shouldAnnounceNewThreadDraftLeave({ + draft: getCurrentDraftRef.current(), + draftRowsVisible: draftRowsVisibleRef.current, + isSplitPane: isSplitPaneRef.current, + }); + if (!shouldAnnounce) return; + + // React's development StrictMode replays effects without navigating. + // Defer one microtask so the replayed setup can mark this same hook + // instance mounted again before an informational toast is emitted. + queueMicrotask(() => { + if (!mountedRef.current) { + appToast.message("Saved to Drafts"); + } + }); + }; + }, []); +} diff --git a/apps/app/src/views/RootComposeView.tsx b/apps/app/src/views/RootComposeView.tsx index e6f5941122..f161abdd6d 100644 --- a/apps/app/src/views/RootComposeView.tsx +++ b/apps/app/src/views/RootComposeView.tsx @@ -175,6 +175,7 @@ import { useAppCommandShortcut, } from "@/components/commands/AppCommandProvider"; import { useOptionalPaneContext } from "./thread-detail/PaneContext"; +import { useNewThreadDraftLeaveToast } from "@/hooks/useNewThreadDraftLeaveToast"; import { RootComposePanelCommandHandlers } from "./RootComposePanelCommandHandlers"; import { ROOT_COMPOSE_FIXED_PANEL_STATE_ID, @@ -739,6 +740,10 @@ function RootComposeSurface({ setServiceTier, renderPromptBox, } = composer; + useNewThreadDraftLeaveToast({ + getCurrentDraft: promptDraft.getCurrent, + isSplitPane: paneContext?.isSplitPane === true, + }); const rootPanelEnvironmentId = parsedEnvironment?.type === "reuse" ? parsedEnvironment.environmentId From 4de6652f9c3acd14243034d00d644abce16b21bb Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Tue, 25 Aug 2026 09:10:57 -0700 Subject: [PATCH 02/11] Expose live new thread draft rows --- .../src/hooks/useNewThreadDraftSlots.test.tsx | 218 ++++++++++++++++++ apps/app/src/hooks/useNewThreadDraftSlots.ts | 56 +++++ apps/app/src/hooks/usePromptDraftStorage.ts | 91 +++++++- 3 files changed, 364 insertions(+), 1 deletion(-) create mode 100644 apps/app/src/hooks/useNewThreadDraftSlots.test.tsx create mode 100644 apps/app/src/hooks/useNewThreadDraftSlots.ts diff --git a/apps/app/src/hooks/useNewThreadDraftSlots.test.tsx b/apps/app/src/hooks/useNewThreadDraftSlots.test.tsx new file mode 100644 index 0000000000..a5c31d2c20 --- /dev/null +++ b/apps/app/src/hooks/useNewThreadDraftSlots.test.tsx @@ -0,0 +1,218 @@ +// @vitest-environment jsdom + +import { act, cleanup, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { PromptDraftAttachment, PromptDraftState } from "@bb/client-core"; +import { + getPromptDraftAccessor, + usePromptDraftStorage, +} from "@/hooks/usePromptDraftStorage"; +import { + getNewThreadDraftSlotStorageKey, + serializeNewThreadDraftSlot, + type NewThreadDraftDestination, +} from "@/lib/prompt-draft-slots"; +import { + getNewThreadDraftTitle, + useNewThreadDraftSlots, +} from "./useNewThreadDraftSlots"; + +const DESTINATION: NewThreadDraftDestination = { + projectId: "project-work", + sectionId: "section-inbox", +}; +const ATTACHMENT: PromptDraftAttachment = { + type: "localFile", + path: "uploads/requirements.pdf", + name: "requirements.pdf", + mimeType: "application/pdf", + sizeBytes: 42, +}; + +function draft(text: string): PromptDraftState { + return { text, mentions: [], attachments: [] }; +} + +function dispatchStorageChange( + key: string | null, + oldValue: string | null = null, + newValue: string | null = null, +): void { + window.dispatchEvent( + new StorageEvent("storage", { + key, + oldValue, + newValue, + storageArea: window.localStorage, + }), + ); +} + +beforeEach(() => { + window.localStorage.clear(); + dispatchStorageChange(null); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +describe("useNewThreadDraftSlots", () => { + it("derives a live title before the debounced content write and sorts newest first", () => { + vi.useFakeTimers(); + const firstComposer = renderHook(() => + usePromptDraftStorage({ + kind: "new-thread", + slotId: "first", + destination: DESTINATION, + }), + ); + const secondComposer = renderHook(() => + usePromptDraftStorage({ + kind: "new-thread", + slotId: "second", + destination: DESTINATION, + }), + ); + const rows = renderHook(() => useNewThreadDraftSlots()); + + vi.setSystemTime(100); + act(() => + firstComposer.result.current.setTextAndMentions( + " Refactor\n the split layout ", + [], + ), + ); + expect( + window.localStorage.getItem(firstComposer.result.current.storageKey), + ).toBeNull(); + expect(rows.result.current).toEqual([ + expect.objectContaining({ + id: "first", + title: "Refactor the split layout", + destination: DESTINATION, + lastEditedAt: 100, + }), + ]); + + vi.setSystemTime(200); + act(() => + secondComposer.result.current.setTextAndMentions("Write tests", []), + ); + expect(rows.result.current.map((row) => row.id)).toEqual([ + "second", + "first", + ]); + + act(() => { + for (const row of rows.result.current) row.delete(); + }); + }); + + it("titles an attachment-only slot New thread", () => { + const composer = renderHook(() => + usePromptDraftStorage({ + kind: "new-thread", + slotId: "attachment-only", + destination: DESTINATION, + }), + ); + const rows = renderHook(() => useNewThreadDraftSlots()); + + act(() => composer.result.current.addAttachment(ATTACHMENT)); + + expect(rows.result.current).toEqual([ + expect.objectContaining({ + id: "attachment-only", + title: "New thread", + draft: { text: "", mentions: [], attachments: [ATTACHMENT] }, + }), + ]); + act(() => rows.result.current[0]?.delete()); + }); + + it("deleting a row synchronously empties a live composer bound to its slot", () => { + const composer = renderHook(() => + usePromptDraftStorage({ + kind: "new-thread", + slotId: "open-composer", + destination: DESTINATION, + }), + ); + const rows = renderHook(() => useNewThreadDraftSlots()); + act(() => composer.result.current.setDraft(draft("Keep this visible"))); + expect(rows.result.current).toHaveLength(1); + + act(() => rows.result.current[0]?.delete()); + + expect(composer.result.current.text).toBe(""); + expect(composer.result.current.attachments).toEqual([]); + expect(rows.result.current).toEqual([]); + expect( + window.localStorage.getItem(composer.result.current.storageKey), + ).toBeNull(); + }); + + it("reacts to another window adding and deleting a slot", () => { + const rows = renderHook(() => useNewThreadDraftSlots()); + const storageKey = getNewThreadDraftSlotStorageKey("other-window"); + const serialized = serializeNewThreadDraftSlot( + draft("Arrived from storage"), + 300, + DESTINATION, + ); + expect(serialized).not.toBeNull(); + + act(() => { + window.localStorage.setItem(storageKey, serialized!); + dispatchStorageChange(storageKey, null, serialized); + }); + expect(rows.result.current).toEqual([ + expect.objectContaining({ + id: "other-window", + title: "Arrived from storage", + lastEditedAt: 300, + }), + ]); + + act(() => { + window.localStorage.removeItem(storageKey); + dispatchStorageChange(storageKey, serialized, null); + }); + expect(rows.result.current).toEqual([]); + }); + + it("excludes active-thread and plugin-rendered composer drafts", () => { + const rows = renderHook(() => useNewThreadDraftSlots()); + + act(() => { + getPromptDraftAccessor({ + kind: "thread", + projectId: "project-work", + threadId: "thread-active", + }).setDraft(draft("Unsent active-thread text")); + getPromptDraftAccessor({ + kind: "plugin-new-thread", + key: "plugin-composer", + }).setDraft(draft("Plugin-owned draft")); + }); + + expect(rows.result.current).toEqual([]); + }); +}); + +describe("getNewThreadDraftTitle", () => { + it("uses New thread only when the draft has no text", () => { + expect(getNewThreadDraftTitle(draft(" first\n words "))).toBe( + "first words", + ); + expect( + getNewThreadDraftTitle({ + text: " \n ", + mentions: [], + attachments: [ATTACHMENT], + }), + ).toBe("New thread"); + }); +}); diff --git a/apps/app/src/hooks/useNewThreadDraftSlots.ts b/apps/app/src/hooks/useNewThreadDraftSlots.ts new file mode 100644 index 0000000000..3f5990591b --- /dev/null +++ b/apps/app/src/hooks/useNewThreadDraftSlots.ts @@ -0,0 +1,56 @@ +import { useMemo, useSyncExternalStore } from "react"; +import { isPromptDraftEmpty, type PromptDraftState } from "@bb/client-core"; +import { + deleteNewThreadDraftSlot, + getNewThreadDraftSlotsSnapshot, + subscribeNewThreadDraftSlots, +} from "@/hooks/usePromptDraftStorage"; +import type { + NewThreadDraftDestination, + NewThreadDraftSlot, +} from "@/lib/prompt-draft-slots"; + +const EMPTY_NEW_THREAD_DRAFT_ROWS: readonly NewThreadDraftRow[] = []; + +export interface NewThreadDraftRow { + id: string; + draft: PromptDraftState; + title: string; + lastEditedAt: number; + destination: NewThreadDraftDestination; + delete: () => void; +} + +export function getNewThreadDraftTitle(draft: PromptDraftState): string { + const firstWords = draft.text.replace(/\s+/gu, " ").trim(); + return firstWords.length > 0 ? firstWords : "New thread"; +} + +function toNewThreadDraftRow(slot: NewThreadDraftSlot): NewThreadDraftRow { + return { + ...slot, + title: getNewThreadDraftTitle(slot.draft), + delete: () => deleteNewThreadDraftSlot(slot.id), + }; +} + +/** + * Reactive local phantom rows for bb's built-in thread list. This store only + * observes generated New-thread slot keys, so active-thread composer drafts + * and plugin-rendered New-thread composers cannot enter the Drafts lifecycle. + */ +export function useNewThreadDraftSlots(): readonly NewThreadDraftRow[] { + const slots = useSyncExternalStore( + subscribeNewThreadDraftSlots, + getNewThreadDraftSlotsSnapshot, + () => EMPTY_NEW_THREAD_DRAFT_ROWS, + ); + + return useMemo( + () => + slots + .filter((slot) => !isPromptDraftEmpty(slot.draft)) + .map(toNewThreadDraftRow), + [slots], + ); +} diff --git a/apps/app/src/hooks/usePromptDraftStorage.ts b/apps/app/src/hooks/usePromptDraftStorage.ts index 822b19f9f0..fdb80f436a 100644 --- a/apps/app/src/hooks/usePromptDraftStorage.ts +++ b/apps/app/src/hooks/usePromptDraftStorage.ts @@ -14,6 +14,8 @@ import { getNewThreadDraftSlotStorageKey, parseNewThreadDraftSlot, persistNewThreadDraftSlot, + readNewThreadDraftSlots, + type NewThreadDraftSlot, type NewThreadDraftDestination, } from "@/lib/prompt-draft-slots"; @@ -52,8 +54,12 @@ const promptDraftCache = new Map(); const promptDraftSubscribers = new Map>(); const pendingPromptDraftStorageKeys = new Set(); const promptDraftPersistTimers = new Map(); +const newThreadDraftSlotSubscribers = new Set(); +let newThreadDraftSlotsSnapshot: readonly NewThreadDraftSlot[] | null = null; let promptDraftStorageObserverInitialized = false; +const EMPTY_NEW_THREAD_DRAFT_SLOTS: readonly NewThreadDraftSlot[] = []; + function normalizeStorageSegment(value: string): string { return encodeURIComponent(value.trim()); } @@ -133,6 +139,7 @@ function updatePromptDraftDestination( // its destination into the same JSON record without changing recency. if (!isPromptDraftEmpty(draft)) { persistPromptDraftCache(storageKey); + emitNewThreadDraftSlotsChange(); } } @@ -145,6 +152,13 @@ function emitPromptDraftChange(storageKey: string): void { } } +function emitNewThreadDraftSlotsChange(): void { + newThreadDraftSlotsSnapshot = null; + for (const listener of newThreadDraftSlotSubscribers) { + listener(); + } +} + function clearPromptDraftPersistTimer(storageKey: string): void { const timerId = promptDraftPersistTimers.get(storageKey); if (timerId === undefined || typeof window === "undefined") return; @@ -238,11 +252,17 @@ function ensurePromptDraftStorageObserver(): void { promptDraftStorageObserverInitialized = true; window.addEventListener("storage", (event) => { - if (!event.key) return; + if (event.key === null) { + emitNewThreadDraftSlotsChange(); + return; + } // While a local deferred write is pending, ignore stale cross-tab storage for this key so it cannot clobber the in-progress draft. if (pendingPromptDraftStorageKeys.has(event.key)) return; promptDraftCache.delete(event.key); emitPromptDraftChange(event.key); + if (getNewThreadDraftSlotIdFromStorageKey(event.key) !== null) { + emitNewThreadDraftSlotsChange(); + } }); window.addEventListener("pagehide", flushPendingPromptDraftPersists); document.addEventListener("visibilitychange", () => { @@ -314,6 +334,75 @@ function writePromptDraft( persistPromptDraftCache(storageKey); } emitPromptDraftChange(storageKey); + if (slotId !== null) { + emitNewThreadDraftSlotsChange(); + } +} + +function readLiveNewThreadDraftSlots(): readonly NewThreadDraftSlot[] { + if (typeof window === "undefined") return EMPTY_NEW_THREAD_DRAFT_SLOTS; + + const slotsById = new Map( + readNewThreadDraftSlots().map((slot) => [slot.id, slot]), + ); + for (const [storageKey, cachedEntry] of promptDraftCache) { + const slotId = getNewThreadDraftSlotIdFromStorageKey(storageKey); + if (slotId === null) continue; + + // A deferred local edit is authoritative until its timer flushes. For a + // settled cache entry, overlay it only while it still describes storage; + // this keeps a cross-window clear from resurrecting stale cached rows. + const isAuthoritative = + pendingPromptDraftStorageKeys.has(storageKey) || + cachedEntry.rawValue === readStoredPromptDraftValue(storageKey); + if (!isAuthoritative) continue; + + if ( + isPromptDraftEmpty(cachedEntry.draft) || + cachedEntry.lastEditedAt === null || + cachedEntry.destination === null + ) { + slotsById.delete(slotId); + continue; + } + slotsById.set(slotId, { + id: slotId, + draft: cachedEntry.draft, + lastEditedAt: cachedEntry.lastEditedAt, + destination: cachedEntry.destination, + }); + } + + return [...slotsById.values()].sort( + (left, right) => + right.lastEditedAt - left.lastEditedAt || left.id.localeCompare(right.id), + ); +} + +/** A referentially stable snapshot for the synthesized New-thread rows. */ +export function getNewThreadDraftSlotsSnapshot(): readonly NewThreadDraftSlot[] { + if (newThreadDraftSlotsSnapshot === null) { + newThreadDraftSlotsSnapshot = readLiveNewThreadDraftSlots(); + } + return newThreadDraftSlotsSnapshot; +} + +export function subscribeNewThreadDraftSlots( + listener: PromptDraftListener, +): () => void { + ensurePromptDraftStorageObserver(); + newThreadDraftSlotSubscribers.add(listener); + return () => { + newThreadDraftSlotSubscribers.delete(listener); + }; +} + +/** + * Deletes a phantom row through the composer store rather than raw storage so + * any mounted composer bound to the slot receives the empty draft immediately. + */ +export function deleteNewThreadDraftSlot(slotId: string): void { + writePromptDraft(getNewThreadDraftSlotStorageKey(slotId), EMPTY_PROMPT_DRAFT); } function restorePromptDraftIfEmpty( From f8bf419d0749e49efc18558775a143e53608dc59 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Tue, 25 Aug 2026 09:14:17 -0700 Subject: [PATCH 03/11] Add archived thread count read --- apps/app/src/hooks/queries/query-keys.ts | 9 +++++ .../src/hooks/queries/thread-queries.test.tsx | 24 ++++++++++++ apps/app/src/hooks/queries/thread-queries.ts | 21 ++++++++++ apps/server/src/routes/threads/base.ts | 11 ++++++ .../test/public/public-thread-data.test.ts | 39 +++++++++++++++++++ packages/db/src/data/index.ts | 2 + packages/db/src/data/threads.ts | 12 ++++++ packages/db/test/data/threads.test.ts | 30 ++++++++++++++ packages/sdk/src/areas/threads.ts | 21 ++++++++++ packages/sdk/test/public-types.test.ts | 1 + packages/sdk/test/sdk.test.ts | 22 +++++++++++ packages/server-contract/src/api/threads.ts | 14 +++++++ packages/server-contract/src/public-api.ts | 11 ++++++ .../server-contract/test/contract.test.ts | 11 ++++++ 14 files changed, 228 insertions(+) diff --git a/apps/app/src/hooks/queries/query-keys.ts b/apps/app/src/hooks/queries/query-keys.ts index 3d1dd90129..495bef8c4b 100644 --- a/apps/app/src/hooks/queries/query-keys.ts +++ b/apps/app/src/hooks/queries/query-keys.ts @@ -98,6 +98,7 @@ export interface ArchivedThreadsListFilters { } export const ARCHIVED_THREADS_LIST_KIND = "archivedList"; +const ARCHIVED_THREAD_COUNT_KIND = "archivedCount"; type HostsQueryKey = readonly [typeof HOSTS_QUERY_KEY]; type HostQueryId = string | null | undefined; @@ -180,6 +181,10 @@ type ArchivedThreadsListQueryKey = readonly [ typeof ARCHIVED_THREADS_LIST_KIND, ArchivedThreadsListFilters, ]; +type ArchivedThreadCountQueryKey = readonly [ + typeof THREADS_QUERY_KEY, + typeof ARCHIVED_THREAD_COUNT_KIND, +]; type DisabledThreadListQueryKey = readonly [ typeof THREADS_DISABLED_QUERY_KEY, ThreadListQueryFilters?, @@ -658,6 +663,10 @@ export function archivedThreadsListQueryKey( return [THREADS_QUERY_KEY, ARCHIVED_THREADS_LIST_KIND, filters]; } +export function archivedThreadCountQueryKey(): ArchivedThreadCountQueryKey { + return [THREADS_QUERY_KEY, ARCHIVED_THREAD_COUNT_KIND]; +} + export function disabledThreadListQueryKey( filters?: ThreadListQueryFilters, ): DisabledThreadListQueryKey { diff --git a/apps/app/src/hooks/queries/thread-queries.test.tsx b/apps/app/src/hooks/queries/thread-queries.test.tsx index 075fdddf16..67c7de5993 100644 --- a/apps/app/src/hooks/queries/thread-queries.test.tsx +++ b/apps/app/src/hooks/queries/thread-queries.test.tsx @@ -25,6 +25,7 @@ import { import { COMPACT_THREAD_TIMELINE_SEGMENT_LIMIT, didThreadDetailBootstrapRefreshAfterMount, + useArchivedThreadCount, useArchivedThreads, useChildThreads, useThread, @@ -47,6 +48,7 @@ vi.mock("@/lib/api", async (importOriginal) => { vi.mock("@/lib/sdk", () => ({ sdk: { threads: { + count: vi.fn(), get: vi.fn(), list: vi.fn(), queuedMessages: { list: vi.fn() }, @@ -145,6 +147,7 @@ afterEach(() => { }); beforeEach(() => { + vi.mocked(sdk.threads.count).mockResolvedValue({ count: 0 }); vi.mocked(sdk.threads.get).mockResolvedValue(THREAD_WITH_INCLUDES); vi.mocked(sdk.threads.list).mockResolvedValue([]); vi.mocked(sdk.threads.queuedMessages.list).mockResolvedValue([]); @@ -420,6 +423,27 @@ describe("useArchivedThreads", () => { }); }); +describe("useArchivedThreadCount", () => { + it("fetches a row-free global archived count only when explicitly requested", async () => { + vi.mocked(sdk.threads.count).mockResolvedValue({ count: 4 }); + const { wrapper } = createQueryClientTestHarness(); + + const { result } = renderHook(() => useArchivedThreadCount(), { wrapper }); + expect(sdk.threads.count).not.toHaveBeenCalled(); + + let fetchedCount: number | undefined; + await act(async () => { + fetchedCount = (await result.current.refetch()).data; + }); + + expect(sdk.threads.count).toHaveBeenCalledWith({ + archived: true, + signal: expect.any(AbortSignal), + }); + expect(fetchedCount).toBe(4); + }); +}); + describe("useThreadQueuedMessages", () => { it("refetches stale queue data on window focus", async () => { const { queryClient, wrapper } = createQueryClientTestHarness(); diff --git a/apps/app/src/hooks/queries/thread-queries.ts b/apps/app/src/hooks/queries/thread-queries.ts index a3c72ceecc..2616738160 100644 --- a/apps/app/src/hooks/queries/thread-queries.ts +++ b/apps/app/src/hooks/queries/thread-queries.ts @@ -18,6 +18,7 @@ import type { ThreadSearchResponse, ThreadWithIncludesResponse, ThreadConversationOutlineResponse, + ThreadCountResponse, ThreadStorageFileListResponse, ThreadStorageLocationResponse, ThreadStoragePathListResponse, @@ -62,6 +63,7 @@ import { RESUME_REFETCH_QUERY_POLICY, } from "./query-policies"; import { + archivedThreadCountQueryKey, archivedThreadsListQueryKey, disabledThreadListQueryKey, threadDetailBootstrapQueryKey, @@ -364,6 +366,25 @@ export function useArchivedThreads( }); } +/** + * An explicit archived-count read for the sidebar Display menu. It stays + * disabled between opens so callers decide exactly when a fresh count is + * requested and displayed. + */ +export function useArchivedThreadCount() { + return useQuery< + ThreadCountResponse, + Error, + number, + ReturnType + >({ + queryKey: archivedThreadCountQueryKey(), + queryFn: ({ signal }) => sdk.threads.count({ archived: true, signal }), + enabled: false, + select: (response) => response.count, + }); +} + export function useThreads(filters: UseThreadsFilters, options?: QueryOptions) { const { projectId, ...rest } = filters; const enabled = (options?.enabled ?? true) && Boolean(projectId); diff --git a/apps/server/src/routes/threads/base.ts b/apps/server/src/routes/threads/base.ts index d99182e871..ba1f1e07c0 100644 --- a/apps/server/src/routes/threads/base.ts +++ b/apps/server/src/routes/threads/base.ts @@ -1,6 +1,7 @@ import { THREAD_SEARCH_LIMIT_PER_GROUP_DEFAULT, THREAD_SEARCH_LIMIT_PER_GROUP_MAX, + countThreads, countNonDeletedAssignedChildThreads, getEnvironment, getThreadSectionById, @@ -20,6 +21,7 @@ import { type ThreadGetQuery, type ThreadIncludeOption, type ThreadChildSummaryResponse, + type ThreadCountResponse, type ThreadSearchResponse, type ThreadWithIncludesResponse, type PublicApiSchema, @@ -211,6 +213,15 @@ export function registerThreadBaseRoutes(app: Hono, deps: AppDeps): void { }); const routes = publicApiRoutes.threads; + get(routes.count, (context, query) => { + const response: ThreadCountResponse = { + count: countThreads(deps.db, { + archived: query.archived === "true", + }), + }; + return context.json(response); + }); + get(routes.list, (context, query) => { const limit = parseOptionalInteger(query.limit, "limit"); if (limit !== undefined && limit <= 0) { diff --git a/apps/server/test/public/public-thread-data.test.ts b/apps/server/test/public/public-thread-data.test.ts index 41f204bd43..ee10221539 100644 --- a/apps/server/test/public/public-thread-data.test.ts +++ b/apps/server/test/public/public-thread-data.test.ts @@ -1,5 +1,6 @@ import { and, eq } from "drizzle-orm"; import { + archiveThread, claimQueuedThreadMessage, createQueuedThreadMessageId, createThreadSection, @@ -10,6 +11,7 @@ import { getQueuedThreadMessage, insertEvents, listQueuedThreadMessages, + markThreadDeleted, getThread, queuedThreadMessages, reorderQueuedThreadMessage, @@ -29,6 +31,7 @@ import { threadSectionMutationResponseSchema, threadSectionSchema, threadConversationOutlineResponseSchema, + threadCountResponseSchema, threadQueuedMessageListResponseSchema, threadStorageLocationResponseSchema, threadTimelineResponseSchema, @@ -259,6 +262,42 @@ describe("public thread data routes", () => { }); }); + it("counts archived visible threads without returning thread rows", async () => { + await withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + }); + const visibleArchived = seedThread(harness.deps, { + projectId: project.id, + }); + const hiddenArchived = seedThread(harness.deps, { + projectId: project.id, + visibility: "hidden", + }); + const deletedArchived = seedThread(harness.deps, { + projectId: project.id, + }); + seedThread(harness.deps, { projectId: project.id }); + + archiveThread(harness.db, harness.deps.hub, visibleArchived.id); + archiveThread(harness.db, harness.deps.hub, hiddenArchived.id); + archiveThread(harness.db, harness.deps.hub, deletedArchived.id); + markThreadDeleted(harness.db, harness.deps.hub, { + threadId: deletedArchived.id, + }); + + const response = await harness.app.request( + "/api/v1/threads/count?archived=true", + ); + + expect(response.status).toBe(200); + expect(threadCountResponseSchema.parse(await readJson(response))).toEqual( + { count: 1 }, + ); + }); + }); + it("allows creating or assigning a hidden thread in a section", async () => { await withTestHarness(async (harness) => { const { host } = seedHostSession(harness.deps); diff --git a/packages/db/src/data/index.ts b/packages/db/src/data/index.ts index 4cadc15b1a..54c642f781 100644 --- a/packages/db/src/data/index.ts +++ b/packages/db/src/data/index.ts @@ -53,6 +53,7 @@ export { } from "./project-sources.js"; export { createThread, + countThreads, countLiveThreadsInEnvironment, countNonDeletedAssignedChildThreads, getThread, @@ -91,6 +92,7 @@ export { export type { ApplyThreadLifecycleEventArgs, ApplyThreadLifecycleEventOutcome, + CountThreadsOptions, ReorderPinnedThreadResult, ThreadSearchHighlightRange, ThreadSearchMatch, diff --git a/packages/db/src/data/threads.ts b/packages/db/src/data/threads.ts index b09c32fc75..fb75a85bea 100644 --- a/packages/db/src/data/threads.ts +++ b/packages/db/src/data/threads.ts @@ -395,6 +395,11 @@ export interface ListThreadsOptions { includeHidden?: boolean; } +export type CountThreadsOptions = Omit< + ListThreadsOptions, + "limit" | "offset" +>; + type ThreadRow = typeof threads.$inferSelect; export interface ListThreadsForProjectsOptions { @@ -1197,6 +1202,13 @@ export function listThreads(db: DbConnection, options: ListThreadsOptions) { return query.all(); } +export function countThreads( + db: DbConnection, + options: CountThreadsOptions, +): number { + return countThreadsWhere(db, and(...buildListThreadsFilters(options))); +} + export function listThreadsWithPendingInteractionState( db: DbConnection, options: ListThreadsOptions, diff --git a/packages/db/test/data/threads.test.ts b/packages/db/test/data/threads.test.ts index 69349b53fd..a6db7e0f7a 100644 --- a/packages/db/test/data/threads.test.ts +++ b/packages/db/test/data/threads.test.ts @@ -5,6 +5,7 @@ import { noopNotifier } from "../../src/notifier.js"; import type { DbNotifier } from "../../src/notifier.js"; import { createThread, + countThreads, countLiveThreadsInEnvironment, countNonDeletedAssignedChildThreads, getThread, @@ -692,6 +693,35 @@ describe("threads", () => { ]); }); + it("counts archived visible threads without returning list rows", () => { + const { db, project } = setup(); + const visibleArchived = createThread(db, noopNotifier, { + projectId: project.id, + providerId: "codex", + }); + const hiddenArchived = createThread(db, noopNotifier, { + projectId: project.id, + providerId: "codex", + visibility: "hidden", + }); + const deletedArchived = createThread(db, noopNotifier, { + projectId: project.id, + providerId: "codex", + }); + createThread(db, noopNotifier, { + projectId: project.id, + providerId: "codex", + }); + + archiveThread(db, noopNotifier, visibleArchived.id); + archiveThread(db, noopNotifier, hiddenArchived.id); + archiveThread(db, noopNotifier, deletedArchived.id); + markThreadDeleted(db, noopNotifier, { threadId: deletedArchived.id }); + + expect(countThreads(db, { archived: true })).toBe(1); + expect(countThreads(db, { archived: true, includeHidden: true })).toBe(2); + }); + it("counts active assigned child threads", () => { const { db, project } = setup(); const parent = createThread(db, noopNotifier, { diff --git a/packages/sdk/src/areas/threads.ts b/packages/sdk/src/areas/threads.ts index d1a25551d7..3db85afbf4 100644 --- a/packages/sdk/src/areas/threads.ts +++ b/packages/sdk/src/areas/threads.ts @@ -23,6 +23,8 @@ import type { ThreadArchiveAllResponse, ThreadChildSummaryResponse, ThreadConversationOutlineResponse, + ThreadCountQuery, + ThreadCountResponse, ThreadListResponse, ThreadOpenResponse, ThreadPaneAction, @@ -83,6 +85,11 @@ export interface ThreadListArgs { unsectioned?: boolean; } +export interface ThreadCountArgs { + archived: boolean; + signal?: AbortSignal; +} + export interface ThreadSearchArgs extends ThreadSearchQuery { signal?: AbortSignal; } @@ -99,6 +106,7 @@ export interface ThreadGetArgs { export type ThreadGetResult = ThreadResponse | ThreadWithIncludesResponse; export type ThreadListResult = ThreadListResponse; +export type ThreadCountResult = ThreadCountResponse; export type ThreadSearchResult = ThreadSearchResponse; export type ThreadResolveMentionsResult = ResolveThreadMentionsResponse; export interface ThreadOutputResponse { @@ -433,6 +441,7 @@ export interface ThreadsArea { archiveAll(args: ThreadActionArgs): Promise; childSummary(args: ThreadStatusArgs): Promise; compact(args: ThreadActionArgs): Promise; + count(args: ThreadCountArgs): Promise; cancelPlan(args: ThreadActionArgs): Promise; clearGoal(args: ThreadActionArgs): Promise; conversationOutline( @@ -509,6 +518,10 @@ function listQuery(args: ThreadListArgs | undefined): ThreadListQuery { }; } +function countQuery(args: ThreadCountArgs): ThreadCountQuery { + return { archived: args.archived ? "true" : "false" }; +} + function updateJson(args: ThreadUpdateArgs): UpdateThreadRequest { return { title: args.title, @@ -921,6 +934,14 @@ export function createThreadsArea(args: CreateSdkAreaArgs): ThreadsArea { ), ); }, + async count(input) { + return transport.readJson( + transport.api.v1.threads.count.$get( + { query: countQuery(input) }, + ...signalRequestArgs(input.signal), + ), + ); + }, async defaultExecutionOptions(input) { return transport.readJson( transport.api.v1.threads[":id"]["default-execution-options"].$get( diff --git a/packages/sdk/test/public-types.test.ts b/packages/sdk/test/public-types.test.ts index 6389b7638b..7f88dbabb2 100644 --- a/packages/sdk/test/public-types.test.ts +++ b/packages/sdk/test/public-types.test.ts @@ -356,6 +356,7 @@ type ExpectedThreadsKey = | "childSummary" | "clearGoal" | "compact" + | "count" | "conversationOutline" | "defaultExecutionOptions" | "delete" diff --git a/packages/sdk/test/sdk.test.ts b/packages/sdk/test/sdk.test.ts index a4d63d93da..e6ab6524e8 100644 --- a/packages/sdk/test/sdk.test.ts +++ b/packages/sdk/test/sdk.test.ts @@ -654,6 +654,28 @@ describe("@bb/sdk", () => { ]); }); + it("routes thread count calls without fetching list rows", async () => { + const queue = createFetchQueue([{ body: { count: 7 } }]); + const sdk = createBbSdk({ + transport: createHttpTransport({ + baseUrl: "http://bb.test", + fetch: queue.fetch, + runtime: "node", + }), + }); + + await expect(sdk.threads.count({ archived: true })).resolves.toEqual({ + count: 7, + }); + expect(queue.requests).toEqual([ + { + bodyText: undefined, + method: "GET", + url: "http://bb.test/api/v1/threads/count?archived=true", + }, + ]); + }); + it("routes bounded thread mention resolution through one HTTP request", async () => { const resolved = [ { diff --git a/packages/server-contract/src/api/threads.ts b/packages/server-contract/src/api/threads.ts index 280e7c6795..1d0c8ec4c4 100644 --- a/packages/server-contract/src/api/threads.ts +++ b/packages/server-contract/src/api/threads.ts @@ -309,6 +309,13 @@ export type SendQueuedMessageResponse = z.infer< export const threadListResponseSchema = z.array(threadListEntrySchema); export type ThreadListResponse = z.infer; +export const threadCountResponseSchema = z + .object({ + count: z.number().int().nonnegative(), + }) + .strict(); +export type ThreadCountResponse = z.infer; + export const THREAD_MENTION_RESOLVE_MAX_IDS = 32; export const resolveThreadMentionsRequestSchema = z @@ -655,6 +662,13 @@ export const threadListQuerySchema = z.object({ }); export type ThreadListQuery = z.infer; +export const threadCountQuerySchema = z + .object({ + archived: z.enum(["true", "false"]), + }) + .strict(); +export type ThreadCountQuery = z.infer; + export const threadSearchQuerySchema = z.object({ query: z.string().trim().min(2), limitPerGroup: z.string().regex(/^\d+$/).optional(), diff --git a/packages/server-contract/src/public-api.ts b/packages/server-contract/src/public-api.ts index e2033881c1..caa15e2729 100644 --- a/packages/server-contract/src/public-api.ts +++ b/packages/server-contract/src/public-api.ts @@ -167,6 +167,8 @@ import type { TerminalResizeRequest, ThreadArchiveAllResponse, ThreadChildSummaryResponse, + ThreadCountQuery, + ThreadCountResponse, ThreadEventWaitQuery, ThreadEventsQuery, ThreadSectionMutationResponse, @@ -283,6 +285,7 @@ import { threadFilesRawQuerySchema, threadGetQuerySchema, threadHostFileContentQuerySchema, + threadCountQuerySchema, threadListQuerySchema, threadOpenRequestSchema, threadPaneActionRequestSchema, @@ -896,6 +899,14 @@ export const publicApiRoutes = { }, threads: { + count: defineRoute({ + path: "/threads/count", + method: "get", + request: queryRequest( + threadCountQuerySchema, + ), + response: jsonResponse(), + }), list: defineRoute({ path: "/threads", method: "get", diff --git a/packages/server-contract/test/contract.test.ts b/packages/server-contract/test/contract.test.ts index dab2fe5e6f..b471735411 100644 --- a/packages/server-contract/test/contract.test.ts +++ b/packages/server-contract/test/contract.test.ts @@ -31,6 +31,8 @@ import { terminalOutputResponseSchema, terminalSessionSchema, terminalWebSocketQuerySchema, + threadCountQuerySchema, + threadCountResponseSchema, threadListResponseSchema, threadPendingInteractionsResponseSchema, timelineTurnSummaryDetailsResponseSchema, @@ -826,6 +828,15 @@ describe("server-contract canonical schemas", () => { }), ).toThrow(); + expect(threadCountQuerySchema.parse({ archived: "true" })).toEqual({ + archived: "true", + }); + expect(() => threadCountQuerySchema.parse({})).toThrow(); + expect(threadCountResponseSchema.parse({ count: 12 })).toEqual({ + count: 12, + }); + expect(() => threadCountResponseSchema.parse({ count: -1 })).toThrow(); + expect( threadListResponseSchema.parse([ { From f9b6d0684169ee28ae72c5c756ad957391082868 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Tue, 25 Aug 2026 09:16:12 -0700 Subject: [PATCH 04/11] Group sidebar search across lifecycle states --- .../sidebar/SidebarThreadSearchPanel.test.tsx | 269 ++++++++++++- .../sidebar/SidebarThreadSearchPanel.tsx | 290 ++++++++++++-- .../sidebar/ThreadSearchResultRow.tsx | 272 ++++++++++--- .../sidebar/sidebarThreadSearch.test.tsx | 133 +++++++ .../components/sidebar/sidebarThreadSearch.ts | 361 +++++++++++++++++- 5 files changed, 1227 insertions(+), 98 deletions(-) create mode 100644 apps/app/src/components/sidebar/sidebarThreadSearch.test.tsx diff --git a/apps/app/src/components/sidebar/SidebarThreadSearchPanel.test.tsx b/apps/app/src/components/sidebar/SidebarThreadSearchPanel.test.tsx index 9d860943e4..8861ef97d8 100644 --- a/apps/app/src/components/sidebar/SidebarThreadSearchPanel.test.tsx +++ b/apps/app/src/components/sidebar/SidebarThreadSearchPanel.test.tsx @@ -1,9 +1,15 @@ // @vitest-environment jsdom import { createRef } from "react"; -import { cleanup, render, screen } from "@testing-library/react"; +import { + cleanup, + fireEvent, + render, + screen, + within, +} from "@testing-library/react"; import { createStore, Provider } from "jotai"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ThreadListEntry } from "@bb/domain"; import type { ThreadSearchMatch, @@ -13,14 +19,23 @@ import { useThreadSearch, type UseThreadSearchResult, } from "@/hooks/queries/thread-queries"; +import { + useNewThreadDraftSlots, + type NewThreadDraftRow, +} from "@/hooks/useNewThreadDraftSlots"; +import { TooltipProvider } from "@bb/shared-ui/tooltip"; import { ProjectListActionButtons } from "./ProjectList"; -import { SidebarThreadSearchPanel } from "./SidebarThreadSearchPanel"; +import { + SidebarThreadSearchPanel, + SidebarThreadSearchShowMenu, +} from "./SidebarThreadSearchPanel"; import { splitLayoutAtom } from "@/lib/split-layout/atoms"; import { getSidebarThreadSearchOptionId, haveSameSidebarThreadSearchNavigationItems, isThreadSearchKeyboardEventTarget, type SidebarThreadSearchNavigationItem, + useSidebarThreadSearchLifecycleFilter, } from "./sidebarThreadSearch"; vi.mock("@/hooks/queries/thread-queries", () => ({ @@ -28,8 +43,12 @@ vi.mock("@/hooks/queries/thread-queries", () => ({ value.replace(/\s/g, "").length >= 2, useThreadSearch: vi.fn(), })); +vi.mock("@/hooks/useNewThreadDraftSlots", () => ({ + useNewThreadDraftSlots: vi.fn(), +})); const mockUseThreadSearch = vi.mocked(useThreadSearch); +const mockUseNewThreadDraftSlots = vi.mocked(useNewThreadDraftSlots); function createThreadListEntry({ sectionId = null, @@ -106,6 +125,31 @@ function mockThreadSearch(result: UseThreadSearchResult): void { mockUseThreadSearch.mockReturnValue(result); } +function createDraftRow({ + id, + lastEditedAt, + text, + title = text, +}: { + id: string; + lastEditedAt: number; + text: string; + title?: string; +}): NewThreadDraftRow { + return { + id, + lastEditedAt, + title, + destination: { projectId: "proj_search", sectionId: null }, + draft: { attachments: [], mentions: [], text }, + delete: vi.fn(), + }; +} + +beforeEach(() => { + mockUseNewThreadDraftSlots.mockReturnValue([]); +}); + afterEach(() => { cleanup(); window.localStorage.clear(); @@ -383,8 +427,222 @@ describe("SidebarThreadSearchPanel", () => { />, ); - expect(screen.getByText("Archived")).not.toBeNull(); - expect(screen.getByText("1/3")).not.toBeNull(); + expect(screen.getByText("Archived threads")).not.toBeNull(); + expect(screen.getByText("3")).not.toBeNull(); + }); + + it("renders Threads, Drafts, then Archived threads as one flat navigation sequence", () => { + const activeThread = createThreadListEntry({ + id: "thr_active", + title: "Active needle", + }); + const archivedThread = createThreadListEntry({ + id: "thr_archived", + title: "Archived needle", + }); + archivedThread.archivedAt = 2_000; + mockUseNewThreadDraftSlots.mockReturnValue([ + createDraftRow({ + id: "draft_middle", + lastEditedAt: 1_500, + text: "Draft needle", + }), + ]); + mockThreadSearch({ + data: { + active: { + results: [{ matches: [], thread: activeThread }], + total: 1, + }, + archived: { + results: [{ matches: [], thread: archivedThread }], + total: 1, + }, + }, + debouncedQuery: "needle", + hasSearchableQuery: true, + isDebouncing: false, + isError: false, + isFetching: false, + isLoading: false, + }); + const onNavigationItemsChange = vi.fn(); + + render( + , + ); + + expect( + screen.getAllByRole("group").map((group) => group.ariaLabel), + ).toEqual(["Threads", "Drafts", "Archived threads"]); + expect( + screen.getAllByRole("option").map((option) => option.textContent), + ).toEqual([ + expect.stringContaining("Active needle"), + expect.stringContaining("Draft needle"), + expect.stringContaining("Archived needle"), + ]); + expect(onNavigationItemsChange).toHaveBeenLastCalledWith([ + expect.objectContaining({ kind: "thread", threadId: "thr_active" }), + expect.objectContaining({ kind: "draft", draftSlotId: "draft_middle" }), + expect.objectContaining({ kind: "thread", threadId: "thr_archived" }), + ]); + }); + + it("shows drafts and recently archived threads before a query exists", () => { + const activeThread = createThreadListEntry({ + id: "thr_active_recent", + title: "Active recent", + }); + const archivedThread = createThreadListEntry({ + id: "thr_archived_recent", + title: "Archived recent", + }); + archivedThread.archivedAt = 2_000; + mockUseNewThreadDraftSlots.mockReturnValue([ + createDraftRow({ + id: "draft_recent", + lastEditedAt: 3_000, + text: "Recent draft", + }), + ]); + mockThreadSearch({ + data: undefined, + debouncedQuery: "", + hasSearchableQuery: false, + isDebouncing: false, + isError: false, + isFetching: false, + isLoading: false, + }); + + render( + , + ); + + expect( + screen.getAllByRole("group").map((group) => group.ariaLabel), + ).toEqual(["Threads", "Drafts", "Archived threads"]); + }); +}); + +describe("SidebarThreadSearchShowMenu", () => { + it("keeps all counts visible while independently narrowing result groups", async () => { + const activeThread = createThreadListEntry({ + id: "thr_active_filter", + title: "Active filter", + }); + const archivedThread = createThreadListEntry({ + id: "thr_archived_filter", + title: "Archived filter", + }); + mockUseNewThreadDraftSlots.mockReturnValue([ + createDraftRow({ + id: "draft_filter", + lastEditedAt: 3_000, + text: "Draft filter", + }), + ]); + mockThreadSearch({ + data: { + active: { + results: [{ matches: [], thread: activeThread }], + total: 4, + }, + archived: { + results: [{ matches: [], thread: archivedThread }], + total: 7, + }, + }, + debouncedQuery: "filter", + hasSearchableQuery: true, + isDebouncing: false, + isError: false, + isFetching: false, + isLoading: false, + }); + + function Harness() { + const lifecycleFilter = useSidebarThreadSearchLifecycleFilter(); + return ( + + + + + ); + } + + render(); + fireEvent.pointerDown( + screen.getByRole("button", { name: "Search show options" }), + { button: 0 }, + ); + const showGroup = await screen.findByRole("group", { + name: "Show search results", + }); + expect(within(showGroup).getByText("4")).not.toBeNull(); + expect(within(showGroup).getByText("1")).not.toBeNull(); + expect(within(showGroup).getByText("7")).not.toBeNull(); + + fireEvent.click( + within(showGroup).getByRole("menuitemcheckbox", { + name: /Archived threads/, + }), + ); + + expect( + screen.getByRole("button", { + name: "Search show options (filtered)", + }), + ).not.toBeNull(); + expect( + screen.queryByRole("group", { name: "Archived threads" }), + ).toBeNull(); + + fireEvent.pointerDown( + screen.getByRole("button", { + name: "Search show options (filtered)", + }), + { button: 0 }, + ); + const reopenedGroup = await screen.findByRole("group", { + name: "Show search results", + }); + expect(within(reopenedGroup).getByText("7")).not.toBeNull(); + expect( + within(reopenedGroup) + .getByRole("menuitemcheckbox", { name: /Archived threads/ }) + .getAttribute("data-state"), + ).toBe("unchecked"); }); }); @@ -392,6 +650,7 @@ 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 = { + kind: "thread", id: "active:thr_search", optionId, projectId: "proj_search", diff --git a/apps/app/src/components/sidebar/SidebarThreadSearchPanel.tsx b/apps/app/src/components/sidebar/SidebarThreadSearchPanel.tsx index c4f2a36c9c..59271047f2 100644 --- a/apps/app/src/components/sidebar/SidebarThreadSearchPanel.tsx +++ b/apps/app/src/components/sidebar/SidebarThreadSearchPanel.tsx @@ -7,16 +7,39 @@ import { COARSE_POINTER_ICON_SIZE_CLASS, } from "@bb/shared-ui/coarse-pointer-sizing"; import { Icon, type IconName } from "@bb/shared-ui/icon"; +import { Button } from "@bb/shared-ui/button"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "@bb/shared-ui/dropdown-menu"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@bb/shared-ui/tooltip"; import { useThreadSearch } from "@/hooks/queries/thread-queries"; import { hasThreadSearchableQuery } from "@/hooks/queries/thread-queries"; +import { + useNewThreadDraftSlots, + type NewThreadDraftRow, +} from "@/hooks/useNewThreadDraftSlots"; import { cn } from "@bb/shared-ui/lib/utils"; import { + getSidebarDraftSearchMatch, getSidebarThreadSearchOptionId, isSidebarThreadTitleMatch, SIDEBAR_THREAD_SEARCH_LISTBOX_ID, + SIDEBAR_THREAD_SEARCH_LIFECYCLE_STATES, type SidebarThreadSearchNavigationItem, + type SidebarThreadSearchLifecycleCounts, + type SidebarThreadSearchLifecycleFilterController, + type SidebarThreadSearchLifecycleState, + useSidebarThreadSearchLifecycleFilter, } from "./sidebarThreadSearch"; -import { ThreadSearchResultRow } from "./ThreadSearchResultRow"; +import { + DraftSearchResultRow, + ThreadSearchResultRow, +} from "./ThreadSearchResultRow"; interface SidebarThreadSearchPanelProps { activeIndex: number; @@ -29,18 +52,33 @@ interface SidebarThreadSearchPanelProps { onSelect: (item: SidebarThreadSearchNavigationItem) => void; projectNamesById: ReadonlyMap; query: string; + recentArchivedThreads?: readonly ThreadListEntry[]; recentThreads: readonly ThreadListEntry[]; + lifecycleFilter?: SidebarThreadSearchLifecycleFilterController; showSectionLabels?: boolean; } -interface ThreadSearchRenderableRow { +interface ThreadSearchRenderableThreadRow { + kind: "thread"; id: string; matches: readonly ThreadSearchMatch[]; thread: ThreadListEntry; } +interface ThreadSearchRenderableDraftRow { + kind: "draft"; + draft: NewThreadDraftRow; + id: string; + matches: ThreadSearchMatch["highlightRanges"]; + primaryText: string; +} + +type ThreadSearchRenderableRow = + | ThreadSearchRenderableThreadRow + | ThreadSearchRenderableDraftRow; + interface ThreadSearchSection { - id: "active" | "archived"; + id: SidebarThreadSearchLifecycleState; label: string; rows: readonly ThreadSearchRenderableRow[]; total: number; @@ -71,7 +109,16 @@ function getMessageMatchSeq( function toNavigationItem( row: ThreadSearchRenderableRow, ): SidebarThreadSearchNavigationItem { + if (row.kind === "draft") { + return { + kind: "draft", + draftSlotId: row.draft.id, + id: row.id, + optionId: getSidebarThreadSearchOptionId(row.id), + }; + } return { + kind: "thread", id: row.id, optionId: getSidebarThreadSearchOptionId(row.id), projectId: row.thread.projectId, @@ -80,6 +127,77 @@ function toNavigationItem( }; } +const LIFECYCLE_STATE_LABELS: Record< + SidebarThreadSearchLifecycleState, + string +> = { + active: "Threads", + drafts: "Drafts", + archived: "Archived threads", +}; + +export function SidebarThreadSearchShowMenu({ + lifecycleFilter, +}: { + lifecycleFilter: SidebarThreadSearchLifecycleFilterController; +}) { + return ( + + + + + + + + + Show search results + + + + + Show + + + {SIDEBAR_THREAD_SEARCH_LIFECYCLE_STATES.map((state) => { + const isSelected = lifecycleFilter.selectedStates.includes(state); + return ( + + lifecycleFilter.onStateCheckedChange(state, checked) + } + > + {LIFECYCLE_STATE_LABELS[state]} + + {lifecycleFilter.counts[state]} + + + ); + })} + + + + ); +} + function ThreadSearchMessage({ iconName, isLoading = false, @@ -141,16 +259,39 @@ function renderSectionRows({ )} > {section.label} - {section.total > section.rows.length ? ( - - {section.rows.length}/{section.total} - - ) : null} + + {section.total} +
{section.rows.map((row, rowIndex) => { const index = startIndex + rowIndex; const item = toNavigationItem(row); + if (row.kind === "draft") { + return ( + onActiveIndexChange(index)} + onSelect={() => onSelect(item)} + primaryText={row.primaryText} + projectId={row.draft.destination.projectId} + projectName={projectNamesById.get( + row.draft.destination.projectId, + )} + sectionLabel={ + showSectionLabels && row.draft.destination.sectionId + ? (sectionNamesById.get(row.draft.destination.sectionId) ?? + "Section") + : null + } + title={row.draft.title} + updatedAt={row.draft.lastEditedAt} + /> + ); + } return ( (() => { + const allSections = useMemo(() => { if (!liveQueryIsSearchable) { - const rows = recentThreads + const activeRows: ThreadSearchRenderableThreadRow[] = recentThreads .slice(0, RECENT_THREAD_LIMIT) .map((thread) => ({ - id: `recent:${thread.id}`, + kind: "thread", + id: `active:${thread.id}`, + matches: EMPTY_MATCHES, + thread, + })); + const recentDraftRows: ThreadSearchRenderableDraftRow[] = draftRows + .slice(0, RECENT_THREAD_LIMIT) + .map((draft) => ({ + kind: "draft", + draft, + id: `draft:${draft.id}`, + matches: [], + primaryText: draft.title, + })); + const archivedRows: ThreadSearchRenderableThreadRow[] = + recentArchivedThreads.slice(0, RECENT_THREAD_LIMIT).map((thread) => ({ + kind: "thread", + id: `archived:${thread.id}`, matches: EMPTY_MATCHES, thread, })); return [ { id: "active", - label: "Recent", - rows, - total: rows.length, + label: "Threads", + rows: activeRows, + total: activeRows.length, }, - ]; - } - - if (!searchResultsAreCurrent) { - return [ { - id: "active", - label: "Threads", - rows: [], - total: 0, + id: "drafts", + label: "Drafts", + rows: recentDraftRows, + total: recentDraftRows.length, }, { id: "archived", - label: "Archived", - rows: [], - total: 0, + label: "Archived threads", + rows: archivedRows, + total: archivedRows.length, }, ]; } - const activeRows = + if (!searchResultsAreCurrent) { + return SIDEBAR_THREAD_SEARCH_LIFECYCLE_STATES.map((state) => ({ + id: state, + label: LIFECYCLE_STATE_LABELS[state], + rows: [], + total: 0, + })); + } + + const activeRows: ThreadSearchRenderableThreadRow[] = threadSearch.data?.active.results.map((result) => ({ + kind: "thread", id: `active:${result.thread.id}`, matches: result.matches, thread: result.thread, })) ?? []; - const archivedRows = + const matchingDraftRows: ThreadSearchRenderableDraftRow[] = + draftRows.flatMap((draft) => { + const match = getSidebarDraftSearchMatch({ + query: trimmedQuery, + text: draft.draft.text, + title: draft.title, + }); + return match === null + ? [] + : [ + { + kind: "draft" as const, + draft, + id: `draft:${draft.id}`, + matches: match.highlightRanges, + primaryText: match.text, + }, + ]; + }); + const archivedRows: ThreadSearchRenderableThreadRow[] = threadSearch.data?.archived.results.map((result) => ({ + kind: "thread", id: `archived:${result.thread.id}`, matches: result.matches, thread: result.thread, @@ -246,19 +439,43 @@ export function SidebarThreadSearchPanel({ rows: activeRows, total: threadSearch.data?.active.total ?? 0, }, + { + id: "drafts", + label: "Drafts", + rows: matchingDraftRows, + total: matchingDraftRows.length, + }, { id: "archived", - label: "Archived", + label: "Archived threads", rows: archivedRows, total: threadSearch.data?.archived.total ?? 0, }, ]; }, [ + draftRows, liveQueryIsSearchable, + recentArchivedThreads, recentThreads, searchResultsAreCurrent, threadSearch.data, + trimmedQuery, ]); + const lifecycleCounts = useMemo( + () => ({ + active: + allSections.find((section) => section.id === "active")?.total ?? 0, + drafts: + allSections.find((section) => section.id === "drafts")?.total ?? 0, + archived: + allSections.find((section) => section.id === "archived")?.total ?? 0, + }), + [allSections], + ); + const sections = useMemo(() => { + const selectedStates = new Set(selectedLifecycleStates); + return allSections.filter((section) => selectedStates.has(section.id)); + }, [allSections, selectedLifecycleStates]); const rows = useMemo( () => sections.flatMap((section) => section.rows), [sections], @@ -269,6 +486,17 @@ export function SidebarThreadSearchPanel({ onNavigationItemsChange(navigationItems); }, [navigationItems, onNavigationItemsChange]); + useEffect(() => { + onLifecycleCountsChange(lifecycleCounts); + }, [lifecycleCounts, onLifecycleCountsChange]); + + useEffect( + () => () => { + resetLifecycleFilter(); + }, + [resetLifecycleFilter], + ); + const isLoading = liveQueryIsSearchable && (!searchResultsAreCurrent || @@ -281,7 +509,9 @@ export function SidebarThreadSearchPanel({ const showNoSearchResults = liveQueryIsSearchable && !isLoading && !showError && !hasRows; const showTypeToSearch = - !liveQueryIsSearchable && !showRecentLoading && recentThreads.length === 0; + !liveQueryIsSearchable && + !showRecentLoading && + allSections.every((section) => section.rows.length === 0); let startIndex = 0; return ( diff --git a/apps/app/src/components/sidebar/ThreadSearchResultRow.tsx b/apps/app/src/components/sidebar/ThreadSearchResultRow.tsx index 9e43a0a5d5..b8a2bd8956 100644 --- a/apps/app/src/components/sidebar/ThreadSearchResultRow.tsx +++ b/apps/app/src/components/sidebar/ThreadSearchResultRow.tsx @@ -2,14 +2,16 @@ import { memo, useCallback, useEffect, + useLayoutEffect, + useMemo, useRef, + useState, 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, @@ -25,7 +27,10 @@ import { import { getThreadDisplayTitle } from "@/lib/thread-title"; import { cn } from "@bb/shared-ui/lib/utils"; import { ThreadStatusGlyph } from "./ThreadRow"; -import { isSidebarThreadTitleMatch } from "./sidebarThreadSearch"; +import { + getSidebarThreadSearchMatchWindow, + isSidebarThreadTitleMatch, +} from "./sidebarThreadSearch"; import { usePromptDraftHasInput } from "@/hooks/usePromptDraftStorage"; import { SIDEBAR_ROW_BASE_CLASS, @@ -54,6 +59,31 @@ interface HighlightedTextProps { text: string; } +interface SearchResultRowLayoutProps { + id: string; + isActive: boolean; + metadataText: string; + onActive: () => void; + onSelect: () => void; + primaryHighlightRanges: ThreadSearchMatch["highlightRanges"]; + primaryText: string; + trailingIndicator?: ReactNode; +} + +export interface DraftSearchResultRowProps { + id: string; + isActive: boolean; + matches: ThreadSearchMatch["highlightRanges"]; + onActive: () => void; + onSelect: () => void; + primaryText: string; + projectId: string; + projectName: string | undefined; + sectionLabel?: string | null; + title: string; + updatedAt: number; +} + function clampRange( range: ThreadSearchMatch["highlightRanges"][number], textLength: number, @@ -75,7 +105,7 @@ function HighlightedText({ ranges, text }: HighlightedTextProps) { .filter((range): range is NonNullable => range !== null) .sort((left, right) => left.start - right.start || left.end - right.end); - for (const range of sortedRanges) { + for (const [rangeIndex, range] of sortedRanges.entries()) { if (range.start < cursor) { continue; } @@ -85,7 +115,8 @@ function HighlightedText({ ranges, text }: HighlightedTextProps) { nodes.push( {text.slice(range.start, range.end)} , @@ -118,6 +149,168 @@ function isNonEmptyMetadataPart(value: string | null): value is string { return value !== null && value.length > 0; } +function SearchResultRowLayout({ + id, + isActive, + metadataText, + onActive, + onSelect, + primaryHighlightRanges, + primaryText, + trailingIndicator, +}: SearchResultRowLayoutProps) { + const rowRef = useRef(null); + const matchProbeRef = useRef(null); + const [matchIsHidden, setMatchIsHidden] = useState(false); + const displayMatch = useMemo( + () => + getSidebarThreadSearchMatchWindow({ + highlightRanges: primaryHighlightRanges, + matchIsHidden, + text: primaryText, + }), + [matchIsHidden, primaryHighlightRanges, primaryText], + ); + const handleMouseEnter = useCallback< + MouseEventHandler + >(() => { + onActive(); + }, [onActive]); + + useLayoutEffect(() => { + const probe = matchProbeRef.current; + if (probe === null || primaryHighlightRanges.length === 0) { + setMatchIsHidden(false); + return; + } + + const measure = () => { + const firstMatch = probe.querySelector( + '[data-sidebar-search-first-match="true"]', + ); + const firstMatchRect = firstMatch?.getClientRects()[0]; + if (firstMatchRect === undefined) { + // jsdom and display:none surfaces have no measurable line boxes. Keep + // the ordinary clamp until the row enters a measurable layout. + setMatchIsHidden(false); + return; + } + const probeRect = probe.getBoundingClientRect(); + setMatchIsHidden( + firstMatchRect.top >= probeRect.bottom - 0.5 || + firstMatchRect.bottom > probeRect.bottom + 0.5, + ); + }; + + measure(); + if (typeof ResizeObserver === "undefined") { + window.addEventListener("resize", measure); + return () => window.removeEventListener("resize", measure); + } + const observer = new ResizeObserver(measure); + observer.observe(probe); + return () => observer.disconnect(); + }, [primaryHighlightRanges, primaryText]); + + useEffect(() => { + if (!isActive) { + return; + } + rowRef.current?.scrollIntoView({ block: "nearest" }); + }, [isActive]); + + return ( + + ); +} + +export function DraftSearchResultRow({ + id, + isActive, + matches, + onActive, + onSelect, + primaryText, + projectId, + projectName, + sectionLabel, + title, + updatedAt, +}: DraftSearchResultRowProps) { + const projectMetadata = + projectId !== PERSONAL_PROJECT_ID && projectName ? projectName : null; + const contextLabel = sectionLabel ?? projectMetadata; + const relativeTime = formatRelativeTime({ + timestamp: updatedAt, + now: Date.now(), + }); + const metadataText = [ + primaryText === title ? null : title, + contextLabel, + relativeTime, + ] + .filter(isNonEmptyMetadataPart) + .join(" · "); + + return ( + + ); +} + function ThreadSearchResultRowComponent({ id, isActive, @@ -128,7 +321,6 @@ function ThreadSearchResultRowComponent({ sectionLabel, thread, }: ThreadSearchResultRowProps) { - const rowRef = useRef(null); const title = getThreadDisplayTitle(thread); const titleMatch = getTitleMatch(title, matches); const snippetMatch = getSnippetMatch(matches); @@ -171,63 +363,23 @@ function ThreadSearchResultRowComponent({ 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 ( - + isActive={isActive} + metadataText={metadataText} + onActive={onActive} + onSelect={onSelect} + primaryHighlightRanges={primaryHighlightRanges} + primaryText={primaryText} + trailingIndicator={ + indicatorKind !== "none" ? ( + + + + ) : null + } + /> ); } diff --git a/apps/app/src/components/sidebar/sidebarThreadSearch.test.tsx b/apps/app/src/components/sidebar/sidebarThreadSearch.test.tsx new file mode 100644 index 0000000000..9227198782 --- /dev/null +++ b/apps/app/src/components/sidebar/sidebarThreadSearch.test.tsx @@ -0,0 +1,133 @@ +// @vitest-environment jsdom + +import { act, cleanup, render } from "@testing-library/react"; +import { useEffect } from "react"; +import { afterEach, describe, expect, it } from "vitest"; +import { + getSidebarDraftSearchMatch, + getSidebarThreadSearchMatchWindow, + useSidebarThreadSearchLifecycleFilter, + type SidebarThreadSearchLifecycleFilterController, +} from "./sidebarThreadSearch"; + +afterEach(cleanup); + +describe("sidebar draft search matching", () => { + it("matches local draft text and falls back to an attachment-only title", () => { + expect( + getSidebarDraftSearchMatch({ + query: "permission", + text: "Review the Permission boundary", + title: "Review the Permission boundary", + }), + ).toEqual({ + highlightRanges: [{ start: 11, end: 21 }], + text: "Review the Permission boundary", + }); + expect( + getSidebarDraftSearchMatch({ + query: "thread", + text: "", + title: "New thread", + }), + ).toEqual({ + highlightRanges: [{ start: 4, end: 10 }], + text: "New thread", + }); + }); +}); + +describe("sidebar search match window", () => { + it("keeps the plain text when the two-line clamp already reveals the match", () => { + const text = "A short sentence with the needle visible."; + expect( + getSidebarThreadSearchMatchWindow({ + highlightRanges: [{ start: 26, end: 32 }], + matchIsHidden: false, + text, + }), + ).toEqual({ + highlightRanges: [{ start: 26, end: 32 }], + text, + wasWindowed: false, + }); + }); + + it("reveals a late 240px-row match with bounded context and rebased ranges", () => { + const text = + "This deliberately long preface fills more than two lines in a 240px sidebar result before the visible needle and then continues with enough following words to require a trailing cut."; + const matchStart = text.indexOf("needle"); + const result = getSidebarThreadSearchMatchWindow({ + highlightRanges: [{ start: matchStart, end: matchStart + 6 }], + matchIsHidden: true, + text, + }); + + expect(result.wasWindowed).toBe(true); + expect(result.text.startsWith("…")).toBe(true); + expect(result.text.endsWith("…")).toBe(true); + expect(result.text.indexOf("needle")).toBeLessThanOrEqual(17); + expect( + result.text.length - result.text.indexOf("needle") - 6, + ).toBeLessThanOrEqual(41); + expect( + result.text.slice( + result.highlightRanges[0]?.start, + result.highlightRanges[0]?.end, + ), + ).toBe("needle"); + }); + + it("never clips or rebases through a surrogate pair", () => { + const text = `${"word ".repeat(8)}😀😀😀😀 target ${"after ".repeat(12)}`; + const matchStart = text.indexOf("target"); + const result = getSidebarThreadSearchMatchWindow({ + // Deliberately malformed input boundaries inside emoji pairs exercise + // the defensive normalization used for server-provided UTF-16 offsets. + highlightRanges: [ + { start: text.indexOf("😀") + 1, end: text.indexOf("😀") + 3 }, + { start: matchStart, end: matchStart + 6 }, + ], + matchIsHidden: true, + text, + }); + + expect(result.text).not.toContain("�"); + for (const range of result.highlightRanges) { + const highlighted = result.text.slice(range.start, range.end); + expect(highlighted).not.toBe("\ud83d"); + expect(highlighted).not.toBe("\ude00"); + } + }); +}); + +describe("sidebar search lifecycle filter", () => { + it("keeps at least one state selected and resets to all-on", () => { + let controller: SidebarThreadSearchLifecycleFilterController | null = null; + + function Harness() { + const next = useSidebarThreadSearchLifecycleFilter(); + useEffect(() => { + controller = next; + }); + return null; + } + + render(); + const current = () => { + if (controller === null) throw new Error("controller not ready"); + return controller; + }; + + act(() => current().onStateCheckedChange("active", false)); + act(() => current().onStateCheckedChange("drafts", false)); + expect(current().selectedStates).toEqual(["archived"]); + + act(() => current().onStateCheckedChange("archived", false)); + expect(current().selectedStates).toEqual(["archived"]); + + act(() => current().reset()); + expect(current().selectedStates).toEqual(["active", "drafts", "archived"]); + expect(current().isFiltered).toBe(false); + }); +}); diff --git a/apps/app/src/components/sidebar/sidebarThreadSearch.ts b/apps/app/src/components/sidebar/sidebarThreadSearch.ts index 70274e52d9..2225212edc 100644 --- a/apps/app/src/components/sidebar/sidebarThreadSearch.ts +++ b/apps/app/src/components/sidebar/sidebarThreadSearch.ts @@ -1,12 +1,16 @@ -import type { RefObject } from "react"; +import { useCallback, useMemo, useState, 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 { +interface SidebarThreadSearchNavigationItemBase { id: string; optionId: string; +} + +export interface SidebarThreadSearchThreadNavigationItem extends SidebarThreadSearchNavigationItemBase { + kind: "thread"; projectId: string; threadId: string; /** @@ -17,6 +21,350 @@ export interface SidebarThreadSearchNavigationItem { messageSeq: number | null; } +export interface SidebarThreadSearchDraftNavigationItem extends SidebarThreadSearchNavigationItemBase { + kind: "draft"; + draftSlotId: string; +} + +export type SidebarThreadSearchNavigationItem = + | SidebarThreadSearchThreadNavigationItem + | SidebarThreadSearchDraftNavigationItem; + +export const SIDEBAR_THREAD_SEARCH_LIFECYCLE_STATES = [ + "active", + "drafts", + "archived", +] as const; + +export type SidebarThreadSearchLifecycleState = + (typeof SIDEBAR_THREAD_SEARCH_LIFECYCLE_STATES)[number]; + +export type SidebarThreadSearchLifecycleCounts = Record< + SidebarThreadSearchLifecycleState, + number +>; + +export interface SidebarThreadSearchLifecycleFilterController { + counts: SidebarThreadSearchLifecycleCounts; + isFiltered: boolean; + onCountsChange: (counts: SidebarThreadSearchLifecycleCounts) => void; + onStateCheckedChange: ( + state: SidebarThreadSearchLifecycleState, + checked: boolean, + ) => void; + reset: () => void; + selectedStates: readonly SidebarThreadSearchLifecycleState[]; +} + +const EMPTY_LIFECYCLE_COUNTS: SidebarThreadSearchLifecycleCounts = { + active: 0, + drafts: 0, + archived: 0, +}; + +function haveSameLifecycleCounts( + left: SidebarThreadSearchLifecycleCounts, + right: SidebarThreadSearchLifecycleCounts, +): boolean { + return SIDEBAR_THREAD_SEARCH_LIFECYCLE_STATES.every( + (state) => left[state] === right[state], + ); +} + +/** + * Transient lifecycle state for built-in sidebar search. The owner mounts one + * controller while search is available; the result panel resets it on + * dismissal so a fresh search always starts with every group visible. + */ +export function useSidebarThreadSearchLifecycleFilter(): SidebarThreadSearchLifecycleFilterController { + const [selectedStates, setSelectedStates] = useState< + readonly SidebarThreadSearchLifecycleState[] + >(SIDEBAR_THREAD_SEARCH_LIFECYCLE_STATES); + const [counts, setCounts] = useState( + EMPTY_LIFECYCLE_COUNTS, + ); + const selectedStateSet = useMemo( + () => new Set(selectedStates), + [selectedStates], + ); + const onCountsChange = useCallback( + (nextCounts: SidebarThreadSearchLifecycleCounts) => { + setCounts((current) => + haveSameLifecycleCounts(current, nextCounts) ? current : nextCounts, + ); + }, + [], + ); + const onStateCheckedChange = useCallback( + (state: SidebarThreadSearchLifecycleState, checked: boolean) => { + setSelectedStates((current) => { + const isSelected = current.includes(state); + if (checked === isSelected) return current; + if (!checked && current.length === 1) return current; + return SIDEBAR_THREAD_SEARCH_LIFECYCLE_STATES.filter((candidate) => + candidate === state ? checked : current.includes(candidate), + ); + }); + }, + [], + ); + const reset = useCallback(() => { + setSelectedStates(SIDEBAR_THREAD_SEARCH_LIFECYCLE_STATES); + setCounts(EMPTY_LIFECYCLE_COUNTS); + }, []); + + return { + counts, + isFiltered: + selectedStateSet.size < SIDEBAR_THREAD_SEARCH_LIFECYCLE_STATES.length, + onCountsChange, + onStateCheckedChange, + reset, + selectedStates, + }; +} + +export interface SidebarThreadSearchMatchWindow { + highlightRanges: ThreadSearchMatch["highlightRanges"]; + text: string; + wasWindowed: boolean; +} + +export interface SidebarDraftSearchMatch { + highlightRanges: ThreadSearchMatch["highlightRanges"]; + text: string; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** Client-side draft matching mirrors the visible phrase the user typed. */ +export function getSidebarDraftSearchMatch({ + query, + text, + title, +}: { + query: string; + text: string; + title: string; +}): SidebarDraftSearchMatch | null { + const trimmedQuery = query.trim(); + if (trimmedQuery.length === 0) return null; + const matcher = new RegExp(escapeRegExp(trimmedQuery), "iu"); + const textMatch = matcher.exec(text); + if (textMatch !== null) { + return { + highlightRanges: [ + { start: textMatch.index, end: textMatch.index + textMatch[0].length }, + ], + text, + }; + } + const titleMatch = matcher.exec(title); + if (titleMatch === null) return null; + return { + highlightRanges: [ + { + start: titleMatch.index, + end: titleMatch.index + titleMatch[0].length, + }, + ], + text: title, + }; +} + +function isHighSurrogate(value: number): boolean { + return value >= 0xd800 && value <= 0xdbff; +} + +function isLowSurrogate(value: number): boolean { + return value >= 0xdc00 && value <= 0xdfff; +} + +function clampTextOffset(value: number, textLength: number): number { + return Math.max(0, Math.min(value, textLength)); +} + +function moveOffsetOffSurrogateSplit( + text: string, + value: number, + direction: "backward" | "forward", +): number { + const offset = clampTextOffset(value, text.length); + if ( + offset > 0 && + offset < text.length && + isHighSurrogate(text.charCodeAt(offset - 1)) && + isLowSurrogate(text.charCodeAt(offset)) + ) { + return direction === "backward" ? offset - 1 : offset + 1; + } + return offset; +} + +function moveByCodePoints(text: string, from: number, count: number): number { + let offset = moveOffsetOffSurrogateSplit( + text, + from, + count < 0 ? "backward" : "forward", + ); + const step = count < 0 ? -1 : 1; + for (let moved = 0; moved < Math.abs(count); moved += 1) { + if (step < 0) { + if (offset === 0) break; + offset -= 1; + if ( + offset > 0 && + isLowSurrogate(text.charCodeAt(offset)) && + isHighSurrogate(text.charCodeAt(offset - 1)) + ) { + offset -= 1; + } + } else { + if (offset === text.length) break; + if ( + isHighSurrogate(text.charCodeAt(offset)) && + offset + 1 < text.length && + isLowSurrogate(text.charCodeAt(offset + 1)) + ) { + offset += 2; + } else { + offset += 1; + } + } + } + return offset; +} + +function isWordCharacter(value: string): boolean { + return /[\p{L}\p{N}_]/u.test(value); +} + +function readCodePointBefore(text: string, offset: number): string { + if (offset <= 0) return ""; + const previous = moveByCodePoints(text, offset, -1); + return text.slice(previous, offset); +} + +function readCodePointAt(text: string, offset: number): string { + if (offset >= text.length) return ""; + const next = moveByCodePoints(text, offset, 1); + return text.slice(offset, next); +} + +function isWordStart(text: string, offset: number): boolean { + const current = readCodePointAt(text, offset); + return ( + current.length > 0 && + isWordCharacter(current) && + !isWordCharacter(readCodePointBefore(text, offset)) + ); +} + +function isWordEnd(text: string, offset: number): boolean { + const previous = readCodePointBefore(text, offset); + return ( + previous.length > 0 && + isWordCharacter(previous) && + !isWordCharacter(readCodePointAt(text, offset)) + ); +} + +function findWindowStart( + text: string, + candidate: number, + matchStart: number, +): number { + for ( + let offset = candidate; + offset < matchStart; + offset = moveByCodePoints(text, offset, 1) + ) { + if (isWordStart(text, offset)) return offset; + } + return matchStart; +} + +function findWindowEnd( + text: string, + matchEnd: number, + candidate: number, +): number { + for ( + let offset = candidate; + offset > matchEnd; + offset = moveByCodePoints(text, offset, -1) + ) { + if (isWordEnd(text, offset)) return offset; + } + return matchEnd; +} + +function normalizeHighlightRange( + text: string, + range: ThreadSearchMatch["highlightRanges"][number], +): ThreadSearchMatch["highlightRanges"][number] | null { + const start = moveOffsetOffSurrogateSplit(text, range.start, "backward"); + const end = moveOffsetOffSurrogateSplit(text, range.end, "forward"); + return end > start ? { start, end } : null; +} + +/** + * Clips around the first match only after layout proves the ordinary two-line + * clamp would hide it. Offsets remain UTF-16 indices (matching server ranges), + * while the 16/40 context limits count Unicode code points and every boundary + * is moved away from the middle of a surrogate pair. + */ +export function getSidebarThreadSearchMatchWindow({ + highlightRanges, + matchIsHidden, + text, +}: { + highlightRanges: ThreadSearchMatch["highlightRanges"]; + matchIsHidden: boolean; + text: string; +}): SidebarThreadSearchMatchWindow { + const normalizedRanges = highlightRanges + .map((range) => normalizeHighlightRange(text, range)) + .filter((range): range is NonNullable => range !== null) + .sort((left, right) => left.start - right.start || left.end - right.end); + const firstMatch = normalizedRanges[0]; + if (!matchIsHidden || firstMatch === undefined) { + return { + highlightRanges: normalizedRanges, + text, + wasWindowed: false, + }; + } + + const candidateStart = moveByCodePoints(text, firstMatch.start, -16); + const candidateEnd = moveByCodePoints(text, firstMatch.end, 40); + const sliceStart = + candidateStart === 0 + ? 0 + : findWindowStart(text, candidateStart, firstMatch.start); + const sliceEnd = + candidateEnd === text.length + ? text.length + : findWindowEnd(text, firstMatch.end, candidateEnd); + const hasLeadingEllipsis = sliceStart > 0; + const hasTrailingEllipsis = sliceEnd < text.length; + const rangeOffset = sliceStart - (hasLeadingEllipsis ? 1 : 0); + + return { + highlightRanges: normalizedRanges.flatMap((range) => { + const start = Math.max(range.start, sliceStart); + const end = Math.min(range.end, sliceEnd); + return end > start + ? [{ start: start - rangeOffset, end: end - rangeOffset }] + : []; + }), + text: `${hasLeadingEllipsis ? "…" : ""}${text.slice(sliceStart, sliceEnd)}${hasTrailingEllipsis ? "…" : ""}`, + wasWindowed: true, + }; +} + export interface SidebarThreadSearchInputController { activeDescendantId: string | undefined; inputRef: RefObject; @@ -74,6 +422,13 @@ export function haveSameSidebarThreadSearchNavigationItems( (item, index) => item.id === right[index]?.id && item.optionId === right[index]?.optionId && - item.messageSeq === right[index]?.messageSeq, + item.kind === right[index]?.kind && + (item.kind === "thread" + ? right[index]?.kind === "thread" && + item.projectId === right[index].projectId && + item.threadId === right[index].threadId && + item.messageSeq === right[index].messageSeq + : right[index]?.kind === "draft" && + item.draftSlotId === right[index].draftSlotId), ); } From 8942d8d4e3198e2e682978a9196c251c3e714c31 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Tue, 25 Aug 2026 09:28:02 -0700 Subject: [PATCH 05/11] Render sidebar lifecycle drafts and archive --- .../app/src/components/sidebar/AppSidebar.tsx | 21 +- .../src/components/sidebar/ProjectList.tsx | 326 ++++++++++-- .../SidebarDisplayOptionsMenu.test.tsx | 124 ++++- .../sidebar/SidebarLifecycleRows.test.tsx | 387 ++++++++++++++ .../sidebar/SidebarLifecycleRows.tsx | 495 ++++++++++++++++++ .../sidebar/useSidebarThreadSearch.test.tsx | 1 + .../thread-state-cache-owner.test.ts | 45 ++ 7 files changed, 1341 insertions(+), 58 deletions(-) create mode 100644 apps/app/src/components/sidebar/SidebarLifecycleRows.test.tsx create mode 100644 apps/app/src/components/sidebar/SidebarLifecycleRows.tsx diff --git a/apps/app/src/components/sidebar/AppSidebar.tsx b/apps/app/src/components/sidebar/AppSidebar.tsx index 5824b3d9d0..b48b14ebd3 100644 --- a/apps/app/src/components/sidebar/AppSidebar.tsx +++ b/apps/app/src/components/sidebar/AppSidebar.tsx @@ -47,8 +47,12 @@ import { import { getRootComposeRoutePath, getThreadRoutePath } from "@/lib/route-paths"; import { usePaneContentSplitDrag } from "./usePaneContentSplitDrag"; import { createNewThreadDraftSlotId } from "@/lib/prompt-draft-slots"; +import { withRootComposeDraftSlotId } from "@/lib/root-compose-location-state"; import { openUrlInExternalBrowser } from "@/lib/url-open-routing"; -import type { SidebarThreadSearchNavigationItem } from "./sidebarThreadSearch"; +import { + useSidebarThreadSearchLifecycleFilter, + type SidebarThreadSearchNavigationItem, +} from "./sidebarThreadSearch"; import { useSidebarThreadSearch } from "./useSidebarThreadSearch"; import { EMPTY_SIDEBAR_THREAD_SHORTCUT_KEYS, @@ -193,6 +197,8 @@ export function AppSidebar({ hiddenSidebarTopLevelSectionIdsAtom, ); const pluginNavPanels = usePluginNavPanelChrome(); + const searchLifecycleFilter = useSidebarThreadSearchLifecycleFilter(); + const usesBuiltInThreadList = threadListReplacement.kind === "owner"; const openSidebarForThreadSearch = useCallback(() => { if (isCompactViewport) { @@ -204,6 +210,15 @@ export function AppSidebar({ const openSearchedThread = useCallback( (item: SidebarThreadSearchNavigationItem) => { + if (item.kind === "draft") { + void navigate(getRootComposeRoutePath(), { + state: withRootComposeDraftSlotId( + { focusPrompt: true }, + item.draftSlotId, + ), + }); + return; + } void navigate( getThreadRoutePath({ projectId: item.projectId, @@ -372,6 +387,7 @@ export function AppSidebar({ onProjectSelect={closeOnMobile} isCreatingProject={quickCreateProject.isCreating} threadSearch={threadSearchPanelController} + searchLifecycleFilter={searchLifecycleFilter} /> ); @@ -430,6 +446,9 @@ export function AppSidebar({ onQueryChange: threadSearch.onQueryChange, query: threadSearch.query, }} + searchLifecycleFilter={ + usesBuiltInThreadList ? searchLifecycleFilter : undefined + } /> {toolsRoutePath ? ( void; onProjectSelect?: () => void; isCreatingProject?: boolean; threadSearch?: SidebarThreadSearchPanelController; + searchLifecycleFilter?: SidebarThreadSearchLifecycleFilterController; } interface ProjectListActionButtonsProps { @@ -183,6 +208,7 @@ interface ProjectListActionButtonsProps { }; onNewChat?: () => void; threadSearch?: SidebarThreadSearchInputController; + searchLifecycleFilter?: SidebarThreadSearchLifecycleFilterController; } interface ProjectListShellProps { @@ -209,6 +235,8 @@ interface ProjectListThreadsSectionActionsProps { } interface SidebarDisplayOptionsMenuProps { + activeCount?: number; + draftCount?: number; open?: boolean; onOpenChange?: (open: boolean) => void; } @@ -633,10 +661,12 @@ const SIDEBAR_SORT_OPTIONS = [ function SidebarDisplayMenuTrigger({ ariaLabel, + filtered, iconName, tooltip, }: { ariaLabel: string; + filtered: boolean; iconName: IconName; tooltip: string; }) { @@ -653,13 +683,20 @@ function SidebarDisplayMenuTrigger({ size="icon" aria-label={ariaLabel} className={cn( - "rounded-md p-0 text-muted-foreground", + "relative rounded-md p-0 text-muted-foreground", "data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-foreground", LIST_HOVER_TRANSITION, COARSE_POINTER_ROW_ACTION_SIZE_CLASS, )} > + {filtered ? ( +
{section.rows.map((row, rowIndex) => { const index = startIndex + rowIndex; const item = toNavigationItem(row); - if (row.kind === "draft") { - return ( - onActiveIndexChange(index)} - onSelect={() => onSelect(item)} - primaryText={row.primaryText} - projectId={row.draft.destination.projectId} - projectName={projectNamesById.get( - row.draft.destination.projectId, - )} - sectionLabel={ - showSectionLabels && row.draft.destination.sectionId - ? (sectionNamesById.get(row.draft.destination.sectionId) ?? - "Section") - : null - } - title={row.draft.title} - updatedAt={row.draft.lastEditedAt} - /> - ); - } return ( (() => { + const sections = useMemo(() => { if (!liveQueryIsSearchable) { - const activeRows: ThreadSearchRenderableThreadRow[] = recentThreads + const rows = recentThreads .slice(0, RECENT_THREAD_LIMIT) .map((thread) => ({ - kind: "thread", - id: `active:${thread.id}`, - matches: EMPTY_MATCHES, - thread, - })); - const recentDraftRows: ThreadSearchRenderableDraftRow[] = draftRows - .slice(0, RECENT_THREAD_LIMIT) - .map((draft) => ({ - kind: "draft", - draft, - id: `draft:${draft.id}`, - matches: [], - primaryText: draft.title, - })); - const archivedRows: ThreadSearchRenderableThreadRow[] = - recentArchivedThreads.slice(0, RECENT_THREAD_LIMIT).map((thread) => ({ - kind: "thread", - id: `archived:${thread.id}`, + id: `recent:${thread.id}`, matches: EMPTY_MATCHES, thread, })); return [ { id: "active", - label: "Threads", - rows: activeRows, - total: activeRows.length, + label: "Recent", + rows, + total: rows.length, }, + ]; + } + + if (!searchResultsAreCurrent) { + return [ { - id: "drafts", - label: "Drafts", - rows: recentDraftRows, - total: recentDraftRows.length, + id: "active", + label: "Threads", + rows: [], + total: 0, }, { id: "archived", - label: "Archived threads", - rows: archivedRows, - total: archivedRows.length, + label: "Archived", + rows: [], + total: 0, }, ]; } - if (!searchResultsAreCurrent) { - return SIDEBAR_THREAD_SEARCH_LIFECYCLE_STATES.map((state) => ({ - id: state, - label: LIFECYCLE_STATE_LABELS[state], - rows: [], - total: 0, - })); - } - - const activeRows: ThreadSearchRenderableThreadRow[] = + const activeRows = threadSearch.data?.active.results.map((result) => ({ - kind: "thread", id: `active:${result.thread.id}`, matches: result.matches, thread: result.thread, })) ?? []; - const matchingDraftRows: ThreadSearchRenderableDraftRow[] = - draftRows.flatMap((draft) => { - const match = getSidebarDraftSearchMatch({ - query: trimmedQuery, - text: draft.draft.text, - title: draft.title, - }); - return match === null - ? [] - : [ - { - kind: "draft" as const, - draft, - id: `draft:${draft.id}`, - matches: match.highlightRanges, - primaryText: match.text, - }, - ]; - }); - const archivedRows: ThreadSearchRenderableThreadRow[] = + const archivedRows = threadSearch.data?.archived.results.map((result) => ({ - kind: "thread", id: `archived:${result.thread.id}`, matches: result.matches, thread: result.thread, @@ -439,43 +246,19 @@ export function SidebarThreadSearchPanel({ rows: activeRows, total: threadSearch.data?.active.total ?? 0, }, - { - id: "drafts", - label: "Drafts", - rows: matchingDraftRows, - total: matchingDraftRows.length, - }, { id: "archived", - label: "Archived threads", + label: "Archived", rows: archivedRows, total: threadSearch.data?.archived.total ?? 0, }, ]; }, [ - draftRows, liveQueryIsSearchable, - recentArchivedThreads, recentThreads, searchResultsAreCurrent, threadSearch.data, - trimmedQuery, ]); - const lifecycleCounts = useMemo( - () => ({ - active: - allSections.find((section) => section.id === "active")?.total ?? 0, - drafts: - allSections.find((section) => section.id === "drafts")?.total ?? 0, - archived: - allSections.find((section) => section.id === "archived")?.total ?? 0, - }), - [allSections], - ); - const sections = useMemo(() => { - const selectedStates = new Set(selectedLifecycleStates); - return allSections.filter((section) => selectedStates.has(section.id)); - }, [allSections, selectedLifecycleStates]); const rows = useMemo( () => sections.flatMap((section) => section.rows), [sections], @@ -486,17 +269,6 @@ export function SidebarThreadSearchPanel({ onNavigationItemsChange(navigationItems); }, [navigationItems, onNavigationItemsChange]); - useEffect(() => { - onLifecycleCountsChange(lifecycleCounts); - }, [lifecycleCounts, onLifecycleCountsChange]); - - useEffect( - () => () => { - resetLifecycleFilter(); - }, - [resetLifecycleFilter], - ); - const isLoading = liveQueryIsSearchable && (!searchResultsAreCurrent || @@ -509,9 +281,7 @@ export function SidebarThreadSearchPanel({ const showNoSearchResults = liveQueryIsSearchable && !isLoading && !showError && !hasRows; const showTypeToSearch = - !liveQueryIsSearchable && - !showRecentLoading && - allSections.every((section) => section.rows.length === 0); + !liveQueryIsSearchable && !showRecentLoading && recentThreads.length === 0; let startIndex = 0; return ( diff --git a/apps/app/src/components/sidebar/ThreadSearchResultRow.tsx b/apps/app/src/components/sidebar/ThreadSearchResultRow.tsx index b8a2bd8956..9e43a0a5d5 100644 --- a/apps/app/src/components/sidebar/ThreadSearchResultRow.tsx +++ b/apps/app/src/components/sidebar/ThreadSearchResultRow.tsx @@ -2,16 +2,14 @@ import { memo, useCallback, useEffect, - useLayoutEffect, - useMemo, useRef, - useState, 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, @@ -27,10 +25,7 @@ import { import { getThreadDisplayTitle } from "@/lib/thread-title"; import { cn } from "@bb/shared-ui/lib/utils"; import { ThreadStatusGlyph } from "./ThreadRow"; -import { - getSidebarThreadSearchMatchWindow, - isSidebarThreadTitleMatch, -} from "./sidebarThreadSearch"; +import { isSidebarThreadTitleMatch } from "./sidebarThreadSearch"; import { usePromptDraftHasInput } from "@/hooks/usePromptDraftStorage"; import { SIDEBAR_ROW_BASE_CLASS, @@ -59,31 +54,6 @@ interface HighlightedTextProps { text: string; } -interface SearchResultRowLayoutProps { - id: string; - isActive: boolean; - metadataText: string; - onActive: () => void; - onSelect: () => void; - primaryHighlightRanges: ThreadSearchMatch["highlightRanges"]; - primaryText: string; - trailingIndicator?: ReactNode; -} - -export interface DraftSearchResultRowProps { - id: string; - isActive: boolean; - matches: ThreadSearchMatch["highlightRanges"]; - onActive: () => void; - onSelect: () => void; - primaryText: string; - projectId: string; - projectName: string | undefined; - sectionLabel?: string | null; - title: string; - updatedAt: number; -} - function clampRange( range: ThreadSearchMatch["highlightRanges"][number], textLength: number, @@ -105,7 +75,7 @@ function HighlightedText({ ranges, text }: HighlightedTextProps) { .filter((range): range is NonNullable => range !== null) .sort((left, right) => left.start - right.start || left.end - right.end); - for (const [rangeIndex, range] of sortedRanges.entries()) { + for (const range of sortedRanges) { if (range.start < cursor) { continue; } @@ -115,8 +85,7 @@ function HighlightedText({ ranges, text }: HighlightedTextProps) { nodes.push( {text.slice(range.start, range.end)} , @@ -149,168 +118,6 @@ function isNonEmptyMetadataPart(value: string | null): value is string { return value !== null && value.length > 0; } -function SearchResultRowLayout({ - id, - isActive, - metadataText, - onActive, - onSelect, - primaryHighlightRanges, - primaryText, - trailingIndicator, -}: SearchResultRowLayoutProps) { - const rowRef = useRef(null); - const matchProbeRef = useRef(null); - const [matchIsHidden, setMatchIsHidden] = useState(false); - const displayMatch = useMemo( - () => - getSidebarThreadSearchMatchWindow({ - highlightRanges: primaryHighlightRanges, - matchIsHidden, - text: primaryText, - }), - [matchIsHidden, primaryHighlightRanges, primaryText], - ); - const handleMouseEnter = useCallback< - MouseEventHandler - >(() => { - onActive(); - }, [onActive]); - - useLayoutEffect(() => { - const probe = matchProbeRef.current; - if (probe === null || primaryHighlightRanges.length === 0) { - setMatchIsHidden(false); - return; - } - - const measure = () => { - const firstMatch = probe.querySelector( - '[data-sidebar-search-first-match="true"]', - ); - const firstMatchRect = firstMatch?.getClientRects()[0]; - if (firstMatchRect === undefined) { - // jsdom and display:none surfaces have no measurable line boxes. Keep - // the ordinary clamp until the row enters a measurable layout. - setMatchIsHidden(false); - return; - } - const probeRect = probe.getBoundingClientRect(); - setMatchIsHidden( - firstMatchRect.top >= probeRect.bottom - 0.5 || - firstMatchRect.bottom > probeRect.bottom + 0.5, - ); - }; - - measure(); - if (typeof ResizeObserver === "undefined") { - window.addEventListener("resize", measure); - return () => window.removeEventListener("resize", measure); - } - const observer = new ResizeObserver(measure); - observer.observe(probe); - return () => observer.disconnect(); - }, [primaryHighlightRanges, primaryText]); - - useEffect(() => { - if (!isActive) { - return; - } - rowRef.current?.scrollIntoView({ block: "nearest" }); - }, [isActive]); - - return ( - - ); -} - -export function DraftSearchResultRow({ - id, - isActive, - matches, - onActive, - onSelect, - primaryText, - projectId, - projectName, - sectionLabel, - title, - updatedAt, -}: DraftSearchResultRowProps) { - const projectMetadata = - projectId !== PERSONAL_PROJECT_ID && projectName ? projectName : null; - const contextLabel = sectionLabel ?? projectMetadata; - const relativeTime = formatRelativeTime({ - timestamp: updatedAt, - now: Date.now(), - }); - const metadataText = [ - primaryText === title ? null : title, - contextLabel, - relativeTime, - ] - .filter(isNonEmptyMetadataPart) - .join(" · "); - - return ( - - ); -} - function ThreadSearchResultRowComponent({ id, isActive, @@ -321,6 +128,7 @@ function ThreadSearchResultRowComponent({ sectionLabel, thread, }: ThreadSearchResultRowProps) { + const rowRef = useRef(null); const title = getThreadDisplayTitle(thread); const titleMatch = getTitleMatch(title, matches); const snippetMatch = getSnippetMatch(matches); @@ -363,23 +171,63 @@ function ThreadSearchResultRowComponent({ 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 ( - - - - ) : null - } - /> + type="button" + role="option" + aria-selected={isActive} + className={cn( + SIDEBAR_ROW_BASE_CLASS, + SIDEBAR_STANDARD_ROW_PADDING_CLASS, + SIDEBAR_ROW_INTERACTIVE_STATE_CLASS, + "min-h-10 py-1.5 pr-2 text-left outline-none ring-sidebar-ring focus-visible:ring-2", + isActive && "bg-sidebar-accent text-sidebar-accent-foreground", + )} + onMouseEnter={handleMouseEnter} + onFocus={onActive} + onClick={onSelect} + > + + + + + + {snippetMatch ? ( + + + {indicatorKind !== "none" ? ( + + + + ) : null} + ); } diff --git a/apps/app/src/components/sidebar/sidebarThreadSearch.test.tsx b/apps/app/src/components/sidebar/sidebarThreadSearch.test.tsx deleted file mode 100644 index 8a0840760a..0000000000 --- a/apps/app/src/components/sidebar/sidebarThreadSearch.test.tsx +++ /dev/null @@ -1,169 +0,0 @@ -// @vitest-environment jsdom - -import { act, cleanup, render } from "@testing-library/react"; -import { useEffect } from "react"; -import { afterEach, describe, expect, it } from "vitest"; -import { - getSidebarDraftSearchMatch, - getSidebarThreadSearchMatchWindow, - useSidebarThreadSearchLifecycleFilter, - type SidebarThreadSearchLifecycleFilterController, -} from "./sidebarThreadSearch"; - -afterEach(cleanup); - -describe("sidebar draft search matching", () => { - it("matches local draft text and falls back to an attachment-only title", () => { - expect( - getSidebarDraftSearchMatch({ - query: "permission", - text: "Review the Permission boundary", - title: "Review the Permission boundary", - }), - ).toEqual({ - highlightRanges: [{ start: 11, end: 21 }], - text: "Review the Permission boundary", - }); - expect( - getSidebarDraftSearchMatch({ - query: "thread", - text: "", - title: "New thread", - }), - ).toEqual({ - highlightRanges: [{ start: 4, end: 10 }], - text: "New thread", - }); - }); - - it("matches every query token across draft text like server thread search", () => { - expect( - getSidebarDraftSearchMatch({ - query: "alpha beta", - text: "Alpha begins here; beta finishes later.", - title: "Alpha begins here; beta finishes later.", - }), - ).toEqual({ - highlightRanges: [ - { start: 0, end: 5 }, - { start: 19, end: 23 }, - ], - text: "Alpha begins here; beta finishes later.", - }); - expect( - getSidebarDraftSearchMatch({ - query: "alpha missing", - text: "Alpha begins here; beta finishes later.", - title: "Alpha begins here; beta finishes later.", - }), - ).toBeNull(); - }); - - it("folds accents and keeps highlight offsets in the original draft", () => { - expect( - getSidebarDraftSearchMatch({ - query: "cafe", - text: "Café planning", - title: "Café planning", - }), - ).toEqual({ - highlightRanges: [{ start: 0, end: 4 }], - text: "Café planning", - }); - }); -}); - -describe("sidebar search match window", () => { - it("keeps the plain text when the two-line clamp already reveals the match", () => { - const text = "A short sentence with the needle visible."; - expect( - getSidebarThreadSearchMatchWindow({ - highlightRanges: [{ start: 26, end: 32 }], - matchIsHidden: false, - text, - }), - ).toEqual({ - highlightRanges: [{ start: 26, end: 32 }], - text, - wasWindowed: false, - }); - }); - - it("reveals a late 240px-row match with bounded context and rebased ranges", () => { - const text = - "This deliberately long preface fills more than two lines in a 240px sidebar result before the visible needle and then continues with enough following words to require a trailing cut."; - const matchStart = text.indexOf("needle"); - const result = getSidebarThreadSearchMatchWindow({ - highlightRanges: [{ start: matchStart, end: matchStart + 6 }], - matchIsHidden: true, - text, - }); - - expect(result.wasWindowed).toBe(true); - expect(result.text.startsWith("…")).toBe(true); - expect(result.text.endsWith("…")).toBe(true); - expect(result.text.indexOf("needle")).toBeLessThanOrEqual(17); - expect( - result.text.length - result.text.indexOf("needle") - 6, - ).toBeLessThanOrEqual(41); - expect( - result.text.slice( - result.highlightRanges[0]?.start, - result.highlightRanges[0]?.end, - ), - ).toBe("needle"); - }); - - it("never clips or rebases through a surrogate pair", () => { - const text = `${"word ".repeat(8)}😀😀😀😀 target ${"after ".repeat(12)}`; - const matchStart = text.indexOf("target"); - const result = getSidebarThreadSearchMatchWindow({ - // Deliberately malformed input boundaries inside emoji pairs exercise - // the defensive normalization used for server-provided UTF-16 offsets. - highlightRanges: [ - { start: text.indexOf("😀") + 1, end: text.indexOf("😀") + 3 }, - { start: matchStart, end: matchStart + 6 }, - ], - matchIsHidden: true, - text, - }); - - expect(result.text).not.toContain("�"); - for (const range of result.highlightRanges) { - const highlighted = result.text.slice(range.start, range.end); - expect(highlighted).not.toBe("\ud83d"); - expect(highlighted).not.toBe("\ude00"); - } - }); -}); - -describe("sidebar search lifecycle filter", () => { - it("keeps at least one state selected and resets to all-on", () => { - let controller: SidebarThreadSearchLifecycleFilterController | null = null; - - function Harness() { - const next = useSidebarThreadSearchLifecycleFilter(); - useEffect(() => { - controller = next; - }); - return null; - } - - render(); - const current = () => { - if (controller === null) throw new Error("controller not ready"); - return controller; - }; - - act(() => current().onStateCheckedChange("active", false)); - act(() => current().onStateCheckedChange("drafts", false)); - expect(current().selectedStates).toEqual(["archived"]); - - act(() => current().onStateCheckedChange("archived", false)); - expect(current().selectedStates).toEqual(["archived"]); - - act(() => current().reset()); - expect(current().selectedStates).toEqual(["active", "drafts", "archived"]); - expect(current().isFiltered).toBe(false); - }); -}); diff --git a/apps/app/src/components/sidebar/sidebarThreadSearch.ts b/apps/app/src/components/sidebar/sidebarThreadSearch.ts index 1b68980ada..70274e52d9 100644 --- a/apps/app/src/components/sidebar/sidebarThreadSearch.ts +++ b/apps/app/src/components/sidebar/sidebarThreadSearch.ts @@ -1,16 +1,12 @@ -import { useCallback, useMemo, useState, type RefObject } from "react"; +import type { RefObject } from "react"; import type { ThreadSearchMatch } from "@bb/server-contract"; export const SIDEBAR_THREAD_SEARCH_LISTBOX_ID = "bb-sidebar-thread-search-results"; -interface SidebarThreadSearchNavigationItemBase { +export interface SidebarThreadSearchNavigationItem { id: string; optionId: string; -} - -export interface SidebarThreadSearchThreadNavigationItem extends SidebarThreadSearchNavigationItemBase { - kind: "thread"; projectId: string; threadId: string; /** @@ -21,410 +17,6 @@ export interface SidebarThreadSearchThreadNavigationItem extends SidebarThreadSe messageSeq: number | null; } -export interface SidebarThreadSearchDraftNavigationItem extends SidebarThreadSearchNavigationItemBase { - kind: "draft"; - draftSlotId: string; -} - -export type SidebarThreadSearchNavigationItem = - | SidebarThreadSearchThreadNavigationItem - | SidebarThreadSearchDraftNavigationItem; - -export const SIDEBAR_THREAD_SEARCH_LIFECYCLE_STATES = [ - "active", - "drafts", - "archived", -] as const; - -export type SidebarThreadSearchLifecycleState = - (typeof SIDEBAR_THREAD_SEARCH_LIFECYCLE_STATES)[number]; - -export type SidebarThreadSearchLifecycleCounts = Record< - SidebarThreadSearchLifecycleState, - number ->; - -export interface SidebarThreadSearchLifecycleFilterController { - counts: SidebarThreadSearchLifecycleCounts; - isFiltered: boolean; - onCountsChange: (counts: SidebarThreadSearchLifecycleCounts) => void; - onStateCheckedChange: ( - state: SidebarThreadSearchLifecycleState, - checked: boolean, - ) => void; - reset: () => void; - selectedStates: readonly SidebarThreadSearchLifecycleState[]; -} - -const EMPTY_LIFECYCLE_COUNTS: SidebarThreadSearchLifecycleCounts = { - active: 0, - drafts: 0, - archived: 0, -}; - -function haveSameLifecycleCounts( - left: SidebarThreadSearchLifecycleCounts, - right: SidebarThreadSearchLifecycleCounts, -): boolean { - return SIDEBAR_THREAD_SEARCH_LIFECYCLE_STATES.every( - (state) => left[state] === right[state], - ); -} - -/** - * Transient lifecycle state for built-in sidebar search. The owner mounts one - * controller while search is available; the result panel resets it on - * dismissal so a fresh search always starts with every group visible. - */ -export function useSidebarThreadSearchLifecycleFilter(): SidebarThreadSearchLifecycleFilterController { - const [selectedStates, setSelectedStates] = useState< - readonly SidebarThreadSearchLifecycleState[] - >(SIDEBAR_THREAD_SEARCH_LIFECYCLE_STATES); - const [counts, setCounts] = useState( - EMPTY_LIFECYCLE_COUNTS, - ); - const selectedStateSet = useMemo( - () => new Set(selectedStates), - [selectedStates], - ); - const onCountsChange = useCallback( - (nextCounts: SidebarThreadSearchLifecycleCounts) => { - setCounts((current) => - haveSameLifecycleCounts(current, nextCounts) ? current : nextCounts, - ); - }, - [], - ); - const onStateCheckedChange = useCallback( - (state: SidebarThreadSearchLifecycleState, checked: boolean) => { - setSelectedStates((current) => { - const isSelected = current.includes(state); - if (checked === isSelected) return current; - if (!checked && current.length === 1) return current; - return SIDEBAR_THREAD_SEARCH_LIFECYCLE_STATES.filter((candidate) => - candidate === state ? checked : current.includes(candidate), - ); - }); - }, - [], - ); - const reset = useCallback(() => { - setSelectedStates(SIDEBAR_THREAD_SEARCH_LIFECYCLE_STATES); - setCounts(EMPTY_LIFECYCLE_COUNTS); - }, []); - - return { - counts, - isFiltered: - selectedStateSet.size < SIDEBAR_THREAD_SEARCH_LIFECYCLE_STATES.length, - onCountsChange, - onStateCheckedChange, - reset, - selectedStates, - }; -} - -export interface SidebarThreadSearchMatchWindow { - highlightRanges: ThreadSearchMatch["highlightRanges"]; - text: string; - wasWindowed: boolean; -} - -export interface SidebarDraftSearchMatch { - highlightRanges: ThreadSearchMatch["highlightRanges"]; - text: string; -} - -const SIDEBAR_SEARCH_TOKEN_PATTERN = /[\p{L}\p{N}_]+/gu; -const SIDEBAR_SEARCH_HIGHLIGHT_RANGE_LIMIT = 8; - -function normalizeSidebarSearchText(value: string): string { - return value - .normalize("NFD") - .replace(/\p{Mark}/gu, "") - .toLocaleLowerCase(); -} - -function listSidebarSearchTokens(value: string): string[] { - return [...value.matchAll(SIDEBAR_SEARCH_TOKEN_PATTERN)] - .map((match) => normalizeSidebarSearchText(match[0])) - .filter((token) => token.length > 0); -} - -function getNormalizedPrefixOriginalEnd( - value: string, - normalizedPrefixLength: number, -): number { - let normalizedLength = 0; - for (let index = 0; index < value.length; ) { - const codePoint = value.codePointAt(index); - if (codePoint === undefined) break; - const originalValue = String.fromCodePoint(codePoint); - const end = index + originalValue.length; - normalizedLength += normalizeSidebarSearchText(originalValue).length; - if (normalizedLength >= normalizedPrefixLength) return end; - index = end; - } - return value.length; -} - -function getSidebarSearchCandidateMatch( - value: string, - queryTokens: readonly string[], -): SidebarDraftSearchMatch | null { - const rangesByToken = new Map(); - for (const token of queryTokens) rangesByToken.set(token, []); - - for (const match of value.matchAll(SIDEBAR_SEARCH_TOKEN_PATTERN)) { - const originalToken = match[0]; - const normalizedToken = normalizeSidebarSearchText(originalToken); - for (const queryToken of queryTokens) { - if (!normalizedToken.startsWith(queryToken)) continue; - const start = match.index; - rangesByToken.get(queryToken)?.push({ - start, - end: - start + - getNormalizedPrefixOriginalEnd(originalToken, queryToken.length), - }); - } - } - - if ([...rangesByToken.values()].some((ranges) => ranges.length === 0)) { - return null; - } - - const ranges = [...rangesByToken.values()] - .flat() - .sort((left, right) => left.start - right.start || left.end - right.end); - const mergedRanges: ThreadSearchMatch["highlightRanges"] = []; - for (const range of ranges) { - const previous = mergedRanges.at(-1); - if (previous === undefined || range.start > previous.end) { - mergedRanges.push({ ...range }); - continue; - } - previous.end = Math.max(previous.end, range.end); - } - - return { - highlightRanges: mergedRanges.slice( - 0, - SIDEBAR_SEARCH_HIGHLIGHT_RANGE_LIMIT, - ), - text: value, - }; -} - -/** Client-side draft matching mirrors server thread-search token semantics. */ -export function getSidebarDraftSearchMatch({ - query, - text, - title, -}: { - query: string; - text: string; - title: string; -}): SidebarDraftSearchMatch | null { - const queryTokens = [...new Set(listSidebarSearchTokens(query))]; - if (queryTokens.length === 0) return null; - return ( - getSidebarSearchCandidateMatch(text, queryTokens) ?? - getSidebarSearchCandidateMatch(title, queryTokens) - ); -} - -function isHighSurrogate(value: number): boolean { - return value >= 0xd800 && value <= 0xdbff; -} - -function isLowSurrogate(value: number): boolean { - return value >= 0xdc00 && value <= 0xdfff; -} - -function clampTextOffset(value: number, textLength: number): number { - return Math.max(0, Math.min(value, textLength)); -} - -function moveOffsetOffSurrogateSplit( - text: string, - value: number, - direction: "backward" | "forward", -): number { - const offset = clampTextOffset(value, text.length); - if ( - offset > 0 && - offset < text.length && - isHighSurrogate(text.charCodeAt(offset - 1)) && - isLowSurrogate(text.charCodeAt(offset)) - ) { - return direction === "backward" ? offset - 1 : offset + 1; - } - return offset; -} - -function moveByCodePoints(text: string, from: number, count: number): number { - let offset = moveOffsetOffSurrogateSplit( - text, - from, - count < 0 ? "backward" : "forward", - ); - const step = count < 0 ? -1 : 1; - for (let moved = 0; moved < Math.abs(count); moved += 1) { - if (step < 0) { - if (offset === 0) break; - offset -= 1; - if ( - offset > 0 && - isLowSurrogate(text.charCodeAt(offset)) && - isHighSurrogate(text.charCodeAt(offset - 1)) - ) { - offset -= 1; - } - } else { - if (offset === text.length) break; - if ( - isHighSurrogate(text.charCodeAt(offset)) && - offset + 1 < text.length && - isLowSurrogate(text.charCodeAt(offset + 1)) - ) { - offset += 2; - } else { - offset += 1; - } - } - } - return offset; -} - -function isWordCharacter(value: string): boolean { - return /[\p{L}\p{N}_]/u.test(value); -} - -function readCodePointBefore(text: string, offset: number): string { - if (offset <= 0) return ""; - const previous = moveByCodePoints(text, offset, -1); - return text.slice(previous, offset); -} - -function readCodePointAt(text: string, offset: number): string { - if (offset >= text.length) return ""; - const next = moveByCodePoints(text, offset, 1); - return text.slice(offset, next); -} - -function isWordStart(text: string, offset: number): boolean { - const current = readCodePointAt(text, offset); - return ( - current.length > 0 && - isWordCharacter(current) && - !isWordCharacter(readCodePointBefore(text, offset)) - ); -} - -function isWordEnd(text: string, offset: number): boolean { - const previous = readCodePointBefore(text, offset); - return ( - previous.length > 0 && - isWordCharacter(previous) && - !isWordCharacter(readCodePointAt(text, offset)) - ); -} - -function findWindowStart( - text: string, - candidate: number, - matchStart: number, -): number { - for ( - let offset = candidate; - offset < matchStart; - offset = moveByCodePoints(text, offset, 1) - ) { - if (isWordStart(text, offset)) return offset; - } - return matchStart; -} - -function findWindowEnd( - text: string, - matchEnd: number, - candidate: number, -): number { - for ( - let offset = candidate; - offset > matchEnd; - offset = moveByCodePoints(text, offset, -1) - ) { - if (isWordEnd(text, offset)) return offset; - } - return matchEnd; -} - -function normalizeHighlightRange( - text: string, - range: ThreadSearchMatch["highlightRanges"][number], -): ThreadSearchMatch["highlightRanges"][number] | null { - const start = moveOffsetOffSurrogateSplit(text, range.start, "backward"); - const end = moveOffsetOffSurrogateSplit(text, range.end, "forward"); - return end > start ? { start, end } : null; -} - -/** - * Clips around the first match only after layout proves the ordinary two-line - * clamp would hide it. Offsets remain UTF-16 indices (matching server ranges), - * while the 16/40 context limits count Unicode code points and every boundary - * is moved away from the middle of a surrogate pair. - */ -export function getSidebarThreadSearchMatchWindow({ - highlightRanges, - matchIsHidden, - text, -}: { - highlightRanges: ThreadSearchMatch["highlightRanges"]; - matchIsHidden: boolean; - text: string; -}): SidebarThreadSearchMatchWindow { - const normalizedRanges = highlightRanges - .map((range) => normalizeHighlightRange(text, range)) - .filter((range): range is NonNullable => range !== null) - .sort((left, right) => left.start - right.start || left.end - right.end); - const firstMatch = normalizedRanges[0]; - if (!matchIsHidden || firstMatch === undefined) { - return { - highlightRanges: normalizedRanges, - text, - wasWindowed: false, - }; - } - - const candidateStart = moveByCodePoints(text, firstMatch.start, -16); - const candidateEnd = moveByCodePoints(text, firstMatch.end, 40); - const sliceStart = - candidateStart === 0 - ? 0 - : findWindowStart(text, candidateStart, firstMatch.start); - const sliceEnd = - candidateEnd === text.length - ? text.length - : findWindowEnd(text, firstMatch.end, candidateEnd); - const hasLeadingEllipsis = sliceStart > 0; - const hasTrailingEllipsis = sliceEnd < text.length; - const rangeOffset = sliceStart - (hasLeadingEllipsis ? 1 : 0); - - return { - highlightRanges: normalizedRanges.flatMap((range) => { - const start = Math.max(range.start, sliceStart); - const end = Math.min(range.end, sliceEnd); - return end > start - ? [{ start: start - rangeOffset, end: end - rangeOffset }] - : []; - }), - text: `${hasLeadingEllipsis ? "…" : ""}${text.slice(sliceStart, sliceEnd)}${hasTrailingEllipsis ? "…" : ""}`, - wasWindowed: true, - }; -} - export interface SidebarThreadSearchInputController { activeDescendantId: string | undefined; inputRef: RefObject; @@ -482,13 +74,6 @@ export function haveSameSidebarThreadSearchNavigationItems( (item, index) => item.id === right[index]?.id && item.optionId === right[index]?.optionId && - item.kind === right[index]?.kind && - (item.kind === "thread" - ? right[index]?.kind === "thread" && - item.projectId === right[index].projectId && - item.threadId === right[index].threadId && - item.messageSeq === right[index].messageSeq - : right[index]?.kind === "draft" && - item.draftSlotId === right[index].draftSlotId), + 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 index b36077d6d5..47d814e95f 100644 --- a/apps/app/src/components/sidebar/useSidebarThreadSearch.test.tsx +++ b/apps/app/src/components/sidebar/useSidebarThreadSearch.test.tsx @@ -22,7 +22,6 @@ function createNavigationItem( threadId: string, ): SidebarThreadSearchNavigationItem { return { - kind: "thread", id: `active:${threadId}`, optionId: getSidebarThreadSearchOptionId(`active:${threadId}`), projectId: "proj_search", From 01fd0f09d02e9554e21cb4a52ae7d9ed0ee4f93c Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Tue, 25 Aug 2026 11:47:15 -0700 Subject: [PATCH 08/11] Refresh draft rows after legacy migration --- .../components/AppLocalStateInitialization.test.tsx | 10 +++++++++- .../app/src/components/AppLocalStateInitialization.tsx | 2 ++ apps/app/src/hooks/usePromptDraftStorage.ts | 5 +++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/app/src/components/AppLocalStateInitialization.test.tsx b/apps/app/src/components/AppLocalStateInitialization.test.tsx index a787b37eb3..0aa299cf3c 100644 --- a/apps/app/src/components/AppLocalStateInitialization.test.tsx +++ b/apps/app/src/components/AppLocalStateInitialization.test.tsx @@ -1,14 +1,20 @@ // @vitest-environment jsdom -import { cleanup, render } from "@testing-library/react"; +import { cleanup, render, screen } from "@testing-library/react"; import { StrictMode } from "react"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { promptDraftSlotStorageKeysForTests, readNewThreadDraftSlots, } from "@/lib/prompt-draft-slots"; +import { useNewThreadDraftSlots } from "@/hooks/useNewThreadDraftSlots"; import { AppLocalStateInitialization } from "./AppLocalStateInitialization"; +function DraftRows() { + const drafts = useNewThreadDraftSlots(); + return {drafts.map((draft) => draft.title).join(", ")}; +} + beforeEach(() => { window.localStorage.clear(); }); @@ -32,6 +38,7 @@ describe("AppLocalStateInitialization", () => { render( + , ); @@ -51,5 +58,6 @@ describe("AppLocalStateInitialization", () => { expect( window.localStorage.getItem(promptDraftSlotStorageKeysForTests.legacy), ).toBeNull(); + expect(screen.getByText("Never lose this draft")).not.toBeNull(); }); }); diff --git a/apps/app/src/components/AppLocalStateInitialization.tsx b/apps/app/src/components/AppLocalStateInitialization.tsx index 5c1492ba58..b548dd765f 100644 --- a/apps/app/src/components/AppLocalStateInitialization.tsx +++ b/apps/app/src/components/AppLocalStateInitialization.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef } from "react"; +import { refreshNewThreadDraftSlots } from "@/hooks/usePromptDraftStorage"; import { initializeNewThreadDraftSlots } from "@/lib/prompt-draft-slots"; import { readRootComposeProjectId } from "@/lib/root-compose-selection"; @@ -13,6 +14,7 @@ export function AppLocalStateInitialization() { if (didInitializeDraftSlots.current) return; didInitializeDraftSlots.current = true; initializeNewThreadDraftSlots(readRootComposeProjectId()); + refreshNewThreadDraftSlots(); }, []); return null; diff --git a/apps/app/src/hooks/usePromptDraftStorage.ts b/apps/app/src/hooks/usePromptDraftStorage.ts index fdb80f436a..09f77f7b32 100644 --- a/apps/app/src/hooks/usePromptDraftStorage.ts +++ b/apps/app/src/hooks/usePromptDraftStorage.ts @@ -159,6 +159,11 @@ function emitNewThreadDraftSlotsChange(): void { } } +/** Refreshes synthesized draft rows after same-window storage migrations. */ +export function refreshNewThreadDraftSlots(): void { + emitNewThreadDraftSlotsChange(); +} + function clearPromptDraftPersistTimer(storageKey: string): void { const timerId = promptDraftPersistTimers.get(storageKey); if (timerId === undefined || typeof window === "undefined") return; From b971bd7959174235b6498c41861d4e39da9af4e9 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Tue, 25 Aug 2026 11:50:52 -0700 Subject: [PATCH 09/11] Cover lifecycle composition acceptance --- .../sidebar/ProjectList.modes.test.tsx | 35 +- .../src/components/sidebar/ProjectList.tsx | 344 ++++++++++-------- .../sidebar/sidebarThreadLifecycle.test.ts | 74 +++- .../sidebar/sidebarThreadLifecycle.ts | 59 ++- .../hooks/useNewThreadDraftLeaveToast.test.ts | 45 ++- 5 files changed, 383 insertions(+), 174 deletions(-) diff --git a/apps/app/src/components/sidebar/ProjectList.modes.test.tsx b/apps/app/src/components/sidebar/ProjectList.modes.test.tsx index e034107b7d..17edcd1c21 100644 --- a/apps/app/src/components/sidebar/ProjectList.modes.test.tsx +++ b/apps/app/src/components/sidebar/ProjectList.modes.test.tsx @@ -17,7 +17,11 @@ import { } from "jotai"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { ThreadListEntry } from "@bb/domain"; -import { ActiveSidebarModeSections, MachineModeSections } from "./ProjectList"; +import { + ActiveSidebarModeSections, + BuiltInSidebarLifecycleSections, + MachineModeSections, +} from "./ProjectList"; import { buildMachineThreadGroups } from "@bb/client-core"; import { collapsedSidebarSectionIdsAtom, @@ -195,6 +199,35 @@ afterEach(() => { }); describe("sidebar organization mode sections", () => { + it.each(["project", "chronological", "machine"])( + "keeps drafts above %s sections and archived rows trailing", + (mode) => { + const { container } = render( + Drafts
} + activeModeSections={ +
Chronological
} + renderMachine={() =>
Machine
} + renderProject={() =>
Project
} + /> + } + archivedRows={
Archived
} + emptyState={null} + />, + ); + + const activeLabel = + mode === "chronological" + ? "Chronological" + : mode === "machine" + ? "Machine" + : "Project"; + expect(container.textContent).toBe(`Drafts${activeLabel}Archived`); + }, + ); + it("does not mount inactive ordering or machine-grouping work", async () => { const store = createStore(); store.set(sidebarSectionOrderAtom, ["threads", "project:a", "pinned"]); diff --git a/apps/app/src/components/sidebar/ProjectList.tsx b/apps/app/src/components/sidebar/ProjectList.tsx index 22f02466ac..4aab103b0e 100644 --- a/apps/app/src/components/sidebar/ProjectList.tsx +++ b/apps/app/src/components/sidebar/ProjectList.tsx @@ -182,6 +182,7 @@ import { import { isDefaultSidebarThreadLifecycleSelection, builtInSidebarDraftRowsVisibleAtom, + getBuiltInSidebarLifecycleRenderState, sidebarThreadLifecycleSelectionAtom, SIDEBAR_THREAD_LIFECYCLE_STATES, toggleSidebarThreadLifecycleState, @@ -1126,6 +1127,29 @@ interface ActiveSidebarModeSectionsProps { renderProject: () => ReactNode; } +interface BuiltInSidebarLifecycleSectionsProps { + activeModeSections: ReactNode; + archivedRows: ReactNode; + draftRows: ReactNode; + emptyState: ReactNode; +} + +export function BuiltInSidebarLifecycleSections({ + activeModeSections, + archivedRows, + draftRows, + emptyState, +}: BuiltInSidebarLifecycleSectionsProps) { + return ( + <> + {draftRows} + {activeModeSections} + {archivedRows} + {emptyState} + + ); +} + export function ActiveSidebarModeSections({ mode, renderChronological, @@ -1685,7 +1709,6 @@ function ProjectListComponent({ const setBuiltInDraftRowsVisible = useSetAtom( builtInSidebarDraftRowsVisibleAtom, ); - const showActiveThreads = lifecycleSelection.has("active"); const showDrafts = lifecycleSelection.has("drafts"); const showArchivedThreads = lifecycleSelection.has("archived"); const archivedThreadsQuery = useArchivedThreads( @@ -2187,30 +2210,20 @@ function ProjectListComponent({ ) : null} ); - const lifecycleSelectionIsDefault = - isDefaultSidebarThreadLifecycleSelection(lifecycleSelection); - const archivedRowsAreLoading = - showArchivedThreads && archivedThreadsQuery.isPending; - const hasLifecycleMatches = - (showActiveThreads && threads.length > 0) || - (showDrafts && newThreadDrafts.length > 0) || - (showArchivedThreads && archivedThreads.length > 0); - const showFilteredEmptyState = - !lifecycleSelectionIsDefault && - projectsState.status === "ready" && - !archivedRowsAreLoading && - !hasLifecycleMatches; - const showActiveModeSections = - showActiveThreads && - (lifecycleSelectionIsDefault || - threads.length > 0 || - projectsState.status !== "ready"); - const showLifecycleControlOnlySection = - !showActiveModeSections && - archivedThreads.length === 0 && - !archivedThreadsQuery.hasNextPage; - const showArchivedOnlyControl = - lifecycleSelection.size === 1 && showArchivedThreads; + const { + showActiveModeSections, + showArchivedOnlyControl, + showFilteredEmptyState, + showLifecycleControlOnlySection, + } = getBuiltInSidebarLifecycleRenderState({ + activeCount: threads.length, + archivedCount: archivedThreads.length, + archivedHasNextPage: archivedThreadsQuery.hasNextPage, + archivedIsPending: archivedThreadsQuery.isPending, + draftCount: newThreadDrafts.length, + isReady: projectsState.status === "ready", + selection: lifecycleSelection, + }); if (threadSearch?.isActive) { return ( @@ -2241,150 +2254,163 @@ function ProjectListComponent({ return ( - {showDrafts ? ( - - ) : null} - {showActiveModeSections ? ( - ( - - )} - renderChronological={() => ( - { - const sectionId = buildSidebarEntitySectionId( - "section", - section.id, - ); - return { - actions: renderSectionDisplayOptions(sectionId), - actionsOpen: isSectionDisplayOptionsOpen(sectionId), - }; + ) : null + } + activeModeSections={ + showActiveModeSections ? ( + ( + + )} + renderChronological={() => ( + { + const sectionId = buildSidebarEntitySectionId( + "section", + section.id, + ); + return { + actions: renderSectionDisplayOptions(sectionId), + actionsOpen: isSectionDisplayOptionsOpen(sectionId), + }; + }} + onToggleCollapsed={toggleSidebarSectionCollapsed} + onToggleThreadCollapsed={toggleThreadCollapsed} + onToggleEnvironmentCollapsed={toggleEnvironmentCollapsed} + /> + )} + renderProject={() => ( + + )} + /> + ) : null + } + archivedRows={ + showArchivedThreads ? ( + { + void archivedThreadsQuery.fetchNextPage(); }} - onToggleCollapsed={toggleSidebarSectionCollapsed} - onToggleThreadCollapsed={toggleThreadCollapsed} - onToggleEnvironmentCollapsed={toggleEnvironmentCollapsed} + onNavigate={onProjectSelect} /> - )} - renderProject={() => ( - - )} - /> - ) : null} - {showArchivedThreads ? ( - { - void archivedThreadsQuery.fetchNextPage(); - }} - onNavigate={onProjectSelect} - /> - ) : null} - {showLifecycleControlOnlySection ? ( - - {showFilteredEmptyState ? ( + actionsOpen={ + showArchivedOnlyControl + ? openSidebarMenu === "displayOptions:archived" + : threadsDisplayOptionsMenuOpen + } + > + {showFilteredEmptyState ? ( +

+ No threads match this filter. +

+ ) : null} +
+ ) : showFilteredEmptyState ? (

No threads match this filter.

- ) : null} - - ) : showFilteredEmptyState ? ( -

- No threads match this filter. -

- ) : null} + ) : null + } + /> {sectionCreateDialog} {sectionRenameDialogContent} {sectionDeleteDialogContent} diff --git a/apps/app/src/components/sidebar/sidebarThreadLifecycle.test.ts b/apps/app/src/components/sidebar/sidebarThreadLifecycle.test.ts index 5631282075..f342c76ec5 100644 --- a/apps/app/src/components/sidebar/sidebarThreadLifecycle.test.ts +++ b/apps/app/src/components/sidebar/sidebarThreadLifecycle.test.ts @@ -1,24 +1,21 @@ import { describe, expect, it } from "vitest"; import { DEFAULT_SIDEBAR_THREAD_LIFECYCLE_SELECTION, + getBuiltInSidebarLifecycleRenderState, isDefaultSidebarThreadLifecycleSelection, toggleSidebarThreadLifecycleState, } from "./sidebarThreadLifecycle"; describe("sidebar thread lifecycle selection", () => { it("starts active-only and recognizes only that selection as the default", () => { - expect([...DEFAULT_SIDEBAR_THREAD_LIFECYCLE_SELECTION]).toEqual([ - "active", - ]); + expect([...DEFAULT_SIDEBAR_THREAD_LIFECYCLE_SELECTION]).toEqual(["active"]); expect( isDefaultSidebarThreadLifecycleSelection( DEFAULT_SIDEBAR_THREAD_LIFECYCLE_SELECTION, ), ).toBe(true); expect( - isDefaultSidebarThreadLifecycleSelection( - new Set(["active", "drafts"]), - ), + isDefaultSidebarThreadLifecycleSelection(new Set(["active", "drafts"])), ).toBe(false); }); @@ -38,4 +35,69 @@ describe("sidebar thread lifecycle selection", () => { draftsOnly, ); }); + + it("derives union visibility, loading, and filtered-empty states", () => { + expect( + getBuiltInSidebarLifecycleRenderState({ + activeCount: 0, + archivedCount: 0, + archivedHasNextPage: false, + archivedIsPending: false, + draftCount: 0, + isReady: true, + selection: DEFAULT_SIDEBAR_THREAD_LIFECYCLE_SELECTION, + }), + ).toEqual({ + showActiveModeSections: true, + showArchivedOnlyControl: false, + showFilteredEmptyState: false, + showLifecycleControlOnlySection: false, + }); + + expect( + getBuiltInSidebarLifecycleRenderState({ + activeCount: 0, + archivedCount: 0, + archivedHasNextPage: false, + archivedIsPending: false, + draftCount: 1, + isReady: true, + selection: new Set(["active", "drafts"]), + }), + ).toEqual({ + showActiveModeSections: false, + showArchivedOnlyControl: false, + showFilteredEmptyState: false, + showLifecycleControlOnlySection: true, + }); + + expect( + getBuiltInSidebarLifecycleRenderState({ + activeCount: 0, + archivedCount: 0, + archivedHasNextPage: false, + archivedIsPending: true, + draftCount: 0, + isReady: true, + selection: new Set(["archived"]), + }), + ).toEqual({ + showActiveModeSections: false, + showArchivedOnlyControl: true, + showFilteredEmptyState: false, + showLifecycleControlOnlySection: true, + }); + + expect( + getBuiltInSidebarLifecycleRenderState({ + activeCount: 0, + archivedCount: 0, + archivedHasNextPage: false, + archivedIsPending: false, + draftCount: 0, + isReady: true, + selection: new Set(["archived"]), + }).showFilteredEmptyState, + ).toBe(true); + }); }); diff --git a/apps/app/src/components/sidebar/sidebarThreadLifecycle.ts b/apps/app/src/components/sidebar/sidebarThreadLifecycle.ts index eecd05efec..3534b22369 100644 --- a/apps/app/src/components/sidebar/sidebarThreadLifecycle.ts +++ b/apps/app/src/components/sidebar/sidebarThreadLifecycle.ts @@ -9,13 +9,11 @@ export const SIDEBAR_THREAD_LIFECYCLE_STATES = [ export type SidebarThreadLifecycleState = (typeof SIDEBAR_THREAD_LIFECYCLE_STATES)[number]; -export type SidebarThreadLifecycleSelection = ReadonlySet< - SidebarThreadLifecycleState ->; +export type SidebarThreadLifecycleSelection = + ReadonlySet; -export const DEFAULT_SIDEBAR_THREAD_LIFECYCLE_SELECTION = new Set< - SidebarThreadLifecycleState ->(["active"]); +export const DEFAULT_SIDEBAR_THREAD_LIFECYCLE_SELECTION = + new Set(["active"]); /** * Session-only by design. A fresh app launch always starts from the safe @@ -55,3 +53,52 @@ export function isDefaultSidebarThreadLifecycleSelection( ): boolean { return selection.size === 1 && selection.has("active"); } + +export interface BuiltInSidebarLifecycleRenderState { + showActiveModeSections: boolean; + showArchivedOnlyControl: boolean; + showFilteredEmptyState: boolean; + showLifecycleControlOnlySection: boolean; +} + +export function getBuiltInSidebarLifecycleRenderState({ + activeCount, + archivedCount, + archivedHasNextPage, + archivedIsPending, + draftCount, + isReady, + selection, +}: { + activeCount: number; + archivedCount: number; + archivedHasNextPage: boolean; + archivedIsPending: boolean; + draftCount: number; + isReady: boolean; + selection: SidebarThreadLifecycleSelection; +}): BuiltInSidebarLifecycleRenderState { + const selectionIsDefault = + isDefaultSidebarThreadLifecycleSelection(selection); + const showActive = selection.has("active"); + const showDrafts = selection.has("drafts"); + const showArchived = selection.has("archived"); + const hasMatches = + (showActive && activeCount > 0) || + (showDrafts && draftCount > 0) || + (showArchived && archivedCount > 0); + const showActiveModeSections = + showActive && (selectionIsDefault || activeCount > 0 || !isReady); + + return { + showActiveModeSections, + showArchivedOnlyControl: selection.size === 1 && showArchived, + showFilteredEmptyState: + !selectionIsDefault && + isReady && + !(showArchived && archivedIsPending) && + !hasMatches, + showLifecycleControlOnlySection: + !showActiveModeSections && archivedCount === 0 && !archivedHasNextPage, + }; +} diff --git a/apps/app/src/hooks/useNewThreadDraftLeaveToast.test.ts b/apps/app/src/hooks/useNewThreadDraftLeaveToast.test.ts index cb140ef10b..b1ef791783 100644 --- a/apps/app/src/hooks/useNewThreadDraftLeaveToast.test.ts +++ b/apps/app/src/hooks/useNewThreadDraftLeaveToast.test.ts @@ -1,6 +1,19 @@ -import { describe, expect, it } from "vitest"; +// @vitest-environment jsdom + +import { act, renderHook } from "@testing-library/react"; +import { createElement, StrictMode, type PropsWithChildren } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { PromptDraftState } from "@bb/client-core"; -import { shouldAnnounceNewThreadDraftLeave } from "./useNewThreadDraftLeaveToast"; +import { + shouldAnnounceNewThreadDraftLeave, + useNewThreadDraftLeaveToast, +} from "./useNewThreadDraftLeaveToast"; + +const mockToastMessage = vi.hoisted(() => vi.fn()); + +vi.mock("@/components/ui/app-toast", () => ({ + appToast: { message: mockToastMessage }, +})); const EMPTY_DRAFT: PromptDraftState = { attachments: [], @@ -8,7 +21,35 @@ const EMPTY_DRAFT: PromptDraftState = { text: "", }; +function StrictModeWrapper({ children }: PropsWithChildren) { + return createElement(StrictMode, null, children); +} + +afterEach(() => { + vi.clearAllMocks(); +}); + describe("new-thread draft leave toast", () => { + it("announces exactly once on real unmount with no action payload", async () => { + const draft = { ...EMPTY_DRAFT, text: "Keep this work" }; + const hook = renderHook( + () => + useNewThreadDraftLeaveToast({ + getCurrentDraft: () => draft, + isSplitPane: false, + }), + { wrapper: StrictModeWrapper }, + ); + + await act(async () => { + hook.unmount(); + await Promise.resolve(); + }); + + expect(mockToastMessage).toHaveBeenCalledTimes(1); + expect(mockToastMessage).toHaveBeenCalledWith("Saved to Drafts"); + }); + it("announces a page-composer draft only when its built-in row is hidden", () => { const draft = { ...EMPTY_DRAFT, text: "Keep this work" }; expect( From fe146726b0d884144e25f7b0b3eae47149d56df6 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Thu, 27 Aug 2026 00:43:32 -0700 Subject: [PATCH 10/11] Align sidebar lifecycle rows with approved states --- .../src/components/sidebar/ProjectList.tsx | 65 +------------ .../SidebarDisplayOptionsMenu.test.tsx | 93 +++++-------------- .../sidebar/SidebarLifecycleRows.test.tsx | 28 +++++- .../sidebar/SidebarLifecycleRows.tsx | 33 ++++--- .../components/thread/ThreadActionsMenu.tsx | 16 +++- apps/app/src/hooks/useNewThreadDraftSlots.ts | 4 +- 6 files changed, 80 insertions(+), 159 deletions(-) diff --git a/apps/app/src/components/sidebar/ProjectList.tsx b/apps/app/src/components/sidebar/ProjectList.tsx index e2f8ab38c6..20a594f336 100644 --- a/apps/app/src/components/sidebar/ProjectList.tsx +++ b/apps/app/src/components/sidebar/ProjectList.tsx @@ -3,7 +3,6 @@ import { useCallback, useEffect, useMemo, - useRef, useState, type MouseEventHandler, type PointerEventHandler, @@ -28,10 +27,7 @@ import { import { isTransientReadError } from "@/hooks/queries/query-helpers"; import { stripProjectThreads } from "@/hooks/queries/project-queries"; import { useSidebarNavigation } from "@/hooks/queries/sidebar-navigation-query"; -import { - useArchivedThreadCount, - useArchivedThreads, -} from "@/hooks/queries/thread-queries"; +import { useArchivedThreads } from "@/hooks/queries/thread-queries"; import { useReorderPinnedThread } from "@/hooks/mutations/thread-state-mutations"; import { useCreateThreadSection, @@ -134,7 +130,6 @@ import { DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, - DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, @@ -220,8 +215,6 @@ interface ProjectListThreadsSectionActionsProps { } interface SidebarDisplayOptionsMenuProps { - activeCount?: number; - draftCount?: number; open?: boolean; onOpenChange?: (open: boolean) => void; } @@ -695,8 +688,6 @@ function SidebarDisplayMenuTrigger({ // Thread display options stay scoped to thread organization, sorting, and // lifecycle visibility. Top-region destinations are customized separately. export function SidebarDisplayOptionsMenu({ - activeCount = 0, - draftCount = 0, open, onOpenChange, }: SidebarDisplayOptionsMenuProps) { @@ -709,41 +700,10 @@ export function SidebarDisplayOptionsMenu({ const [lifecycleSelection, setLifecycleSelection] = useAtom( sidebarThreadLifecycleSelectionAtom, ); - const { refetch: refetchArchivedThreadCount } = useArchivedThreadCount(); - const archivedCountRequestRef = useRef(0); - const [archivedCountSnapshot, setArchivedCountSnapshot] = useState< - number | null - >(null); const selectedSort: SidebarChronologicalSort = chronologicalSort === "none" ? "updated" : chronologicalSort; const isFiltered = !isDefaultSidebarThreadLifecycleSelection(lifecycleSelection); - const handleOpenChange = useCallback( - (nextOpen: boolean) => { - onOpenChange?.(nextOpen); - archivedCountRequestRef.current += 1; - if (!nextOpen) return; - - const requestId = archivedCountRequestRef.current; - setArchivedCountSnapshot(null); - void refetchArchivedThreadCount().then((result) => { - if ( - archivedCountRequestRef.current === requestId && - result.isSuccess && - result.data !== undefined - ) { - setArchivedCountSnapshot(result.data); - } - }); - }, - [onOpenChange, refetchArchivedThreadCount], - ); - const lifecycleCounts: Record = { - active: String(activeCount), - drafts: String(draftCount), - archived: - archivedCountSnapshot === null ? "—" : String(archivedCountSnapshot), - }; const lifecycleLabels: Record = { active: "Active", drafts: "Drafts", @@ -751,7 +711,7 @@ export function SidebarDisplayOptionsMenu({ }; return ( - + - Show + Thread status - + {SIDEBAR_THREAD_LIFECYCLE_STATES.map((state) => ( - - {lifecycleLabels[state]} - - - {lifecycleCounts[state]} - + {lifecycleLabels[state]} ))} @@ -827,9 +782,7 @@ export function SidebarDisplayOptionsMenu({ } interface SidebarThreadsSectionActionsProps { - activeCount: number; displayOptionsOpen: boolean; - draftCount: number; onDisplayOptionsOpenChange: (open: boolean) => void; isCreatingSection: boolean; onNewSection?: () => void; @@ -842,9 +795,7 @@ interface SidebarThreadsSectionActionsProps { // every section state renders this same component so the label-adjacent actions // cannot drift apart. function SidebarThreadsSectionActions({ - activeCount, displayOptionsOpen, - draftCount, onDisplayOptionsOpenChange, isCreatingSection, onNewSection, @@ -855,8 +806,6 @@ function SidebarThreadsSectionActions({ return ( <> @@ -1867,8 +1816,6 @@ function ProjectListComponent({ const menuId = `displayOptions:${sectionId}` as const; return ( setSidebarMenuOpen(menuId, open)} /> @@ -2071,9 +2018,7 @@ function ProjectListComponent({ // One Threads-header cluster shared by every organization and section state. const threadsSectionActions = ( ({ - sdk: { - threads: { - count: vi.fn(), - }, - }, -})); +afterEach(cleanup); -afterEach(() => { - cleanup(); - vi.clearAllMocks(); -}); - -function renderMenu({ - activeCount = 3, - draftCount = 2, -}: { - activeCount?: number; - draftCount?: number; -} = {}) { +function renderMenu() { const store = createStore(); - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); render( - - - - - - - , + + + + + , ); return store; } @@ -61,48 +34,46 @@ function openMenu() { ); } -function getShowItem(name: string) { - return within(screen.getByRole("group", { name: "Show" })).getByRole( +function getStatusItem(name: string) { + return within(screen.getByRole("group", { name: "Thread status" })).getByRole( "menuitemcheckbox", { name: new RegExp(`^${name}`) }, ); } describe("SidebarDisplayOptionsMenu lifecycle filter", () => { - it("renders Show below Organize and Sort by with disjoint state counts", async () => { - vi.mocked(sdk.threads.count).mockResolvedValue({ count: 17 }); - renderMenu({ activeCount: 8, draftCount: 4 }); + it("renders count-free Thread status below Organize and Sort by", async () => { + renderMenu(); openMenu(); - await screen.findByRole("group", { name: "Show" }); + await screen.findByRole("group", { name: "Thread status" }); expect( screen .getAllByRole("group") .map((group) => group.getAttribute("aria-label")) .filter(Boolean), - ).toEqual(["Organize", "Sort by", "Show"]); - expect(getShowItem("Active").textContent).toContain("8"); - expect(getShowItem("Drafts").textContent).toContain("4"); - expect(getShowItem("Archived").textContent).toContain("17"); + ).toEqual(["Organize", "Sort by", "Thread status"]); + expect(getStatusItem("Active").textContent).toBe("Active"); + expect(getStatusItem("Drafts").textContent).toBe("Drafts"); + expect(getStatusItem("Archived").textContent).toBe("Archived"); expect( document.querySelector("[data-sidebar-display-filter-dot]"), ).toBeNull(); }); it("builds unions, keeps one state selected, and marks an off-default filter", async () => { - vi.mocked(sdk.threads.count).mockResolvedValue({ count: 0 }); const store = renderMenu(); openMenu(); - await screen.findByRole("group", { name: "Show" }); + await screen.findByRole("group", { name: "Thread status" }); - fireEvent.click(getShowItem("Drafts")); + fireEvent.click(getStatusItem("Drafts")); openMenu(); - fireEvent.click(getShowItem("Active")); + fireEvent.click(getStatusItem("Active")); expect([...store.get(sidebarThreadLifecycleSelectionAtom)]).toEqual([ "drafts", ]); openMenu(); - fireEvent.click(getShowItem("Drafts")); + fireEvent.click(getStatusItem("Drafts")); expect([...store.get(sidebarThreadLifecycleSelectionAtom)]).toEqual([ "drafts", ]); @@ -115,26 +86,4 @@ describe("SidebarDisplayOptionsMenu lifecycle filter", () => { document.querySelector("[data-sidebar-display-filter-dot]"), ).not.toBeNull(); }); - - it("refetches Archived on every open and freezes each open snapshot", async () => { - let resolveFirst: ((value: { count: number }) => void) | undefined; - const first = new Promise<{ count: number }>((resolve) => { - resolveFirst = resolve; - }); - vi.mocked(sdk.threads.count) - .mockReturnValueOnce(first) - .mockResolvedValueOnce({ count: 9 }); - renderMenu(); - openMenu(); - await screen.findByRole("group", { name: "Show" }); - expect(getShowItem("Archived").textContent).toContain("—"); - - resolveFirst?.({ count: 7 }); - expect(await screen.findByText("7")).toBeDefined(); - fireEvent.keyDown(document, { key: "Escape" }); - openMenu(); - expect(getShowItem("Archived").textContent).toContain("—"); - expect(await screen.findByText("9")).toBeDefined(); - expect(sdk.threads.count).toHaveBeenCalledTimes(2); - }); }); diff --git a/apps/app/src/components/sidebar/SidebarLifecycleRows.test.tsx b/apps/app/src/components/sidebar/SidebarLifecycleRows.test.tsx index f4d2e07775..dc7bbadb4e 100644 --- a/apps/app/src/components/sidebar/SidebarLifecycleRows.test.tsx +++ b/apps/app/src/components/sidebar/SidebarLifecycleRows.test.tsx @@ -131,10 +131,11 @@ describe("SidebarDraftRows", () => { "newest", "older", ]); - expect(container.querySelectorAll('[data-icon="EditFile"]')).toHaveLength( - 2, - ); - expect(container.querySelector('[data-icon="Edit"]')).toBeNull(); + expect(container.querySelector('[data-icon="EditFile"]')).toBeNull(); + expect( + container.querySelectorAll("[data-sidebar-draft-state]"), + ).toHaveLength(2); + expect(screen.getAllByText("Draft")).toHaveLength(2); fireEvent.click( screen.getByRole("button", { name: "Open draft Older draft" }), @@ -236,6 +237,10 @@ describe("SidebarArchivedThreadGroup", () => { "second", "third", ]); + expect(container.querySelector('[data-icon="Archive"]')).toBeNull(); + expect( + container.querySelectorAll("[data-sidebar-archived-state]"), + ).toHaveLength(3); const secondLink = screen.getByRole("link", { name: "Open archived thread Second archived", }); @@ -246,7 +251,7 @@ describe("SidebarArchivedThreadGroup", () => { expect(onNavigate).toHaveBeenCalledOnce(); }); - it("uses the shipped thread menus with Unarchive on overflow and right-click, without Split", () => { + it("replaces the right-edge state with quick Unarchive and keeps it in both menus", () => { const { container } = renderLifecycleRows( { />, ); + const archivedState = container.querySelector( + "[data-sidebar-archived-state]", + ); + expect(archivedState?.textContent).toBe("Archived"); + expect(archivedState?.className).toContain( + "group-focus-within/archived-thread-row:opacity-0", + ); + fireEvent.click(screen.getByRole("button", { name: "Unarchive thread" })); + expect(threadActions.unarchiveThread).toHaveBeenCalledWith( + archivedThreads[0], + ); + threadActions.unarchiveThread.mockClear(); + fireEvent.pointerDown( screen.getByRole("button", { name: "Thread actions" }), ); diff --git a/apps/app/src/components/sidebar/SidebarLifecycleRows.tsx b/apps/app/src/components/sidebar/SidebarLifecycleRows.tsx index ef55e49e5b..fc0f7f0759 100644 --- a/apps/app/src/components/sidebar/SidebarLifecycleRows.tsx +++ b/apps/app/src/components/sidebar/SidebarLifecycleRows.tsx @@ -23,6 +23,7 @@ import { LIST_HOVER_TRANSITION } from "@bb/shared-ui/motion"; import { useEffect, useRef, useState, type ReactNode } from "react"; import { NavLink } from "react-router-dom"; import { + ThreadArchiveQuickAction, ThreadActionsContextMenu, ThreadActionsMenu, } from "@/components/thread/ThreadActionsMenu"; @@ -41,7 +42,6 @@ import { getThreadRoutePath } from "@/lib/route-paths"; import { SIDEBAR_MORE_ACTION_TRIGGER_CLASS, SIDEBAR_ROW_BASE_CLASS, - SIDEBAR_ROW_GLYPH_SLOT_CLASS, SIDEBAR_ROW_INTERACTIVE_STATE_CLASS, SIDEBAR_ROW_SELECTED_STATE_CLASS, SIDEBAR_STANDARD_ROW_PADDING_CLASS, @@ -201,16 +201,15 @@ function SidebarDraftRow({ "flex min-w-0 flex-1 items-center gap-2", )} > - - {draft.title} + + Draft + - - {title} + + Archived + + { @@ -276,10 +278,14 @@ export function ThreadArchiveQuickAction({ archiveThreadAndChildren(thread); }} > - + {showLabel ? ( + label + ) : ( + + )} {label} diff --git a/apps/app/src/hooks/useNewThreadDraftSlots.ts b/apps/app/src/hooks/useNewThreadDraftSlots.ts index 3f5990591b..de1e509dae 100644 --- a/apps/app/src/hooks/useNewThreadDraftSlots.ts +++ b/apps/app/src/hooks/useNewThreadDraftSlots.ts @@ -10,7 +10,7 @@ import type { NewThreadDraftSlot, } from "@/lib/prompt-draft-slots"; -const EMPTY_NEW_THREAD_DRAFT_ROWS: readonly NewThreadDraftRow[] = []; +const EMPTY_NEW_THREAD_DRAFT_SLOTS: readonly NewThreadDraftSlot[] = []; export interface NewThreadDraftRow { id: string; @@ -43,7 +43,7 @@ export function useNewThreadDraftSlots(): readonly NewThreadDraftRow[] { const slots = useSyncExternalStore( subscribeNewThreadDraftSlots, getNewThreadDraftSlotsSnapshot, - () => EMPTY_NEW_THREAD_DRAFT_ROWS, + () => EMPTY_NEW_THREAD_DRAFT_SLOTS, ); return useMemo( From 12626c607b58a5e4133f222f9d8a56087bf8e324 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Thu, 27 Aug 2026 15:25:39 -0700 Subject: [PATCH 11/11] Remove archived thread count contract --- apps/app/src/hooks/queries/query-keys.ts | 9 ----- .../src/hooks/queries/thread-queries.test.tsx | 24 ------------ apps/app/src/hooks/queries/thread-queries.ts | 21 ---------- apps/server/src/routes/threads/base.ts | 11 ------ .../test/public/public-thread-data.test.ts | 39 ------------------- packages/db/src/data/index.ts | 2 - packages/db/src/data/threads.ts | 12 ------ packages/db/test/data/threads.test.ts | 30 -------------- packages/sdk/src/areas/threads.ts | 21 ---------- packages/sdk/test/public-types.test.ts | 1 - packages/sdk/test/sdk.test.ts | 22 ----------- packages/server-contract/src/api/threads.ts | 14 ------- packages/server-contract/src/public-api.ts | 11 ------ .../server-contract/test/contract.test.ts | 11 ------ 14 files changed, 228 deletions(-) diff --git a/apps/app/src/hooks/queries/query-keys.ts b/apps/app/src/hooks/queries/query-keys.ts index 495bef8c4b..3d1dd90129 100644 --- a/apps/app/src/hooks/queries/query-keys.ts +++ b/apps/app/src/hooks/queries/query-keys.ts @@ -98,7 +98,6 @@ export interface ArchivedThreadsListFilters { } export const ARCHIVED_THREADS_LIST_KIND = "archivedList"; -const ARCHIVED_THREAD_COUNT_KIND = "archivedCount"; type HostsQueryKey = readonly [typeof HOSTS_QUERY_KEY]; type HostQueryId = string | null | undefined; @@ -181,10 +180,6 @@ type ArchivedThreadsListQueryKey = readonly [ typeof ARCHIVED_THREADS_LIST_KIND, ArchivedThreadsListFilters, ]; -type ArchivedThreadCountQueryKey = readonly [ - typeof THREADS_QUERY_KEY, - typeof ARCHIVED_THREAD_COUNT_KIND, -]; type DisabledThreadListQueryKey = readonly [ typeof THREADS_DISABLED_QUERY_KEY, ThreadListQueryFilters?, @@ -663,10 +658,6 @@ export function archivedThreadsListQueryKey( return [THREADS_QUERY_KEY, ARCHIVED_THREADS_LIST_KIND, filters]; } -export function archivedThreadCountQueryKey(): ArchivedThreadCountQueryKey { - return [THREADS_QUERY_KEY, ARCHIVED_THREAD_COUNT_KIND]; -} - export function disabledThreadListQueryKey( filters?: ThreadListQueryFilters, ): DisabledThreadListQueryKey { diff --git a/apps/app/src/hooks/queries/thread-queries.test.tsx b/apps/app/src/hooks/queries/thread-queries.test.tsx index 67c7de5993..075fdddf16 100644 --- a/apps/app/src/hooks/queries/thread-queries.test.tsx +++ b/apps/app/src/hooks/queries/thread-queries.test.tsx @@ -25,7 +25,6 @@ import { import { COMPACT_THREAD_TIMELINE_SEGMENT_LIMIT, didThreadDetailBootstrapRefreshAfterMount, - useArchivedThreadCount, useArchivedThreads, useChildThreads, useThread, @@ -48,7 +47,6 @@ vi.mock("@/lib/api", async (importOriginal) => { vi.mock("@/lib/sdk", () => ({ sdk: { threads: { - count: vi.fn(), get: vi.fn(), list: vi.fn(), queuedMessages: { list: vi.fn() }, @@ -147,7 +145,6 @@ afterEach(() => { }); beforeEach(() => { - vi.mocked(sdk.threads.count).mockResolvedValue({ count: 0 }); vi.mocked(sdk.threads.get).mockResolvedValue(THREAD_WITH_INCLUDES); vi.mocked(sdk.threads.list).mockResolvedValue([]); vi.mocked(sdk.threads.queuedMessages.list).mockResolvedValue([]); @@ -423,27 +420,6 @@ describe("useArchivedThreads", () => { }); }); -describe("useArchivedThreadCount", () => { - it("fetches a row-free global archived count only when explicitly requested", async () => { - vi.mocked(sdk.threads.count).mockResolvedValue({ count: 4 }); - const { wrapper } = createQueryClientTestHarness(); - - const { result } = renderHook(() => useArchivedThreadCount(), { wrapper }); - expect(sdk.threads.count).not.toHaveBeenCalled(); - - let fetchedCount: number | undefined; - await act(async () => { - fetchedCount = (await result.current.refetch()).data; - }); - - expect(sdk.threads.count).toHaveBeenCalledWith({ - archived: true, - signal: expect.any(AbortSignal), - }); - expect(fetchedCount).toBe(4); - }); -}); - describe("useThreadQueuedMessages", () => { it("refetches stale queue data on window focus", async () => { const { queryClient, wrapper } = createQueryClientTestHarness(); diff --git a/apps/app/src/hooks/queries/thread-queries.ts b/apps/app/src/hooks/queries/thread-queries.ts index 2616738160..a3c72ceecc 100644 --- a/apps/app/src/hooks/queries/thread-queries.ts +++ b/apps/app/src/hooks/queries/thread-queries.ts @@ -18,7 +18,6 @@ import type { ThreadSearchResponse, ThreadWithIncludesResponse, ThreadConversationOutlineResponse, - ThreadCountResponse, ThreadStorageFileListResponse, ThreadStorageLocationResponse, ThreadStoragePathListResponse, @@ -63,7 +62,6 @@ import { RESUME_REFETCH_QUERY_POLICY, } from "./query-policies"; import { - archivedThreadCountQueryKey, archivedThreadsListQueryKey, disabledThreadListQueryKey, threadDetailBootstrapQueryKey, @@ -366,25 +364,6 @@ export function useArchivedThreads( }); } -/** - * An explicit archived-count read for the sidebar Display menu. It stays - * disabled between opens so callers decide exactly when a fresh count is - * requested and displayed. - */ -export function useArchivedThreadCount() { - return useQuery< - ThreadCountResponse, - Error, - number, - ReturnType - >({ - queryKey: archivedThreadCountQueryKey(), - queryFn: ({ signal }) => sdk.threads.count({ archived: true, signal }), - enabled: false, - select: (response) => response.count, - }); -} - export function useThreads(filters: UseThreadsFilters, options?: QueryOptions) { const { projectId, ...rest } = filters; const enabled = (options?.enabled ?? true) && Boolean(projectId); diff --git a/apps/server/src/routes/threads/base.ts b/apps/server/src/routes/threads/base.ts index ba1f1e07c0..d99182e871 100644 --- a/apps/server/src/routes/threads/base.ts +++ b/apps/server/src/routes/threads/base.ts @@ -1,7 +1,6 @@ import { THREAD_SEARCH_LIMIT_PER_GROUP_DEFAULT, THREAD_SEARCH_LIMIT_PER_GROUP_MAX, - countThreads, countNonDeletedAssignedChildThreads, getEnvironment, getThreadSectionById, @@ -21,7 +20,6 @@ import { type ThreadGetQuery, type ThreadIncludeOption, type ThreadChildSummaryResponse, - type ThreadCountResponse, type ThreadSearchResponse, type ThreadWithIncludesResponse, type PublicApiSchema, @@ -213,15 +211,6 @@ export function registerThreadBaseRoutes(app: Hono, deps: AppDeps): void { }); const routes = publicApiRoutes.threads; - get(routes.count, (context, query) => { - const response: ThreadCountResponse = { - count: countThreads(deps.db, { - archived: query.archived === "true", - }), - }; - return context.json(response); - }); - get(routes.list, (context, query) => { const limit = parseOptionalInteger(query.limit, "limit"); if (limit !== undefined && limit <= 0) { diff --git a/apps/server/test/public/public-thread-data.test.ts b/apps/server/test/public/public-thread-data.test.ts index ee10221539..41f204bd43 100644 --- a/apps/server/test/public/public-thread-data.test.ts +++ b/apps/server/test/public/public-thread-data.test.ts @@ -1,6 +1,5 @@ import { and, eq } from "drizzle-orm"; import { - archiveThread, claimQueuedThreadMessage, createQueuedThreadMessageId, createThreadSection, @@ -11,7 +10,6 @@ import { getQueuedThreadMessage, insertEvents, listQueuedThreadMessages, - markThreadDeleted, getThread, queuedThreadMessages, reorderQueuedThreadMessage, @@ -31,7 +29,6 @@ import { threadSectionMutationResponseSchema, threadSectionSchema, threadConversationOutlineResponseSchema, - threadCountResponseSchema, threadQueuedMessageListResponseSchema, threadStorageLocationResponseSchema, threadTimelineResponseSchema, @@ -262,42 +259,6 @@ describe("public thread data routes", () => { }); }); - it("counts archived visible threads without returning thread rows", async () => { - await withTestHarness(async (harness) => { - const { host } = seedHostSession(harness.deps); - const { project } = seedProjectWithSource(harness.deps, { - hostId: host.id, - }); - const visibleArchived = seedThread(harness.deps, { - projectId: project.id, - }); - const hiddenArchived = seedThread(harness.deps, { - projectId: project.id, - visibility: "hidden", - }); - const deletedArchived = seedThread(harness.deps, { - projectId: project.id, - }); - seedThread(harness.deps, { projectId: project.id }); - - archiveThread(harness.db, harness.deps.hub, visibleArchived.id); - archiveThread(harness.db, harness.deps.hub, hiddenArchived.id); - archiveThread(harness.db, harness.deps.hub, deletedArchived.id); - markThreadDeleted(harness.db, harness.deps.hub, { - threadId: deletedArchived.id, - }); - - const response = await harness.app.request( - "/api/v1/threads/count?archived=true", - ); - - expect(response.status).toBe(200); - expect(threadCountResponseSchema.parse(await readJson(response))).toEqual( - { count: 1 }, - ); - }); - }); - it("allows creating or assigning a hidden thread in a section", async () => { await withTestHarness(async (harness) => { const { host } = seedHostSession(harness.deps); diff --git a/packages/db/src/data/index.ts b/packages/db/src/data/index.ts index 54c642f781..4cadc15b1a 100644 --- a/packages/db/src/data/index.ts +++ b/packages/db/src/data/index.ts @@ -53,7 +53,6 @@ export { } from "./project-sources.js"; export { createThread, - countThreads, countLiveThreadsInEnvironment, countNonDeletedAssignedChildThreads, getThread, @@ -92,7 +91,6 @@ export { export type { ApplyThreadLifecycleEventArgs, ApplyThreadLifecycleEventOutcome, - CountThreadsOptions, ReorderPinnedThreadResult, ThreadSearchHighlightRange, ThreadSearchMatch, diff --git a/packages/db/src/data/threads.ts b/packages/db/src/data/threads.ts index fb75a85bea..b09c32fc75 100644 --- a/packages/db/src/data/threads.ts +++ b/packages/db/src/data/threads.ts @@ -395,11 +395,6 @@ export interface ListThreadsOptions { includeHidden?: boolean; } -export type CountThreadsOptions = Omit< - ListThreadsOptions, - "limit" | "offset" ->; - type ThreadRow = typeof threads.$inferSelect; export interface ListThreadsForProjectsOptions { @@ -1202,13 +1197,6 @@ export function listThreads(db: DbConnection, options: ListThreadsOptions) { return query.all(); } -export function countThreads( - db: DbConnection, - options: CountThreadsOptions, -): number { - return countThreadsWhere(db, and(...buildListThreadsFilters(options))); -} - export function listThreadsWithPendingInteractionState( db: DbConnection, options: ListThreadsOptions, diff --git a/packages/db/test/data/threads.test.ts b/packages/db/test/data/threads.test.ts index a6db7e0f7a..69349b53fd 100644 --- a/packages/db/test/data/threads.test.ts +++ b/packages/db/test/data/threads.test.ts @@ -5,7 +5,6 @@ import { noopNotifier } from "../../src/notifier.js"; import type { DbNotifier } from "../../src/notifier.js"; import { createThread, - countThreads, countLiveThreadsInEnvironment, countNonDeletedAssignedChildThreads, getThread, @@ -693,35 +692,6 @@ describe("threads", () => { ]); }); - it("counts archived visible threads without returning list rows", () => { - const { db, project } = setup(); - const visibleArchived = createThread(db, noopNotifier, { - projectId: project.id, - providerId: "codex", - }); - const hiddenArchived = createThread(db, noopNotifier, { - projectId: project.id, - providerId: "codex", - visibility: "hidden", - }); - const deletedArchived = createThread(db, noopNotifier, { - projectId: project.id, - providerId: "codex", - }); - createThread(db, noopNotifier, { - projectId: project.id, - providerId: "codex", - }); - - archiveThread(db, noopNotifier, visibleArchived.id); - archiveThread(db, noopNotifier, hiddenArchived.id); - archiveThread(db, noopNotifier, deletedArchived.id); - markThreadDeleted(db, noopNotifier, { threadId: deletedArchived.id }); - - expect(countThreads(db, { archived: true })).toBe(1); - expect(countThreads(db, { archived: true, includeHidden: true })).toBe(2); - }); - it("counts active assigned child threads", () => { const { db, project } = setup(); const parent = createThread(db, noopNotifier, { diff --git a/packages/sdk/src/areas/threads.ts b/packages/sdk/src/areas/threads.ts index 3db85afbf4..d1a25551d7 100644 --- a/packages/sdk/src/areas/threads.ts +++ b/packages/sdk/src/areas/threads.ts @@ -23,8 +23,6 @@ import type { ThreadArchiveAllResponse, ThreadChildSummaryResponse, ThreadConversationOutlineResponse, - ThreadCountQuery, - ThreadCountResponse, ThreadListResponse, ThreadOpenResponse, ThreadPaneAction, @@ -85,11 +83,6 @@ export interface ThreadListArgs { unsectioned?: boolean; } -export interface ThreadCountArgs { - archived: boolean; - signal?: AbortSignal; -} - export interface ThreadSearchArgs extends ThreadSearchQuery { signal?: AbortSignal; } @@ -106,7 +99,6 @@ export interface ThreadGetArgs { export type ThreadGetResult = ThreadResponse | ThreadWithIncludesResponse; export type ThreadListResult = ThreadListResponse; -export type ThreadCountResult = ThreadCountResponse; export type ThreadSearchResult = ThreadSearchResponse; export type ThreadResolveMentionsResult = ResolveThreadMentionsResponse; export interface ThreadOutputResponse { @@ -441,7 +433,6 @@ export interface ThreadsArea { archiveAll(args: ThreadActionArgs): Promise; childSummary(args: ThreadStatusArgs): Promise; compact(args: ThreadActionArgs): Promise; - count(args: ThreadCountArgs): Promise; cancelPlan(args: ThreadActionArgs): Promise; clearGoal(args: ThreadActionArgs): Promise; conversationOutline( @@ -518,10 +509,6 @@ function listQuery(args: ThreadListArgs | undefined): ThreadListQuery { }; } -function countQuery(args: ThreadCountArgs): ThreadCountQuery { - return { archived: args.archived ? "true" : "false" }; -} - function updateJson(args: ThreadUpdateArgs): UpdateThreadRequest { return { title: args.title, @@ -934,14 +921,6 @@ export function createThreadsArea(args: CreateSdkAreaArgs): ThreadsArea { ), ); }, - async count(input) { - return transport.readJson( - transport.api.v1.threads.count.$get( - { query: countQuery(input) }, - ...signalRequestArgs(input.signal), - ), - ); - }, async defaultExecutionOptions(input) { return transport.readJson( transport.api.v1.threads[":id"]["default-execution-options"].$get( diff --git a/packages/sdk/test/public-types.test.ts b/packages/sdk/test/public-types.test.ts index 7f88dbabb2..6389b7638b 100644 --- a/packages/sdk/test/public-types.test.ts +++ b/packages/sdk/test/public-types.test.ts @@ -356,7 +356,6 @@ type ExpectedThreadsKey = | "childSummary" | "clearGoal" | "compact" - | "count" | "conversationOutline" | "defaultExecutionOptions" | "delete" diff --git a/packages/sdk/test/sdk.test.ts b/packages/sdk/test/sdk.test.ts index e6ab6524e8..a4d63d93da 100644 --- a/packages/sdk/test/sdk.test.ts +++ b/packages/sdk/test/sdk.test.ts @@ -654,28 +654,6 @@ describe("@bb/sdk", () => { ]); }); - it("routes thread count calls without fetching list rows", async () => { - const queue = createFetchQueue([{ body: { count: 7 } }]); - const sdk = createBbSdk({ - transport: createHttpTransport({ - baseUrl: "http://bb.test", - fetch: queue.fetch, - runtime: "node", - }), - }); - - await expect(sdk.threads.count({ archived: true })).resolves.toEqual({ - count: 7, - }); - expect(queue.requests).toEqual([ - { - bodyText: undefined, - method: "GET", - url: "http://bb.test/api/v1/threads/count?archived=true", - }, - ]); - }); - it("routes bounded thread mention resolution through one HTTP request", async () => { const resolved = [ { diff --git a/packages/server-contract/src/api/threads.ts b/packages/server-contract/src/api/threads.ts index 1d0c8ec4c4..280e7c6795 100644 --- a/packages/server-contract/src/api/threads.ts +++ b/packages/server-contract/src/api/threads.ts @@ -309,13 +309,6 @@ export type SendQueuedMessageResponse = z.infer< export const threadListResponseSchema = z.array(threadListEntrySchema); export type ThreadListResponse = z.infer; -export const threadCountResponseSchema = z - .object({ - count: z.number().int().nonnegative(), - }) - .strict(); -export type ThreadCountResponse = z.infer; - export const THREAD_MENTION_RESOLVE_MAX_IDS = 32; export const resolveThreadMentionsRequestSchema = z @@ -662,13 +655,6 @@ export const threadListQuerySchema = z.object({ }); export type ThreadListQuery = z.infer; -export const threadCountQuerySchema = z - .object({ - archived: z.enum(["true", "false"]), - }) - .strict(); -export type ThreadCountQuery = z.infer; - export const threadSearchQuerySchema = z.object({ query: z.string().trim().min(2), limitPerGroup: z.string().regex(/^\d+$/).optional(), diff --git a/packages/server-contract/src/public-api.ts b/packages/server-contract/src/public-api.ts index caa15e2729..e2033881c1 100644 --- a/packages/server-contract/src/public-api.ts +++ b/packages/server-contract/src/public-api.ts @@ -167,8 +167,6 @@ import type { TerminalResizeRequest, ThreadArchiveAllResponse, ThreadChildSummaryResponse, - ThreadCountQuery, - ThreadCountResponse, ThreadEventWaitQuery, ThreadEventsQuery, ThreadSectionMutationResponse, @@ -285,7 +283,6 @@ import { threadFilesRawQuerySchema, threadGetQuerySchema, threadHostFileContentQuerySchema, - threadCountQuerySchema, threadListQuerySchema, threadOpenRequestSchema, threadPaneActionRequestSchema, @@ -899,14 +896,6 @@ export const publicApiRoutes = { }, threads: { - count: defineRoute({ - path: "/threads/count", - method: "get", - request: queryRequest( - threadCountQuerySchema, - ), - response: jsonResponse(), - }), list: defineRoute({ path: "/threads", method: "get", diff --git a/packages/server-contract/test/contract.test.ts b/packages/server-contract/test/contract.test.ts index b471735411..dab2fe5e6f 100644 --- a/packages/server-contract/test/contract.test.ts +++ b/packages/server-contract/test/contract.test.ts @@ -31,8 +31,6 @@ import { terminalOutputResponseSchema, terminalSessionSchema, terminalWebSocketQuerySchema, - threadCountQuerySchema, - threadCountResponseSchema, threadListResponseSchema, threadPendingInteractionsResponseSchema, timelineTurnSummaryDetailsResponseSchema, @@ -828,15 +826,6 @@ describe("server-contract canonical schemas", () => { }), ).toThrow(); - expect(threadCountQuerySchema.parse({ archived: "true" })).toEqual({ - archived: "true", - }); - expect(() => threadCountQuerySchema.parse({})).toThrow(); - expect(threadCountResponseSchema.parse({ count: 12 })).toEqual({ - count: 12, - }); - expect(() => threadCountResponseSchema.parse({ count: -1 })).toThrow(); - expect( threadListResponseSchema.parse([ {