From 64e9963fa8d28267b6ddb00b379705bfe3063b29 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 18 Sep 2026 22:52:49 -0400 Subject: [PATCH 01/57] feat: discover saved drafts through thread lifecycle filters --- .../commands/CommandPalette.test.tsx | 1 + .../app/src/hooks/cache-owners/query-cache.ts | 5 + .../thread-runtime-cache-owner.test.ts | 35 ++- .../thread-runtime-cache-owner.ts | 14 +- .../palette-thread-search.test.ts | 1 + .../command-output/thread-list.test.ts | 25 ++ .../thread-organization.test.ts | 27 ++ .../command-output/thread-spawn.test.ts | 29 ++ .../command-output/thread-tell.test.ts | 54 ++++ apps/cli/src/commands/thread/actions.ts | 21 +- apps/cli/src/commands/thread/helpers.ts | 21 ++ apps/cli/src/commands/thread/list.ts | 8 + apps/cli/src/commands/thread/organization.ts | 13 +- apps/cli/src/commands/thread/spawn.ts | 19 +- apps/cli/src/json-shapes.ts | 5 +- apps/server/src/routes/threads/base.ts | 21 ++ .../services/plugins/plugin-hook-registry.ts | 5 +- .../src/services/plugins/plugin-service.ts | 9 + .../src/services/threads/dispatch-attempt.ts | 8 +- .../src/services/threads/dispatch-hooks.ts | 58 +++- .../src/services/threads/thread-create.ts | 2 + .../threads/thread-runtime-display.ts | 27 +- .../services/threads/thread-send-request.ts | 10 +- .../test/public/public-thread-search.test.ts | 104 ++++++- .../threads/thread-runtime-display.test.ts | 62 ++++ .../test/threads/dispatch-hooks.test.ts | 286 +++++++++++++++++- .../db/src/data/queued-thread-messages.ts | 2 + packages/db/src/data/threads.ts | 67 +++- .../data/thread-discovery-lifecycle.test.ts | 190 ++++++++++++ packages/domain/src/thread.ts | 5 + packages/sdk/src/areas/threads.ts | 14 +- packages/sdk/test/sdk.test.ts | 34 +++ packages/server-contract/src/api/threads.ts | 14 + .../server-contract/test/contract.test.ts | 1 + .../src/templates/bb-guide-threads.md | 22 ++ packages/test-helpers/src/domain-fixtures.ts | 1 + .../bb-cli/references/thread-creation.md | 4 + .../bb-cli/references/thread-operation.md | 12 + 38 files changed, 1199 insertions(+), 37 deletions(-) create mode 100644 packages/db/test/data/thread-discovery-lifecycle.test.ts diff --git a/apps/app/src/components/commands/CommandPalette.test.tsx b/apps/app/src/components/commands/CommandPalette.test.tsx index acf02e3764d..31ff5a720f0 100644 --- a/apps/app/src/components/commands/CommandPalette.test.tsx +++ b/apps/app/src/components/commands/CommandPalette.test.tsx @@ -308,6 +308,7 @@ function makeThread( environmentWorkspaceDisplayKind: "other", runtime: { displayStatus: "idle", hostReconnectGraceExpiresAt: null }, queuedWork: "none", + lifecycle: overrides.archivedAt != null ? "archived" : "active", ...overrides, }; } diff --git a/apps/app/src/hooks/cache-owners/query-cache.ts b/apps/app/src/hooks/cache-owners/query-cache.ts index 7e3a40b8460..7b3cb508289 100644 --- a/apps/app/src/hooks/cache-owners/query-cache.ts +++ b/apps/app/src/hooks/cache-owners/query-cache.ts @@ -2,6 +2,7 @@ import type { QueryClient, QueryKey } from "@tanstack/react-query"; import type { Thread, ThreadListEntry, + ThreadLifecycle, ThreadStatusChangeMetadata, } from "@bb/domain"; import { @@ -572,10 +573,14 @@ function threadMatchesListFilters( export function optimisticallyInsertThread( queryClient: QueryClient, thread: ThreadResponse, + lifecycle: ThreadLifecycle = thread.archivedAt !== null + ? "archived" + : "active", ): void { const queuedWork = thread.queuedMessageCount > 0 ? "waiting" : "none"; const insertedThread: ThreadListEntry = { ...thread, + lifecycle, activity: { activeWorkflowCount: 0, activeBackgroundAgentCount: 0, diff --git a/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.test.ts b/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.test.ts index 61a1584aa9b..77edad530f8 100644 --- a/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.test.ts +++ b/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.test.ts @@ -15,7 +15,10 @@ import { makeProjectWithThreadsResponse, makeSidebarBootstrapResponse, } from "@/test/fixtures/projects"; -import { makeThreadTimelineResponse as makeTimelineResponse } from "@/test/fixtures/thread-responses"; +import { + makeThreadResponse, + makeThreadTimelineResponse as makeTimelineResponse, +} from "@/test/fixtures/thread-responses"; import { sidebarNavigationQueryKey, threadListQueryKey, @@ -27,6 +30,7 @@ import { } from "../queries/query-keys"; import { threadDefaultExecutionOptionsQueryKey } from "../queries/thread-default-execution-options-query"; import { + applyCreateThreadResult, applyQueuedMessageCreateResult, applyQueuedMessageSendResult, applyQueuedMessageUpdateResult, @@ -112,6 +116,35 @@ function makeQueuedMessage( } describe("thread runtime cache owner", () => { + it("keeps a newly saved draft classified while the list refreshes", () => { + const queryClient = createAppQueryClient({ + defaultOptions: { queries: { gcTime: Infinity, retry: false } }, + showMutationErrorToasts: false, + }); + const key = threadListQueryKey({ archived: false, projectId: "project-1" }); + queryClient.setQueryData(key, []); + + applyCreateThreadResult({ + queryClient, + request: { + projectId: "project-1", + input: [{ type: "text", text: "Save for later", mentions: [] }], + environment: { type: "project-default" }, + pluginSubmission: { pluginId: "drafts", data: { kind: "draft" } }, + }, + thread: makeThreadResponse({ + id: "thread-draft", + projectId: "project-1", + status: "pending", + queuedMessageCount: 1, + }), + }); + + expect(queryClient.getQueryData(key)).toEqual([ + expect.objectContaining({ id: "thread-draft", lifecycle: "draft" }), + ]); + }); + it.each([ ["Plan", applyThreadPlanCancellationResult, "activePlanModeCount"], ["Goal", applyThreadGoalClearResult, "activeGoalCount"], diff --git a/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.ts b/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.ts index 695a69c1c41..9fe2ebb3cb3 100644 --- a/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.ts +++ b/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.ts @@ -890,7 +890,19 @@ export function applyCreateThreadResult({ thread, }: CreateThreadSuccessArgs): void { queryClient.setQueryData(threadQueryKey(thread.id), thread); - optimisticallyInsertThread(queryClient, thread); + const submission = request.pluginSubmission; + const savedDraft = + thread.status === "pending" && + submission?.pluginId === "drafts" && + submission.data !== null && + typeof submission.data === "object" && + !Array.isArray(submission.data) && + submission.data.kind === "draft"; + optimisticallyInsertThread( + queryClient, + thread, + savedDraft ? "draft" : "active", + ); prependProjectPromptHistory( queryClient, request.projectId, diff --git a/apps/app/src/lib/command-palette/palette-thread-search.test.ts b/apps/app/src/lib/command-palette/palette-thread-search.test.ts index 8ce19f522ec..8ddaf6be9ae 100644 --- a/apps/app/src/lib/command-palette/palette-thread-search.test.ts +++ b/apps/app/src/lib/command-palette/palette-thread-search.test.ts @@ -49,6 +49,7 @@ function makeThread( environmentWorkspaceDisplayKind: "other", runtime: { displayStatus: "idle", hostReconnectGraceExpiresAt: null }, queuedWork: "none", + lifecycle: overrides.archivedAt != null ? "archived" : "active", ...overrides, }; } diff --git a/apps/cli/src/__tests__/command-output/thread-list.test.ts b/apps/cli/src/__tests__/command-output/thread-list.test.ts index 23557696b35..418b466756f 100644 --- a/apps/cli/src/__tests__/command-output/thread-list.test.ts +++ b/apps/cli/src/__tests__/command-output/thread-list.test.ts @@ -16,6 +16,31 @@ describe("bb thread list command output", () => { const register: CommandRegistrar = (program) => registerThreadCommands(program, () => "http://server"); + it("passes explicit lifecycle filters without imposing the legacy archive filter", async () => { + const list = vi.fn(async () => []); + stubServerApi({ "v1.threads.$get": list }); + + await runCommand( + ["thread", "list", "--lifecycle", "draft,archived"], + register, + ); + + expect(list).toHaveBeenCalledWith({ + query: { lifecycles: "draft,archived" }, + }); + }); + + it("rejects an empty lifecycle instead of broadening the list", async () => { + const list = vi.fn(async () => []); + stubServerApi({ "v1.threads.$get": list }); + + await expect( + runCommand(["thread", "list", "--lifecycle", ""], register), + ).rejects.toThrow("process.exit:1"); + + expect(list).not.toHaveBeenCalled(); + }); + it("bb thread list supports parent-thread filtering", async () => { const list = vi.fn(async () => []); stubServerApi({ "v1.threads.$get": list }); diff --git a/apps/cli/src/__tests__/command-output/thread-organization.test.ts b/apps/cli/src/__tests__/command-output/thread-organization.test.ts index 5c2edcab641..165447871d8 100644 --- a/apps/cli/src/__tests__/command-output/thread-organization.test.ts +++ b/apps/cli/src/__tests__/command-output/thread-organization.test.ts @@ -41,6 +41,33 @@ describe("bb thread organization commands", () => { const register: CommandRegistrar = (program) => registerThreadCommands(program, () => "http://server"); + it("forwards lifecycle filtering and the per-group limit", async () => { + const search = vi.fn(async () => ({ + active: { total: 0, results: [] }, + archived: { total: 0, results: [] }, + draft: { total: 0, results: [] }, + })); + stubServerApi({ "v1.threads.search.$get": search }); + + await runCommand( + [ + "thread", + "search", + "release", + "--lifecycle", + "draft", + "--limit", + "3", + "--json", + ], + register, + ); + + expect(search).toHaveBeenCalledWith({ + query: { query: "release", lifecycles: "draft", limitPerGroup: 3 }, + }); + }); + it("creates a named thread section", async () => { const create = vi.fn(async () => ({ id: "section-review", diff --git a/apps/cli/src/__tests__/command-output/thread-spawn.test.ts b/apps/cli/src/__tests__/command-output/thread-spawn.test.ts index a4d02129287..813bce58307 100644 --- a/apps/cli/src/__tests__/command-output/thread-spawn.test.ts +++ b/apps/cli/src/__tests__/command-output/thread-spawn.test.ts @@ -27,6 +27,35 @@ describe("bb thread spawn command output", () => { return vi.spyOn(process.stderr, "write").mockImplementation(() => true); } + it("saves the first message with the shipped Drafts submission", async () => { + const post = vi.fn(async ({ json }: { json: unknown }) => { + createThreadRequestSchema.parse(json); + return fixtures.makeThread({ id: "thread-draft", status: "pending" }); + }); + stubServerApi({ "v1.threads.$post": post }); + + await runCommand( + [ + "thread", + "spawn", + "--project", + "proj-1", + "--prompt", + "Save this", + "--draft", + ], + register, + ); + + expect(post).toHaveBeenCalledWith({ + json: expect.objectContaining({ + pluginSubmission: { pluginId: "drafts", data: { kind: "draft" } }, + input: [{ type: "text", text: "Save this", mentions: [] }], + }), + }); + expect(collectLogLines()[0]).toBe("Draft saved: thread-draft"); + }); + it("rejects explicitly empty lifecycle ownership instead of creating an independent thread", async () => { const post = vi.fn(async ({ json }: { json: unknown }) => { createThreadRequestSchema.parse(json); diff --git a/apps/cli/src/__tests__/command-output/thread-tell.test.ts b/apps/cli/src/__tests__/command-output/thread-tell.test.ts index b94badaed04..a0e7eb34b30 100644 --- a/apps/cli/src/__tests__/command-output/thread-tell.test.ts +++ b/apps/cli/src/__tests__/command-output/thread-tell.test.ts @@ -18,6 +18,60 @@ describe("bb thread tell command output", () => { const register: CommandRegistrar = (program) => registerThreadCommands(program, () => "http://server"); + it("saves a follow-up draft without steering the active turn", async () => { + const post = vi.fn(async () => ({ + ok: true, + delivery: "queued", + queuedMessage: { + id: "qm-draft", + waitingOn: { kind: "plugin", pluginId: "drafts", reason: "Draft" }, + sendAt: null, + }, + })); + stubServerApi({ "v1.threads.:id.send.$post": post }); + + await runCommand( + ["thread", "tell", "thread-draft", "Save this", "--draft"], + register, + ); + + expect(post).toHaveBeenCalledWith({ + param: { id: "thread-draft" }, + json: expect.objectContaining({ + mode: "queue-if-active", + pluginSubmission: { pluginId: "drafts", data: { kind: "draft" } }, + }), + }); + expect(vi.mocked(console.log).mock.calls[0]?.[0]).toBe( + "Thread thread-draft draft saved; send it with bb thread queue send", + ); + }); + + it("rejects conflicting draft and steer requests before sending", async () => { + const post = vi.fn(); + stubServerApi({ "v1.threads.:id.send.$post": post }); + + await expect( + runCommand( + [ + "thread", + "tell", + "thread-draft", + "Save this", + "--draft", + "--mode", + "steer", + ], + register, + ), + ).rejects.toThrow("process.exit:1"); + + expect(post).not.toHaveBeenCalled(); + expect(vi.mocked(console.error).mock.calls[0]?.[0]).toBe( + "Error: --draft cannot be combined with --mode steer or auto.", + ); + }); + it("bb thread tell --json prints the raw response plus thread id", async () => { const post = vi.fn(async () => ({ ok: true, delivery: "sent" })); stubServerApi({ "v1.threads.:id.send.$post": post }); diff --git a/apps/cli/src/commands/thread/actions.ts b/apps/cli/src/commands/thread/actions.ts index f7a4aff90b0..e9d17133b3a 100644 --- a/apps/cli/src/commands/thread/actions.ts +++ b/apps/cli/src/commands/thread/actions.ts @@ -69,6 +69,7 @@ interface ThreadDeleteCommandOptions { } interface ThreadTellCommandOptions { + draft?: boolean; json?: boolean; messageFile?: string; model?: string; @@ -106,6 +107,7 @@ interface ThreadEditMessageCommandOptions { type ThreadTellDeliveryMode = "auto" | "queue" | "steer"; interface PostThreadMessageArgs { + draft?: boolean; getUrl: () => string; threadId: string; message: string; @@ -440,6 +442,7 @@ export function registerActionsCommands( .command("tell [message]") .aliases(["message", "send"]) .description("Send a follow-up message to a thread") + .option("--draft", "Save the message as a draft until you send it manually") .option( "--message-file ", `Read the message from a file instead of [message]; ${TEXT_FILE_HELP_SUFFIX}`, @@ -483,11 +486,18 @@ export function registerActionsCommands( inline: inlineMessage, inlineLabel: "", }); + const mode = resolveThreadMessageMode(opts.mode); + if (opts.draft && opts.mode !== undefined && mode !== "queue") { + throw new Error( + "--draft cannot be combined with --mode steer or auto.", + ); + } const response = await postThreadMessage({ getUrl, threadId: id, message, - mode: resolveThreadMessageMode(opts.mode), + mode: opts.draft ? "queue" : mode, + draft: opts.draft, model: opts.model, permissionMode: parsePermissionMode(opts.permissionMode), reasoningLevel: parseReasoningLevel(opts.reasoningLevel), @@ -618,6 +628,9 @@ async function postThreadMessage( ...(args.serviceTier ? { serviceTier: args.serviceTier } : {}), ...(args.senderThreadId ? { senderThreadId: args.senderThreadId } : {}), ...(args.sendAt === undefined ? {} : { sendAt: args.sendAt }), + ...(args.draft + ? { pluginSubmission: { pluginId: "drafts", data: { kind: "draft" } } } + : {}), }); return { ...response, mode: args.mode }; } @@ -627,6 +640,12 @@ function describeThreadTellOutcome( response: PostThreadMessageResult, ): string { if (response.delivery === "queued") { + if ( + response.queuedMessage.waitingOn?.kind === "plugin" && + response.queuedMessage.waitingOn.pluginId === "drafts" + ) { + return `Thread ${threadId} draft saved; send it with bb thread queue send`; + } // The server says WHY it is waiting, so the CLI does not have to guess // from the flags it happened to send. `bb thread queue list` shows the // same reason for the row afterwards. diff --git a/apps/cli/src/commands/thread/helpers.ts b/apps/cli/src/commands/thread/helpers.ts index d7bbee371cc..9179b0656fd 100644 --- a/apps/cli/src/commands/thread/helpers.ts +++ b/apps/cli/src/commands/thread/helpers.ts @@ -8,6 +8,8 @@ import { type PromptInput, serviceTierSchema, type ServiceTier, + threadLifecycleSchema, + type ThreadLifecycle, } from "@bb/domain"; import { DEFAULT_THREAD_WAIT_POLL_INTERVAL_MS, @@ -29,6 +31,25 @@ export const PERMISSION_MODE_HELP = export const PLAN_HELP = "Send the message as the provider's /plan action so the agent proposes a plan for approval before executing"; +export function parseThreadLifecycles( + value: string | undefined, +): ThreadLifecycle[] | undefined { + if (value === undefined) return undefined; + return [ + ...new Set( + value.split(",").map((entry) => { + const result = threadLifecycleSchema.safeParse(entry.trim()); + if (!result.success) { + throw new Error( + "--lifecycle must contain active, draft, or archived, separated by commas.", + ); + } + return result.data; + }), + ), + ]; +} + export function buildPromptInputs(args: { message: string; files?: readonly string[]; diff --git a/apps/cli/src/commands/thread/list.ts b/apps/cli/src/commands/thread/list.ts index eb4111bd248..ec71258b411 100644 --- a/apps/cli/src/commands/thread/list.ts +++ b/apps/cli/src/commands/thread/list.ts @@ -9,12 +9,14 @@ import { truncateCell, } from "../../table.js"; import { outputJson } from "../helpers.js"; +import { parseThreadLifecycles } from "./helpers.js"; interface ThreadListCommandOptions { environment?: string; project?: string; parentThread?: string; archived?: boolean; + lifecycle?: string; section?: string; unsectioned?: boolean; json?: boolean; @@ -34,6 +36,10 @@ export function registerListCommand( .option("--section ", "Filter by thread section ID") .option("--unsectioned", "Show only threads outside sections") .option("--archived", "Show only archived threads") + .option( + "--lifecycle ", + "Filter by active, draft, or archived (comma-separated)", + ) .option("--include-hidden", "Include hidden threads") .option("--json", "Print machine-readable JSON output") .action( @@ -58,7 +64,9 @@ export function registerListCommand( flagName: "--section", value: opts.section, }); + const lifecycles = parseThreadLifecycles(opts.lifecycle); const threads = await sdk.threads.list({ + ...(lifecycles === undefined ? {} : { lifecycles }), ...(projectId ? { projectId } : {}), ...(environmentId ? { environmentId } : {}), ...(parentThreadId ? { parentThreadId } : {}), diff --git a/apps/cli/src/commands/thread/organization.ts b/apps/cli/src/commands/thread/organization.ts index f5e53141b51..a1f7955a721 100644 --- a/apps/cli/src/commands/thread/organization.ts +++ b/apps/cli/src/commands/thread/organization.ts @@ -22,7 +22,11 @@ import { outputJson, requireThreadIdOrSelf, } from "../helpers.js"; -import { buildPromptInputs, uploadClientAttachmentInputs } from "./helpers.js"; +import { + buildPromptInputs, + parseThreadLifecycles, + uploadClientAttachmentInputs, +} from "./helpers.js"; interface JsonOptions { json?: boolean; @@ -38,6 +42,7 @@ interface SectionDeleteOptions extends JsonOptions { interface SearchOptions extends JsonOptions { limit?: string; + lifecycle?: string; } interface HistoryOptions extends JsonOptions { @@ -230,6 +235,10 @@ export function registerOrganizationCommands( parent .command("search ") .description("Search threads and messages") + .option( + "--lifecycle ", + "Filter by active, draft, or archived (comma-separated)", + ) .option( "--limit ", `Maximum results per group (1-${THREAD_SEARCH_LIMIT_PER_GROUP_MAX})`, @@ -237,8 +246,10 @@ export function registerOrganizationCommands( .option("--json", "Print machine-readable JSON output") .action( action(async (query: string, opts: SearchOptions) => { + const lifecycles = parseThreadLifecycles(opts.lifecycle); const result = await createCliBbSdk(getUrl()).threads.search({ query, + ...(lifecycles === undefined ? {} : { lifecycles }), limitPerGroup: parsePositiveInteger( opts.limit, "--limit", diff --git a/apps/cli/src/commands/thread/spawn.ts b/apps/cli/src/commands/thread/spawn.ts index afb92217144..786dfadf2ca 100644 --- a/apps/cli/src/commands/thread/spawn.ts +++ b/apps/cli/src/commands/thread/spawn.ts @@ -73,6 +73,7 @@ interface ThreadSpawnCommandOptions { sourceSeqEnd?: string; visibility?: string; sendAt?: string; + draft?: boolean; } export function looksLikePath(value: string): boolean { @@ -392,6 +393,10 @@ export function registerSpawnCommand( "JSON value for an --environment-provider that declares inputs (`bb environment providers --json` shows the schema)", ) .option("--send-at ", SEND_AT_HELP) + .option( + "--draft", + "Save the first message as a draft until you send it manually", + ) .option("--origin-kind ", "Thread origin: fork") .option("--source-thread ", "Source thread for a fork") .option( @@ -596,14 +601,24 @@ export function registerSpawnCommand( ...(opts.sourceThread ? { sourceThreadId: opts.sourceThread } : {}), ...(sourceSeqEnd !== undefined ? { sourceSeqEnd } : {}), ...(sendAt !== undefined ? { sendAt } : {}), + ...(opts.draft + ? { + pluginSubmission: { + pluginId: "drafts", + data: { kind: "draft" }, + }, + } + : {}), }); } catch (err: unknown) { throw prependErrorContext("Failed to create thread", err); } if (outputJson(opts, thread)) return; - console.log(`Thread spawned: ${thread.id}`); - if (sendAt !== undefined) { + console.log( + `${opts.draft ? "Draft saved" : "Thread spawned"}: ${thread.id}`, + ); + if (sendAt !== undefined && !opts.draft) { console.log( `First message scheduled for ${new Date(sendAt).toLocaleString()}; the thread stays pending until then.`, ); diff --git a/apps/cli/src/json-shapes.ts b/apps/cli/src/json-shapes.ts index 2cfce1d7beb..59db40e053a 100644 --- a/apps/cli/src/json-shapes.ts +++ b/apps/cli/src/json-shapes.ts @@ -2,7 +2,7 @@ export const JSON_SHAPE_BY_COMMAND_PATH: Readonly> = { status: "{project: {id, name} | null, thread: {id, status, title, parentThreadId, environment: {hostId, display} | null} | null, childThreads: [{id, status, title}] | null, pendingTodos, pluginsNeedingAttention: [{id, status}], dataDir}", "thread list": - "[{id, projectId, environmentId, providerId, title, status, parentThreadId, sectionId, visibility, archivedAt, pinnedAt, createdAt, updatedAt, activity}] (bare array; title can be null)", + "[{id, projectId, environmentId, providerId, title, status, lifecycle, parentThreadId, sectionId, visibility, archivedAt, pinnedAt, createdAt, updatedAt, activity}] (bare array; title can be null)", "thread show": "{thread: {id, status, title, projectId, environmentId, parentThreadId, ...}, environment: {id, hostId, path, branchName, ...} | null, pendingTodos} (thread fields are under .thread)", "thread log": @@ -11,7 +11,8 @@ export const JSON_SHAPE_BY_COMMAND_PATH: Readonly> = { "thread spawn": "the created thread: {id, status, title, projectId, environmentId, ...}", "thread wait": "{threadId, matched: true, target}", - "thread search": "{active: {total, results}, archived: {total, results}}", + "thread search": + "{active: {total, results}, archived: {total, results}, draft?: {total, results}} (draft group present with --lifecycle)", "project list": "[{id, kind, name, gitRemoteUrl, sources: [{id, hostId, path, isDefault}]}] (bare array)", "machine list": diff --git a/apps/server/src/routes/threads/base.ts b/apps/server/src/routes/threads/base.ts index e26770630a7..999e7f404b0 100644 --- a/apps/server/src/routes/threads/base.ts +++ b/apps/server/src/routes/threads/base.ts @@ -17,6 +17,7 @@ import { type UpdateThreadInput, } from "@bb/db"; import type { Environment, Thread, ThreadListEntry } from "@bb/domain"; +import { threadLifecycleSchema } from "@bb/domain"; import { toEnvironmentResponse } from "../../services/environments/environment-response.js"; import { threadIncludeOptionSchema, @@ -85,6 +86,7 @@ interface BuildThreadSearchGroupResponseArgs { interface BuildThreadSearchResponseArgs { active: DbThreadSearchResultGroup; + draft?: DbThreadSearchResultGroup; archived: DbThreadSearchResultGroup; } @@ -204,6 +206,11 @@ function buildThreadSearchResponse( ): ThreadSearchResponse { return { active: buildThreadSearchGroupResponse(deps, { group: args.active }), + ...(args.draft === undefined + ? {} + : { + draft: buildThreadSearchGroupResponse(deps, { group: args.draft }), + }), archived: buildThreadSearchGroupResponse(deps, { group: args.archived }), }; } @@ -272,6 +279,13 @@ export function registerThreadBaseRoutes(app: Hono, deps: AppDeps): void { requireThreadSection(deps, query.sectionId); } const threads = listThreadsWithPendingInteractionState(deps.db, { + ...(query.lifecycles === undefined + ? {} + : { + lifecycles: query.lifecycles + .split(",") + .map((value) => threadLifecycleSchema.parse(value)), + }), ...(query.projectId ? { projectId: query.projectId } : {}), ...(query.environmentId ? { environmentId: query.environmentId } : {}), ...(query.parentThreadId ? { parentThreadId: query.parentThreadId } : {}), @@ -308,6 +322,13 @@ export function registerThreadBaseRoutes(app: Hono, deps: AppDeps): void { ...searchThreadsWithPendingInteractionState(deps.db, { query: searchQuery, limitPerGroup, + ...(query.lifecycles === undefined + ? {} + : { + lifecycles: query.lifecycles + .split(",") + .map((value) => threadLifecycleSchema.parse(value)), + }), }), }) satisfies ThreadSearchResponse, ); diff --git a/apps/server/src/services/plugins/plugin-hook-registry.ts b/apps/server/src/services/plugins/plugin-hook-registry.ts index 12e62a32718..7831745f53c 100644 --- a/apps/server/src/services/plugins/plugin-hook-registry.ts +++ b/apps/server/src/services/plugins/plugin-hook-registry.ts @@ -52,6 +52,7 @@ export async function invokeBridgedProvider( * seams — and because it is the seam a test substitutes fake handlers through. */ export interface PluginHookProvider { + isPluginRunning?(pluginId: string): boolean; /** Registered handlers for a hook, in plugin install order. */ listHooks(hook: K): PluginHookRegistration[]; /** @@ -77,7 +78,9 @@ export interface PluginHookProvider { */ let provider: PluginHookProvider | undefined; -export function setPluginHookProvider(next: PluginHookProvider | undefined): void { +export function setPluginHookProvider( + next: PluginHookProvider | undefined, +): void { provider = next; } diff --git a/apps/server/src/services/plugins/plugin-service.ts b/apps/server/src/services/plugins/plugin-service.ts index f4f905949de..0b65a696c4a 100644 --- a/apps/server/src/services/plugins/plugin-service.ts +++ b/apps/server/src/services/plugins/plugin-service.ts @@ -1266,6 +1266,15 @@ export function createPluginService(deps: PluginServiceDeps): PluginService { }, hooks: { + isPluginRunning(pluginId) { + const row = getInstalledPlugin(deps.db, pluginId); + return ( + row !== undefined && + row.enabled && + loaded.has(pluginId) && + getStatus(row).status === "running" + ); + }, listHooks: listPluginHooks, invokeHook: invokeIsolated, decisionTimeoutMs: DEFAULT_PLUGIN_HOOK_TIMEOUT_MS, diff --git a/apps/server/src/services/threads/dispatch-attempt.ts b/apps/server/src/services/threads/dispatch-attempt.ts index 6b62d8380b1..27ffeb04dd2 100644 --- a/apps/server/src/services/threads/dispatch-attempt.ts +++ b/apps/server/src/services/threads/dispatch-attempt.ts @@ -43,6 +43,8 @@ import { dispatchExecutionSources, dispatchWaitReasonForPass, hasMessageDispatchHooks, + isDraftSubmission, + requireDraftSubmissionAvailable, noteDispatchRequeued, runMessageDispatchHookPass, type DispatchAttemptKind, @@ -293,6 +295,7 @@ async function runDispatchAttempt( reattempted: boolean, ): Promise { const { payload, thread } = args; + requireDraftSubmissionAvailable(args.pluginSubmission); // A stopping thread is writable HERE and nowhere upstream: the checkpoint // below turns it into a core wait, which is a truthful "not yet" the row can // recover from, rather than the 409 that used to make a stop a dead end for @@ -505,7 +508,10 @@ async function runDispatchAttempt( } }; - if (!sendNow && hasMessageDispatchHooks()) { + if ( + !sendNow && + (isDraftSubmission(args.pluginSubmission) || hasMessageDispatchHooks()) + ) { const outcome = await runMessageDispatchHookPass(deps, { thread, threadResponse: toThreadResponseFromThread(deps, { thread }), diff --git a/apps/server/src/services/threads/dispatch-hooks.ts b/apps/server/src/services/threads/dispatch-hooks.ts index 554d9fe1999..e1a7fe457dd 100644 --- a/apps/server/src/services/threads/dispatch-hooks.ts +++ b/apps/server/src/services/threads/dispatch-hooks.ts @@ -166,6 +166,38 @@ export function hasMessageDispatchHooks(): boolean { ); } +export function isDraftSubmission( + submission: MessageDispatchHookContext["experimental_submission"] | undefined, +): boolean { + return ( + submission?.pluginId === "drafts" && + submission.data !== null && + typeof submission.data === "object" && + !Array.isArray(submission.data) && + submission.data.kind === "draft" + ); +} + +export function requireDraftSubmissionAvailable( + submission: MessageDispatchHookContext["experimental_submission"] | undefined, +): void { + if (!isDraftSubmission(submission)) return; + const provider = pluginHookProvider(); + if ( + provider?.isPluginRunning?.("drafts") !== true || + !provider + .listHooks("message.dispatch") + .some((hook) => hook.pluginId === "drafts") + ) { + throw new ApiError( + 409, + "drafts_unavailable", + "Drafts must be installed, enabled, and running to save a draft.", + { details: { pluginId: "drafts" } }, + ); + } +} + /** * Server-wide evaluation lock. * @@ -393,6 +425,7 @@ export async function runMessageDispatchHookPass( deps: DispatchHookDeps, request: MessageDispatchHookPassRequest, ): Promise { + requireDraftSubmissionAvailable(request.pluginSubmission); const provider = pluginHookProvider(); if (provider === undefined) { return { kind: "proceed" }; @@ -403,6 +436,7 @@ export async function runMessageDispatchHookPass( } return withEvaluationLock(async () => { + requireDraftSubmissionAvailable(request.pluginSubmission); const context = buildHookContext(deps, request); const waits: MessageDispatchWaitDecision[] = []; @@ -447,12 +481,32 @@ export async function runMessageDispatchHookPass( } } - const waiter = waits[0]; + const draftWait = waits.find((wait) => wait.pluginId === "drafts"); + if ( + isDraftSubmission(request.pluginSubmission) && + (draftWait === undefined || draftWait.sendAt !== null) + ) { + throw messageDispatchHookFailure( + "drafts", + "did not hold the draft for manual dispatch", + ); + } + const firstQueuedWait = request.queuedMessages[0]?.waitingOn; + const preserveDraftHold = + isDraftSubmission(request.pluginSubmission) || + (firstQueuedWait?.kind === "plugin" && + firstQueuedWait.pluginId === "drafts"); + const waiter = + preserveDraftHold && draftWait !== undefined ? draftWait : waits[0]; if (waiter === undefined) { await request.continueAfterHooks?.(); return { kind: "proceed" }; } - return { kind: "wait", waiter, additionalWaiters: waits.slice(1) }; + return { + kind: "wait", + waiter, + additionalWaiters: waits.filter((wait) => wait !== waiter), + }; }); } diff --git a/apps/server/src/services/threads/thread-create.ts b/apps/server/src/services/threads/thread-create.ts index 0914d5b04a3..0e3052714e4 100644 --- a/apps/server/src/services/threads/thread-create.ts +++ b/apps/server/src/services/threads/thread-create.ts @@ -18,6 +18,7 @@ import type { LoggedPendingInteractionWorkSessionDeps, } from "../../types.js"; import { ApiError } from "../../errors.js"; +import { requireDraftSubmissionAvailable } from "./dispatch-hooks.js"; import { ensureHostSessionReadyForWork } from "../hosts/host-lifecycle.js"; import { buildExecutionOptions } from "./thread-commands.js"; import { @@ -525,6 +526,7 @@ export async function createThreadFromRequest( forkSourceEnvironmentId?: string; } = {}, ) { + requireDraftSubmissionAvailable(rawRequestInput.pluginSubmission); const project = requirePublicProjectForThreadCreate( deps, rawRequestInput.projectId, diff --git a/apps/server/src/services/threads/thread-runtime-display.ts b/apps/server/src/services/threads/thread-runtime-display.ts index 4dc73f35993..b51372589f1 100644 --- a/apps/server/src/services/threads/thread-runtime-display.ts +++ b/apps/server/src/services/threads/thread-runtime-display.ts @@ -19,6 +19,7 @@ import type { ThreadActivityState, ThreadChangeMetadata, ThreadListEntry, + ThreadLifecycle, ThreadQueuedWork, ThreadRuntimeState, ThreadStatus, @@ -87,6 +88,7 @@ interface ToThreadListEntryResponseFromLatestSessionArgs { latestSession: HostDaemonSessionRow | null; now?: number; queuedWork: ThreadQueuedWork; + lifecycle: ThreadLifecycle; thread: ThreadWithPendingInteractionState; } @@ -522,16 +524,19 @@ function buildThreadActivityStateByThreadId( function buildThreadQueuedWorkByThreadId( deps: ThreadRuntimeDisplayDeps, threads: readonly Thread[], -): Map { - const result = new Map(); +): Map { + const result = new Map< + string, + { queuedWork: ThreadQueuedWork; hasDraft: boolean } + >(); for (const counts of listQueuedThreadMessageCountsByThreadIds(deps.db, { threadIds: threads.map((thread) => thread.id), })) { if (counts.queuedMessageCount === 0) continue; - result.set( - counts.threadId, - counts.failedQueuedMessageCount > 0 ? "failed" : "waiting", - ); + result.set(counts.threadId, { + queuedWork: counts.failedQueuedMessageCount > 0 ? "failed" : "waiting", + hasDraft: counts.draftQueuedMessageCount > 0, + }); } return result; } @@ -568,9 +573,16 @@ export function toThreadListEntryResponses( args.threads, ); return args.threads.map((thread) => { + const queue = queuedWorkByThreadId.get(thread.id); return toThreadListEntryResponseFromLatestSession({ activity: activityByThreadId.get(thread.id) ?? EMPTY_THREAD_ACTIVITY, - queuedWork: queuedWorkByThreadId.get(thread.id) ?? "none", + queuedWork: queue?.queuedWork ?? "none", + lifecycle: + thread.archivedAt !== null + ? "archived" + : thread.status === "pending" && queue?.hasDraft === true + ? "draft" + : "active", hostConnected: thread.environmentHostId !== null && connectedActiveHostIds.has(thread.environmentHostId), @@ -592,6 +604,7 @@ function toThreadListEntryResponseFromLatestSession( ...thread, activity: args.activity, queuedWork: args.queuedWork, + lifecycle: args.lifecycle, pinSortKey: args.thread.pinSortKey, environmentBranchName: args.thread.environmentBranchName, environmentHostId: args.thread.environmentHostId, diff --git a/apps/server/src/services/threads/thread-send-request.ts b/apps/server/src/services/threads/thread-send-request.ts index 132257c3c4f..332a73f980d 100644 --- a/apps/server/src/services/threads/thread-send-request.ts +++ b/apps/server/src/services/threads/thread-send-request.ts @@ -5,6 +5,10 @@ import type { } from "@bb/server-contract"; import type { LoggedPendingInteractionWorkSessionDeps } from "../../types.js"; import { attemptDispatch } from "./dispatch-attempt.js"; +import { + isDraftSubmission, + requireDraftSubmissionAvailable, +} from "./dispatch-hooks.js"; import { requireThreadCommandEnvironment } from "./thread-command-environment.js"; import { sendThreadMessage } from "./thread-send.js"; @@ -17,7 +21,11 @@ export async function acceptThreadSendRequest( deps: LoggedPendingInteractionWorkSessionDeps, args: AcceptThreadSendRequestArgs, ): Promise { - if (isStandaloneBuiltinClearCommand(args.payload.input)) { + requireDraftSubmissionAvailable(args.payload.pluginSubmission); + if ( + !isDraftSubmission(args.payload.pluginSubmission) && + isStandaloneBuiltinClearCommand(args.payload.input) + ) { const environment = await requireThreadCommandEnvironment(deps, { thread: args.thread, }); diff --git a/apps/server/test/public/public-thread-search.test.ts b/apps/server/test/public/public-thread-search.test.ts index 63f425d13cb..09f9a508dd3 100644 --- a/apps/server/test/public/public-thread-search.test.ts +++ b/apps/server/test/public/public-thread-search.test.ts @@ -1,5 +1,8 @@ -import { archiveThread } from "@bb/db"; -import { threadSearchResponseSchema } from "@bb/server-contract"; +import { archiveThread, createQueuedThreadMessage } from "@bb/db"; +import { + threadListResponseSchema, + threadSearchResponseSchema, +} from "@bb/server-contract"; import { describe, expect, it } from "vitest"; import { readJson } from "../helpers/json.js"; import { @@ -84,4 +87,101 @@ describe("public thread search route", () => { expect(badLimitResponse.status).toBe(400); }); }); + + it("opts into lifecycle list filters and a separate draft search group", async () => { + await withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + }); + const draft = seedThread(harness.deps, { + projectId: project.id, + status: "pending", + title: "lifecycleroute draft", + }); + const active = seedThread(harness.deps, { + projectId: project.id, + title: "lifecycleroute active", + }); + const archived = seedThread(harness.deps, { + projectId: project.id, + title: "lifecycleroute archived", + }); + const hidden = seedThread(harness.deps, { + projectId: project.id, + status: "pending", + title: "lifecycleroute hidden", + visibility: "hidden", + }); + for (const thread of [draft, hidden]) { + createQueuedThreadMessage(harness.db, harness.deps.hub, { + threadId: thread.id, + content: [{ type: "text", text: "Saved draft", mentions: [] }], + model: "gpt-5", + reasoningLevel: "medium", + permissionMode: "full", + serviceTier: "default", + waitingOn: { kind: "plugin", pluginId: "drafts", reason: "Draft" }, + sendAt: null, + payload: { kind: "inline" }, + systemNotice: null, + }); + } + archiveThread(harness.db, harness.deps.hub, archived.id); + const legacyResponse = await harness.app.request( + "/api/v1/threads/search?query=lifecycleroute", + ); + const legacy = threadSearchResponseSchema.parse( + await readJson(legacyResponse), + ); + expect(Object.keys(legacy)).toEqual(["active", "archived"]); + expect( + new Set(legacy.active.results.map((result) => result.thread.id)), + ).toEqual(new Set([draft.id, active.id])); + const response = await harness.app.request( + "/api/v1/threads/search?query=lifecycleroute&lifecycles=draft,archived", + ); + const body = threadSearchResponseSchema.parse(await readJson(response)); + expect(response.status).toBe(200); + expect(body.active).toEqual({ total: 0, results: [] }); + expect( + body.draft?.results.map((result) => [ + result.thread.id, + result.thread.lifecycle, + ]), + ).toEqual([[draft.id, "draft"]]); + expect(body.archived.results.map((result) => result.thread.id)).toEqual([ + archived.id, + ]); + const listResponse = await harness.app.request( + `/api/v1/threads?projectId=${project.id}&lifecycles=draft&limit=1`, + ); + expect(listResponse.status).toBe(200); + expect( + threadListResponseSchema + .parse(await readJson(listResponse)) + .map((thread) => thread.id), + ).toEqual([draft.id]); + const intersection = await harness.app.request( + `/api/v1/threads?projectId=${project.id}&lifecycles=draft&archived=true`, + ); + expect(await readJson(intersection)).toEqual([]); + for (const lifecycles of ["", "unknown", "draft,", "draft,unknown"]) { + expect( + ( + await harness.app.request( + `/api/v1/threads?lifecycles=${lifecycles}`, + ) + ).status, + ).toBe(400); + expect( + ( + await harness.app.request( + `/api/v1/threads/search?query=lifecycleroute&lifecycles=${lifecycles}`, + ) + ).status, + ).toBe(400); + } + }); + }); }); diff --git a/apps/server/test/services/threads/thread-runtime-display.test.ts b/apps/server/test/services/threads/thread-runtime-display.test.ts index 2deaaf650c0..5f2137aeed6 100644 --- a/apps/server/test/services/threads/thread-runtime-display.test.ts +++ b/apps/server/test/services/threads/thread-runtime-display.test.ts @@ -3,6 +3,8 @@ import { eq } from "drizzle-orm"; import { describe, expect, it } from "vitest"; import { appendStoredThreadEvent, + archiveThread, + claimQueuedThreadMessage, closeSession, createConnection, createEnvironment, @@ -519,6 +521,66 @@ describe("thread runtime display", () => { ).toHaveLength(32_767); }); + it("projects only unarchived pending threads with live Drafts holds as drafts", () => { + const { db, hostId, hub } = setup(); + const saved = createThreadWithEnvironment({ + db, + hostId, + status: "pending", + }); + const followup = createThreadWithEnvironment({ + db, + hostId, + status: "idle", + }); + const archived = createThreadWithEnvironment({ + db, + hostId, + status: "pending", + }); + const claimed = createThreadWithEnvironment({ + db, + hostId, + status: "pending", + }); + const pending = createThreadWithEnvironment({ + db, + hostId, + status: "pending", + }); + for (const fixture of [saved, followup, archived, claimed]) { + const queued = createQueuedThreadMessage(db, noopNotifier, { + threadId: fixture.thread.id, + content: [{ type: "text", text: "Saved draft", mentions: [] }], + model: "gpt-5", + reasoningLevel: "medium", + permissionMode: "auto", + serviceTier: "default", + waitingOn: { kind: "plugin", pluginId: "drafts", reason: "Draft" }, + sendAt: null, + payload: { kind: "inline" }, + systemNotice: null, + }); + if (fixture === claimed) + claimQueuedThreadMessage(db, noopNotifier, queued.id); + } + archiveThread(db, noopNotifier, archived.thread.id); + const entries = toThreadListEntryResponses( + { db, hub, providerRegistry }, + { + threads: listThreadsWithPendingInteractionState(db, {}), + }, + ); + const byId = new Map(entries.map((entry) => [entry.id, entry])); + expect( + [saved, followup, archived, claimed, pending].map( + (fixture) => byId.get(fixture.thread.id)?.lifecycle, + ), + ).toEqual(["draft", "active", "archived", "active", "active"]); + expect(byId.get(saved.thread.id)?.queuedWork).toBe("waiting"); + expect(byId.get(claimed.thread.id)?.queuedWork).toBe("none"); + }); + it("marks list entries active when the prompt banner would show plan or goal state", () => { const { db, hostId, hub } = setup(); const activePlan = createThreadWithEnvironment({ db, hostId }); diff --git a/apps/server/test/threads/dispatch-hooks.test.ts b/apps/server/test/threads/dispatch-hooks.test.ts index 9953b894e97..c15e1f51991 100644 --- a/apps/server/test/threads/dispatch-hooks.test.ts +++ b/apps/server/test/threads/dispatch-hooks.test.ts @@ -1,10 +1,13 @@ import { createQueuedThreadMessage, + environments, + threads, getThread, listEvents, listQueuedThreadMessages, listQueuedThreadMessagesForApi, listRunningThreads, + listThreadsWithPendingInteractionState, setQueuedThreadMessageGroupBoundary, } from "@bb/db"; import type { ThreadQueuedMessage } from "@bb/domain"; @@ -72,9 +75,12 @@ function emptyRegistry(): HookRegistry { */ function installHooks( registry: HookRegistry, - options: { decisionTimeoutMs?: number } = {}, + options: { decisionTimeoutMs?: number; running?: boolean } = {}, ): void { setPluginHookProvider({ + isPluginRunning: (pluginId) => + options.running ?? + registry["message.dispatch"].some((hook) => hook.pluginId === pluginId), listHooks: (hook) => registry[hook], // Mirrors the plugin service's failure isolation: a throw is reported, not // propagated, and the runner is what turns it into a failed dispatch. @@ -197,6 +203,256 @@ async function expectApiError(run: () => Promise): Promise { throw new Error("expected the operation to fail"); } +describe("built-in Drafts save-only admission", () => { + const pluginSubmission = { pluginId: "drafts", data: { kind: "draft" } }; + + it.each(["missing", "disabled", "failed", "missing-handler"] as const)( + "rejects %s Drafts before creating a thread, environment, or turn", + async (state) => { + await withTestHarness(async (harness) => { + const hostId = `host-drafts-${state}`; + const { project, thread } = seedRunnableThread(harness, { + hostId, + status: "active", + }); + if (state === "missing") { + setPluginHookProvider(undefined); + } else { + installHooks( + { + "message.dispatch": + state === "missing-handler" + ? [] + : [ + { + pluginId: "drafts", + handler: () => ({ action: "wait", reason: "Draft" }), + }, + ], + }, + { running: state === "missing-handler" }, + ); + } + const beforeThreads = harness.db.select().from(threads).all().length; + const beforeEnvironments = harness.db + .select() + .from(environments) + .all().length; + const beforeTurns = turnRequests(harness, thread.id).length; + const createError = await expectApiError(() => + createThreadFromRequest(harness.deps, { + projectId: project.id, + providerId: "codex", + environment: { + type: "host", + hostId, + workspace: { + type: "unmanaged", + path: "/tmp/drafts-new-environment", + }, + }, + origin: "sdk", + startedOnBehalfOf: null, + input: textInput("save only"), + pluginSubmission, + }), + ); + expect(createError.body.code).toBe("drafts_unavailable"); + const sendError = await expectApiError(() => + acceptThreadSendRequest(harness.deps, { + thread, + payload: { + input: textInput("/clear"), + mode: "steer-if-active", + pluginSubmission, + }, + }), + ); + expect(sendError.body.code).toBe("drafts_unavailable"); + expect(harness.db.select().from(threads).all()).toHaveLength( + beforeThreads, + ); + expect(harness.db.select().from(environments).all()).toHaveLength( + beforeEnvironments, + ); + expect(turnRequests(harness, thread.id)).toHaveLength(beforeTurns); + expect(queuedRows(harness, thread.id)).toEqual([]); + }); + }, + ); + + it("keeps active steering, a future schedule, and /clear behind the Drafts hold", async () => { + await withTestHarness(async (harness) => { + const { thread } = seedRunnableThread(harness, { + hostId: "host-save-only-steer", + status: "active", + }); + installHooks({ + "message.dispatch": [ + { + pluginId: "drafts", + handler: () => ({ action: "wait", reason: "Draft" }), + }, + ], + }); + const beforeTurns = turnRequests(harness, thread.id).length; + const response = await acceptThreadSendRequest(harness.deps, { + thread, + payload: { + input: textInput("/clear"), + mode: "steer-if-active", + sendAt: Date.now() + 60_000, + pluginSubmission, + }, + }); + expect(response.delivery).toBe("queued"); + expect(onlyQueuedRow(harness, thread.id)).toMatchObject({ + waitingOn: { kind: "plugin", pluginId: "drafts", reason: "Draft" }, + sendAt: null, + }); + expect(turnRequests(harness, thread.id)).toHaveLength(beforeTurns); + expect(getThread(harness.db, thread.id)?.status).toBe("active"); + }); + }); + + it.each(["throws", "proceeds", "schedules"] as const)( + "fails closed when Drafts %s", + async (behavior) => { + await withTestHarness(async (harness) => { + const { host, project } = seedDispatchFixture( + harness, + `host-draft-${behavior}`, + ); + installHooks({ + "message.dispatch": [ + { + pluginId: "drafts", + handler: () => { + if (behavior === "throws") throw new Error("Drafts failed"); + return behavior === "proceeds" + ? { action: "proceed" } + : { + action: "wait", + reason: "Draft", + sendAt: Date.now() + 60_000, + }; + }, + }, + ], + }); + const beforeEnvironments = harness.db + .select() + .from(environments) + .all().length; + const error = await expectApiError(() => + createThreadFromRequest(harness.deps, { + projectId: project.id, + providerId: "codex", + environment: { + type: "host", + hostId: host.id, + workspace: { + type: "unmanaged", + path: "/tmp/drafts-failure-environment", + }, + }, + origin: "sdk", + startedOnBehalfOf: null, + input: textInput("save only"), + pluginSubmission, + }), + ); + expect(error.body.code).toBe("dispatch_hook_failed"); + expect(harness.db.select().from(environments).all()).toHaveLength( + beforeEnvironments, + ); + for (const row of harness.db.select().from(threads).all()) { + expect(turnRequests(harness, row.id)).toEqual([]); + } + }); + }, + ); + + it("keeps the durable Drafts owner when another plugin also waits", async () => { + await withTestHarness(async (harness) => { + const { thread } = seedRunnableThread(harness, { + hostId: "host-drafts-owner", + status: "idle", + }); + installHooks({ + "message.dispatch": [ + { + pluginId: "capacity", + handler: () => ({ action: "wait", reason: "Busy" }), + }, + { + pluginId: "drafts", + handler: (context) => { + const wait = context.queuedMessages[0]?.waitingOn; + return context.experimental_submission?.pluginId === "drafts" || + (wait?.kind === "plugin" && wait.pluginId === "drafts") + ? { action: "wait", reason: "Draft" } + : { action: "proceed" }; + }, + }, + ], + }); + const beforeTurns = turnRequests(harness, thread.id).length; + await acceptThreadSendRequest(harness.deps, { + thread, + payload: { + input: textInput("save only"), + mode: "auto", + pluginSubmission, + }, + }); + const saved = onlyQueuedRow(harness, thread.id); + expect(saved.waitingOn).toEqual({ + kind: "plugin", + pluginId: "drafts", + reason: "Draft (also waiting on capacity: Busy)", + }); + await runQueuedMessageDispatch(harness.deps, { kind: "plugin-recheck" }); + expect(onlyQueuedRow(harness, thread.id)).toMatchObject({ + id: saved.id, + waitingOn: { kind: "plugin", pluginId: "drafts" }, + }); + expect(turnRequests(harness, thread.id)).toHaveLength(beforeTurns); + }); + }); + + it.each([ + { pluginId: "other", data: { kind: "draft" } }, + { pluginId: "drafts", data: { kind: "other" } }, + { pluginId: "drafts", data: null }, + { pluginId: "drafts", data: [{ kind: "draft" }] }, + ])( + "preserves opaque submission behavior for $pluginId / $data", + async (opaqueSubmission) => { + await withTestHarness(async (harness) => { + const { thread } = seedRunnableThread(harness, { + hostId: "host-opaque-draft", + status: "idle", + }); + setPluginHookProvider(undefined); + const response = await acceptThreadSendRequest(harness.deps, { + thread, + payload: { + input: textInput("scheduled opaque request"), + mode: "auto", + sendAt: Date.now() + 60_000, + pluginSubmission: opaqueSubmission, + }, + }); + expect(response.delivery).toBe("queued"); + expect(onlyQueuedRow(harness, thread.id).waitingOn).toEqual({ + kind: "time", + }); + }); + }, + ); +}); + describe("message.dispatch hook context", () => { it("passes plugin submission data through a new thread's first dispatch", async () => { await withTestHarness(async (harness) => { @@ -207,7 +463,7 @@ describe("message.dispatch hook context", () => { pluginId: "drafts", handler: (context) => { seen.push(context.experimental_submission); - return { action: "proceed" }; + return { action: "wait", reason: "Draft" }; }, }, ], @@ -221,7 +477,7 @@ describe("message.dispatch hook context", () => { data: { kind: "draft" }, }; - await createThreadFromRequest(harness.deps, { + const created = await createThreadFromRequest(harness.deps, { environment: { type: "host", hostId: host.id, @@ -236,6 +492,30 @@ describe("message.dispatch hook context", () => { }); expect(seen).toEqual([pluginSubmission]); + expect( + listThreadsWithPendingInteractionState(harness.db, { + lifecycles: ["draft"], + }).map((thread) => thread.id), + ).toEqual([created.id]); + const saved = onlyQueuedRow(harness, created.id); + await sendQueuedMessage(harness.deps, { + threadId: created.id, + queuedMessageId: saved.id, + mode: "auto", + claimPolicy: { kind: "explicit-send" }, + }); + expect(seen).toEqual([pluginSubmission]); + expect( + listThreadsWithPendingInteractionState(harness.db, { + lifecycles: ["draft"], + }), + ).toEqual([]); + expect( + listThreadsWithPendingInteractionState(harness.db, { + lifecycles: ["active"], + }).map((thread) => thread.id), + ).toContain(created.id); + expect(queuedRows(harness, created.id)).toEqual([]); }); }); diff --git a/packages/db/src/data/queued-thread-messages.ts b/packages/db/src/data/queued-thread-messages.ts index 79a029c6a60..c76e004e1d1 100644 --- a/packages/db/src/data/queued-thread-messages.ts +++ b/packages/db/src/data/queued-thread-messages.ts @@ -1572,6 +1572,7 @@ export function listQueuedThreadMessagesForApi( export interface QueuedThreadMessageCounts { threadId: string; queuedMessageCount: number; + draftQueuedMessageCount: number; /** * How many of those rows last failed to dispatch. Counted in the same pass * as the total because both answers come from the same rows, and the thread @@ -1605,6 +1606,7 @@ export function listQueuedThreadMessageCountsByThreadIds( threadId: queuedThreadMessages.threadId, queuedMessageCount: count(queuedThreadMessages.id), failedQueuedMessageCount: count(queuedThreadMessages.failureReason), + draftQueuedMessageCount: sql`count(CASE WHEN ${queuedThreadMessages.waitHolder} = 'plugin:drafts' THEN 1 END)`.mapWith(Number), }) .from(queuedThreadMessages) .where( diff --git a/packages/db/src/data/threads.ts b/packages/db/src/data/threads.ts index 9375627e1bb..7aedd70b59a 100644 --- a/packages/db/src/data/threads.ts +++ b/packages/db/src/data/threads.ts @@ -14,12 +14,14 @@ import { or, sql, type SQL, + type SQLWrapper, } from "drizzle-orm"; import type { JsonObject, ReasoningLevel, ThreadChangeKind, ThreadLifecycleEvent, + ThreadLifecycle, ThreadLifecycleNoopReason, ThreadOriginKind, ThreadSearchSourceKind, @@ -106,12 +108,14 @@ export interface ThreadSearchResultGroup { export interface ThreadSearchResults { active: ThreadSearchResultGroup; + draft?: ThreadSearchResultGroup; archived: ThreadSearchResultGroup; } export interface SearchThreadsWithPendingInteractionStateArgs { query: string; limitPerGroup: number; + lifecycles?: readonly ThreadLifecycle[]; } export interface UpsertThreadTitleSearchSegmentsArgs { @@ -143,10 +147,11 @@ interface ListThreadSearchMatchRowsArgs { anyTokenMatchQuery: string; limitPerGroup: number; tokenMatchQueries: readonly string[]; + lifecycles?: readonly ThreadLifecycle[]; } interface ThreadSearchMatchRow { - archived: number; + lifecycle: ThreadLifecycle; segmentOrder: number; sourceKind: string; sourceSeq: number | null; @@ -413,6 +418,7 @@ export interface ListThreadsOptions { projectId?: string; environmentId?: string; archived?: boolean; + lifecycles?: readonly ThreadLifecycle[]; sectionId?: string; unsectioned?: boolean; parentThreadId?: string; @@ -676,8 +682,27 @@ function statusTransitionNeedsAttention(args: StatusTransition): boolean { return args.currentStatus === "active" || args.currentStatus === "starting"; } +function threadLifecycleSql(thread: { + id: SQLWrapper; + archivedAt: SQLWrapper; + status: SQLWrapper; +} = threads): SQL { + return sql`CASE + WHEN ${thread.archivedAt} IS NOT NULL THEN 'archived' + WHEN ${thread.status} = 'pending' AND ${thread.id} IN ( + SELECT thread_id FROM queued_thread_messages + WHERE wait_holder = 'plugin:drafts' + AND claimed_at IS NULL AND claim_token IS NULL + ) THEN 'draft' + ELSE 'active' + END`; +} + function buildListThreadsFilters(options: ListThreadsOptions) { return [ + options.lifecycles === undefined + ? undefined + : inArray(threadLifecycleSql(), [...options.lifecycles]), options.projectId ? eq(threads.projectId, options.projectId) : undefined, options.environmentId ? eq(threads.environmentId, options.environmentId) @@ -993,6 +1018,17 @@ function listThreadSearchMatchRows( ); const isTitleSegment = sql`thread_search_segments.source_kind IN ('title', 'title_fallback')`; + const lifecycle = args.lifecycles === undefined + ? sql`CASE WHEN t.archived_at IS NOT NULL THEN 'archived' ELSE 'active' END` + : threadLifecycleSql({ + id: sql`t.id`, + archivedAt: sql`t.archived_at`, + status: sql`t.status`, + }); + const lifecycleFilter = args.lifecycles === undefined + ? sql`1 = 1` + : inArray(sql`lifecycle`, [...args.lifecycles]); + return db.all(sql` WITH token_matches AS ( ${sql.join(tokenMatchSelects, sql` UNION ALL `)} @@ -1002,7 +1038,7 @@ function listThreadSearchMatchRows( token_matches.threadId AS threadId, MIN(token_matches.tokenRank) AS bestRank, MAX(t.updated_at) AS threadUpdatedAt, - MAX(t.archived_at IS NOT NULL) AS archived + ${lifecycle} AS lifecycle FROM token_matches JOIN threads AS t ON t.id = token_matches.threadId WHERE t.deleted_at IS NULL @@ -1013,22 +1049,23 @@ function listThreadSearchMatchRows( ordered_threads AS ( SELECT threadId, - archived, + lifecycle, ROW_NUMBER() OVER ( - PARTITION BY archived + PARTITION BY lifecycle ORDER BY bestRank ASC, threadUpdatedAt DESC, threadId DESC ) AS threadOrder, - COUNT(*) OVER (PARTITION BY archived) AS total + COUNT(*) OVER (PARTITION BY lifecycle) AS total FROM ranked_threads + WHERE ${lifecycleFilter} ), limited_threads AS ( - SELECT threadId, archived, threadOrder, total + SELECT threadId, lifecycle, threadOrder, total FROM ordered_threads WHERE threadOrder <= ${args.limitPerGroup} ), ranked_segments AS ( SELECT - limited_threads.archived AS archived, + limited_threads.lifecycle AS lifecycle, limited_threads.threadOrder AS threadOrder, limited_threads.total AS total, ROW_NUMBER() OVER ( @@ -1051,7 +1088,7 @@ function listThreadSearchMatchRows( WHERE thread_search_segments_fts MATCH ${args.anyTokenMatchQuery} ) SELECT - archived, + lifecycle, threadOrder, total, segmentOrder, @@ -1063,7 +1100,7 @@ function listThreadSearchMatchRows( FROM ranked_segments WHERE isTitle = 1 OR segmentOrder <= ${THREAD_SEARCH_MESSAGE_MATCHES_PER_THREAD} - ORDER BY archived ASC, threadOrder ASC, isTitle DESC, segmentOrder ASC + ORDER BY lifecycle ASC, threadOrder ASC, isTitle DESC, segmentOrder ASC `); } @@ -1136,6 +1173,7 @@ export function searchThreadsWithPendingInteractionState( if (anyTokenMatchQuery === null) { return { active: { total: 0, results: [] }, + ...(args.lifecycles === undefined ? {} : { draft: { total: 0, results: [] } }), archived: { total: 0, results: [] }, }; } @@ -1148,16 +1186,23 @@ export function searchThreadsWithPendingInteractionState( anyTokenMatchQuery, limitPerGroup, tokenMatchQueries, + ...(args.lifecycles === undefined ? {} : { lifecycles: args.lifecycles }), }); return { active: hydrateThreadSearchGroup(db, { tokens, - rows: rows.filter((row) => row.archived === 0), + rows: rows.filter((row) => row.lifecycle === "active"), + }), + ...(args.lifecycles === undefined ? {} : { + draft: hydrateThreadSearchGroup(db, { + tokens, + rows: rows.filter((row) => row.lifecycle === "draft"), + }), }), archived: hydrateThreadSearchGroup(db, { tokens, - rows: rows.filter((row) => row.archived === 1), + rows: rows.filter((row) => row.lifecycle === "archived"), }), }; } diff --git a/packages/db/test/data/thread-discovery-lifecycle.test.ts b/packages/db/test/data/thread-discovery-lifecycle.test.ts new file mode 100644 index 00000000000..9b850052972 --- /dev/null +++ b/packages/db/test/data/thread-discovery-lifecycle.test.ts @@ -0,0 +1,190 @@ +import { eq } from "drizzle-orm"; +import { describe, expect, it, vi } from "vitest"; +import { threadScope, type ThreadLifecycle } from "@bb/domain"; +import { createConnection } from "../../src/connection.js"; +import { migrate } from "../../src/migrate.js"; +import { noopNotifier } from "../../src/notifier.js"; +import { threads } from "../../src/schema.js"; +import { insertEvents, listEvents } from "../../src/data/events.js"; +import { upsertHost } from "../../src/data/hosts.js"; +import { createProject } from "../../src/data/projects.js"; +import { + claimQueuedThreadMessage, + createQueuedThreadMessage, + deleteQueuedThreadMessage, + getQueuedThreadMessage, + listQueuedThreadMessageCountsByThreadIds, + setQueuedThreadMessageFailureReason, +} from "../../src/data/queued-thread-messages.js"; +import { + archiveThread, + createThread, + getThread, + listThreadsWithPendingInteractionState, + searchThreadsWithPendingInteractionState, + unarchiveThread, +} from "../../src/data/threads.js"; + +function setup() { + const db = createConnection(":memory:"); + migrate(db); + const host = upsertHost(db, noopNotifier, { name: "lifecycle-host" }); + const { project } = createProject(db, noopNotifier, { + name: "lifecycle-project", + source: { type: "local_path", hostId: host.id, path: "/tmp/lifecycle" }, + }); + function thread(status: "pending" | "idle" = "pending") { + return createThread(db, noopNotifier, { + projectId: project.id, + providerId: "codex", + status, + title: "discovery lifecycle", + }); + } + function draft(threadId: string, pluginId = "drafts") { + return createQueuedThreadMessage(db, noopNotifier, { + threadId, + content: [{ type: "text", text: "saved message", mentions: [] }], + model: "gpt-5", + reasoningLevel: "medium", + permissionMode: "full", + serviceTier: "default", + waitingOn: { kind: "plugin", pluginId, reason: "Draft" }, + sendAt: null, + payload: { kind: "inline" }, + systemNotice: null, + }); + } + function list(lifecycles: readonly ThreadLifecycle[]) { + return listThreadsWithPendingInteractionState(db, { lifecycles }).map((row) => row.id); + } + return { db, project, thread, draft, list }; +} + +describe("derived thread lifecycle discovery", () => { + it("recognizes live Drafts waits, preserves archive precedence and counts in one grouped pass", () => { + const { db, thread, draft, list } = setup(); + try { + const saved = thread(); + const savedRow = draft(saved.id); + const followup = thread("idle"); + draft(followup.id); + const other = thread(); + draft(other.id, "scheduler"); + const claimed = thread(); + const claimedRow = draft(claimed.id); + claimQueuedThreadMessage(db, noopNotifier, claimedRow.id); + setQueuedThreadMessageFailureReason(db, noopNotifier, { + threadId: saved.id, + id: savedRow.id, + failureReason: "Host unavailable", + }); + const prepare = vi.spyOn(db.$client, "prepare"); + try { + expect(listQueuedThreadMessageCountsByThreadIds(db, { + threadIds: [saved.id, followup.id, other.id, claimed.id], + })).toEqual(expect.arrayContaining([ + { threadId: saved.id, queuedMessageCount: 1, failedQueuedMessageCount: 1, draftQueuedMessageCount: 1 }, + { threadId: followup.id, queuedMessageCount: 1, failedQueuedMessageCount: 0, draftQueuedMessageCount: 1 }, + { threadId: other.id, queuedMessageCount: 1, failedQueuedMessageCount: 0, draftQueuedMessageCount: 0 }, + ])); + expect(prepare).toHaveBeenCalledTimes(1); + } finally { + prepare.mockRestore(); + } + expect(list(["draft"])).toEqual([saved.id]); + expect(new Set(list(["active"]))).toEqual(new Set([followup.id, other.id, claimed.id])); + archiveThread(db, noopNotifier, saved.id); + expect(list(["draft"])).toEqual([]); + expect(list(["archived"])).toEqual([saved.id]); + unarchiveThread(db, noopNotifier, saved.id); + expect(list(["draft"])).toEqual([saved.id]); + expect(getQueuedThreadMessage(db, savedRow.id)?.waitingOn).toContain('"drafts"'); + } finally { + db.$client.close(); + } + }); + + it("filters before list offsets and search limits while legacy search keeps drafts active", () => { + const { db, thread, draft } = setup(); + try { + const first = thread(); + const second = thread(); + draft(first.id); + draft(second.id); + db.update(threads).set({ createdAt: 1, updatedAt: 1 }).where(eq(threads.id, first.id)).run(); + db.update(threads).set({ createdAt: 2, updatedAt: 2 }).where(eq(threads.id, second.id)).run(); + for (let index = 0; index < 24; index += 1) thread("idle"); + const archived = thread(); + draft(archived.id); + archiveThread(db, noopNotifier, archived.id); + const hidden = thread(); + draft(hidden.id); + db.update(threads).set({ visibility: "hidden" }).where(eq(threads.id, hidden.id)).run(); + const deleted = thread(); + draft(deleted.id); + db.update(threads).set({ deletedAt: Date.now() }).where(eq(threads.id, deleted.id)).run(); + + expect(listThreadsWithPendingInteractionState(db, { + lifecycles: ["draft"], limit: 1, offset: 1, + }).map((row) => row.id)).toEqual([first.id]); + expect(listThreadsWithPendingInteractionState(db, { + lifecycles: ["draft"], archived: true, + })).toEqual([]); + const legacy = searchThreadsWithPendingInteractionState(db, { + query: "discovery", limitPerGroup: 50, + }); + expect(Object.keys(legacy)).toEqual(["active", "archived"]); + expect(legacy.active.total).toBe(26); + expect(legacy.active.results.map((result) => result.thread.id)).toContain(first.id); + const filtered = searchThreadsWithPendingInteractionState(db, { + query: "discovery", limitPerGroup: 1, lifecycles: ["draft"], + }); + expect(filtered.active).toEqual({ total: 0, results: [] }); + expect(filtered.archived).toEqual({ total: 0, results: [] }); + expect(filtered.draft?.total).toBe(2); + expect(filtered.draft?.results.map((result) => result.thread.id)).toEqual([second.id]); + const all = searchThreadsWithPendingInteractionState(db, { + query: "discovery", limitPerGroup: 50, lifecycles: ["active", "draft", "archived"], + }); + expect([all.active.total, all.draft?.total, all.archived.total]).toEqual([24, 2, 1]); + expect(all.archived.results.map((result) => result.thread.id)).toEqual([archived.id]); + } finally { + db.$client.close(); + } + }); + + it("deletes only the held row and preserves its owning fork and inherited history", () => { + const { db, project, thread, draft, list } = setup(); + try { + const source = thread("idle"); + const fork = createThread(db, noopNotifier, { + projectId: project.id, + providerId: "codex", + status: "pending", + sourceThreadId: source.id, + originKind: "fork", + }); + insertEvents(db, noopNotifier, [{ + threadId: fork.id, + sequence: 1, + type: "item/completed", + scope: threadScope(), + itemId: "inherited-message", + itemKind: "agentMessage", + parentToolCallId: null, + data: JSON.stringify({ item: { id: "inherited-message", type: "agentMessage", text: "Inherited response" } }), + }]); + const saved = draft(fork.id); + const before = listEvents(db, { threadId: fork.id }); + expect(list(["draft"])).toEqual([fork.id]); + expect(deleteQueuedThreadMessage(db, noopNotifier, saved.id)).toBe(true); + expect(getThread(db, fork.id)).toMatchObject({ sourceThreadId: source.id, deletedAt: null, status: "pending" }); + expect(listEvents(db, { threadId: fork.id })).toEqual(before); + expect(list(["draft"])).toEqual([]); + expect(list(["active"])).toContain(fork.id); + } finally { + db.$client.close(); + } + }); +}); diff --git a/packages/domain/src/thread.ts b/packages/domain/src/thread.ts index 405edfee51a..e13e8ff1a20 100644 --- a/packages/domain/src/thread.ts +++ b/packages/domain/src/thread.ts @@ -437,7 +437,12 @@ export const threadQueuedWorkValues = ["none", "waiting", "failed"] as const; export const threadQueuedWorkSchema = z.enum(threadQueuedWorkValues); export type ThreadQueuedWork = z.infer; +export const threadLifecycleValues = ["active", "draft", "archived"] as const; +export const threadLifecycleSchema = z.enum(threadLifecycleValues); +export type ThreadLifecycle = z.infer; + export const threadListEntrySchema = threadWithRuntimeSchema.extend({ + lifecycle: threadLifecycleSchema, activity: threadActivityStateSchema, queuedWork: threadQueuedWorkSchema, pinSortKey: z.string().nullable(), diff --git a/packages/sdk/src/areas/threads.ts b/packages/sdk/src/areas/threads.ts index 292420f4382..33dbfc683a6 100644 --- a/packages/sdk/src/areas/threads.ts +++ b/packages/sdk/src/areas/threads.ts @@ -11,6 +11,7 @@ import { type QueuedMessageWaitHolder, type ThreadQueuedMessage, type ThreadStatus, + type ThreadLifecycle, validatePluginMetadata, } from "@bb/domain"; import { @@ -84,6 +85,7 @@ export const DEFAULT_THREAD_WAIT_POLL_INTERVAL_MS = 250; export interface ThreadListArgs { archived?: boolean; + lifecycles?: readonly ThreadLifecycle[]; environmentId?: string; sectionId?: string; hasParent?: boolean; @@ -99,7 +101,11 @@ export interface ThreadListArgs { unsectioned?: boolean; } -export interface ThreadSearchArgs extends ThreadSearchQuery { +export interface ThreadSearchArgs extends Omit< + ThreadSearchQuery, + "lifecycles" +> { + lifecycles?: readonly ThreadLifecycle[]; signal?: AbortSignal; } @@ -633,6 +639,9 @@ function listQuery(args: ThreadListArgs | undefined): ThreadListQuery { ...(args?.archived === undefined ? {} : { archived: args.archived ? "true" : "false" }), + ...(args?.lifecycles === undefined + ? {} + : { lifecycles: args.lifecycles.join(",") }), ...(args?.unsectioned === undefined ? {} : { unsectioned: args.unsectioned ? "true" : "false" }), @@ -769,6 +778,9 @@ function searchQuery(args: ThreadSearchArgs): ThreadSearchQuery { return { limitPerGroup: args.limitPerGroup, query: args.query, + ...(args.lifecycles === undefined + ? {} + : { lifecycles: args.lifecycles.join(",") }), }; } diff --git a/packages/sdk/test/sdk.test.ts b/packages/sdk/test/sdk.test.ts index e5fe470a80d..a613f52afc9 100644 --- a/packages/sdk/test/sdk.test.ts +++ b/packages/sdk/test/sdk.test.ts @@ -1312,6 +1312,40 @@ describe("@bb/sdk", () => { }); }); + it("opts into lifecycle filtering without changing legacy list and search requests", async () => { + const queue = createFetchQueue( + Array.from({ length: 4 }, () => ({ body: [] })), + ); + const sdk = createBbSdk({ + transport: createHttpTransport({ + baseUrl: "http://bb.test", + fetch: queue.fetch, + runtime: "node", + }), + }); + + await sdk.threads.list(); + await sdk.threads.search({ query: "release" }); + await sdk.threads.list({ lifecycles: ["draft", "archived"], limit: 5 }); + await sdk.threads.search({ + query: "release", + lifecycles: ["draft"], + limitPerGroup: 3, + }); + + expect( + queue.requests.map(({ url }) => { + const parsed = new URL(url); + return Object.fromEntries(parsed.searchParams); + }), + ).toEqual([ + {}, + { query: "release" }, + { lifecycles: "draft,archived", limit: "5" }, + { query: "release", lifecycles: "draft", limitPerGroup: "3" }, + ]); + }); + it("forwards every public permission mode through thread surfaces", async () => { const queue = createFetchQueue([ { body: { id: "thr_auto" }, status: 201 }, diff --git a/packages/server-contract/src/api/threads.ts b/packages/server-contract/src/api/threads.ts index 6e3b33f2dd4..ed9725d44ae 100644 --- a/packages/server-contract/src/api/threads.ts +++ b/packages/server-contract/src/api/threads.ts @@ -23,6 +23,7 @@ import { threadCreateOriginSchema, threadOriginKindSchema, threadListEntrySchema, + threadLifecycleSchema, threadQueuedMessageSchema, threadSearchSourceKindSchema, threadStatusSchema, @@ -476,6 +477,7 @@ export const threadSearchResultGroupSchema = z export const threadSearchResponseSchema = z .object({ active: threadSearchResultGroupSchema, + draft: threadSearchResultGroupSchema.optional(), archived: threadSearchResultGroupSchema, }) .strict(); @@ -751,7 +753,18 @@ export type ThreadArchiveAllResponse = z.infer< typeof threadArchiveAllResponseSchema >; +const threadLifecyclesQuerySchema = z + .string() + .refine( + (value) => + value + .split(",") + .every((entry) => threadLifecycleSchema.safeParse(entry).success), + { message: "Invalid lifecycles" }, + ); + export const threadListQuerySchema = z.object({ + lifecycles: threadLifecyclesQuerySchema.optional(), projectId: z.string().min(1).optional(), environmentId: z.string().min(1).optional(), parentThreadId: z.string().min(1).optional(), @@ -853,6 +866,7 @@ export const threadRunningResponseSchema = z.array(threadRunningEntrySchema); export type ThreadRunningResponse = z.infer; export const threadSearchQuerySchema = z.object({ + lifecycles: threadLifecyclesQuerySchema.optional(), query: z.string().trim().min(2), limitPerGroup: z.string().regex(/^\d+$/).optional(), }); diff --git a/packages/server-contract/test/contract.test.ts b/packages/server-contract/test/contract.test.ts index 602d1dae150..0ed17717701 100644 --- a/packages/server-contract/test/contract.test.ts +++ b/packages/server-contract/test/contract.test.ts @@ -1224,6 +1224,7 @@ describe("server-contract canonical schemas", () => { environmentIsWorktree: true, environmentWorkspaceDisplayKind: "managed-worktree", queuedWork: "none", + lifecycle: "active", }, ]), ).toMatchObject([ diff --git a/packages/templates/src/templates/bb-guide-threads.md b/packages/templates/src/templates/bb-guide-threads.md index 0329c745ac1..42884a18692 100644 --- a/packages/templates/src/templates/bb-guide-threads.md +++ b/packages/templates/src/templates/bb-guide-threads.md @@ -51,6 +51,7 @@ Spawning: --section Create the thread in a section --visibility visible or hidden; a child inherits its parent by default --send-at Dispatch the first message at an ISO 8601 timestamp or a duration from now (30s, 10m, 2h, 7d) + --draft Save the first message until you send it manually --file CLI-local absolute path, file: URL, or uploaded file path --image CLI-local absolute path, file: URL, or uploaded image path --origin-kind Create a fork thread @@ -82,6 +83,11 @@ Spawning: machine resolution is unchanged. Omit --base-branch for bb's default. Explicit values are exact; use origin/ for a remote ref. + --draft uses the built-in Drafts plugin to hold the first queued message. + The thread stays pending without starting a turn or provisioning its workspace. + Missing, disabled, or unavailable Drafts rejects the save instead of starting work. + Inspect or edit it with bb thread queue list/update; send it with queue send. + Deleting the queued message preserves its thread and any fork history. Before selecting a provider, run `bb environment providers --project --machine ` to see whether it is available, needs setup, or is unavailable and why. The first-party providers are Project checkout, @@ -154,6 +160,7 @@ Listing: --environment Filter by environment --parent-thread Filter by parent thread --archived Show only archived threads + --lifecycle Filter by active, draft, or archived (comma-separated) --section Filter by section --unsectioned Show only threads outside sections --include-hidden Include hidden threads @@ -165,6 +172,13 @@ Listing: bb thread search [--limit <1-50>] Search threads and messages + --lifecycle Filter by active, draft, or archived (comma-separated) + + Lifecycle draft means an unarchived pending thread held by Drafts. Saved + follow-ups on an established thread do not change its lifecycle. Archived + takes precedence. Omit --lifecycle to retain the existing list/search groups; + with it, search also returns a draft group. --archived intersects the lifecycle + filter when both are given. SDK list/search accept lifecycles as an array. bb thread history List prompt history bb thread count Count threads without listing them @@ -260,6 +274,7 @@ Messaging: --reasoning-level Reasoning level override --plan Send the message as the provider's /plan action --send-at Dispatch at an ISO 8601 timestamp or a duration from now (30s, 10m, 2h, 7d) + --draft Save a follow-up until you send it manually; implies queue mode --file CLI-local absolute path, file: URL, or uploaded file path --image CLI-local absolute path, file: URL, or uploaded image path @@ -276,6 +291,13 @@ Messaging: its `id`, `waitingOn`, and `sendAt`. A deferred message waits for a thread that failed while it was deferred, and delivers when the thread is retried. + --draft saves a held queue row even while a turn is running. It cannot be + combined with --mode steer or auto. SDK callers pass + pluginSubmission: { pluginId: "drafts", data: { kind: "draft" } } to + threads.spawn or threads.send; follow-up saves use mode: "queue-if-active". + Save-only requests require the Drafts plugin to be available. A saved + follow-up keeps its established thread active; it does not become a draft thread. + --plan sends the same structured /plan command the composer's plan action sends, so the agent proposes a plan for approval before executing (Claude Code and Codex threads). Plain "/plan ..." text is not recognized; it reaches diff --git a/packages/test-helpers/src/domain-fixtures.ts b/packages/test-helpers/src/domain-fixtures.ts index 2e17d4e52ab..44a55bc1737 100644 --- a/packages/test-helpers/src/domain-fixtures.ts +++ b/packages/test-helpers/src/domain-fixtures.ts @@ -186,6 +186,7 @@ export function makeThreadListEntry( environmentIsWorktree: null, environmentWorkspaceDisplayKind: "other", queuedWork: "none", + lifecycle: overrides.archivedAt != null ? "archived" : "active", }; return { ...entry, diff --git a/plugins/bb-guide/skills/bb-cli/references/thread-creation.md b/plugins/bb-guide/skills/bb-cli/references/thread-creation.md index bbe137475bd..c35436392ba 100644 --- a/plugins/bb-guide/skills/bb-cli/references/thread-creation.md +++ b/plugins/bb-guide/skills/bb-cli/references/thread-creation.md @@ -10,6 +10,10 @@ current thread's project ID to add. Omitted execution flags use remembered project defaults; without a remembered model, bb resolves the selected provider and its reported default model on the target machine. +- Add `--draft` to save the first message without starting a turn or provisioning + its environment. Drafts must be available; a failed save never falls back to + starting work. Use `bb thread queue list/update/send` to inspect, edit, or send + it. Queue deletion removes only the message, preserving the owning thread. - Select a target with `--environment`, `--new-environment`, `--base-branch`, or `--machine`. Select execution with `--provider`, `--model`, `--reasoning-level`, `--service-tier`, and `--permission-mode`. diff --git a/plugins/bb-guide/skills/bb-cli/references/thread-operation.md b/plugins/bb-guide/skills/bb-cli/references/thread-operation.md index 88984cd9adf..2f0dd120ee7 100644 --- a/plugins/bb-guide/skills/bb-cli/references/thread-operation.md +++ b/plugins/bb-guide/skills/bb-cli/references/thread-operation.md @@ -42,6 +42,12 @@ scheduled tell neither sends nor runs. Both report `delivery: "queued"` and dispatch on the sweep after the requested time. The SDK equivalent is `sendAt` (epoch ms) on `threads.spawn` / `threads.send`. +- Add `--draft` to `bb thread spawn` or `bb thread tell` to save a message until + manual Send now. Follow-up saves imply queue mode and reject explicit steer + or auto mode. SDK callers use `pluginSubmission: { pluginId: "drafts", data: + { kind: "draft" } }` on `threads.spawn` or `threads.send`; use + `mode: "queue-if-active"` for a follow-up. Drafts must be installed, enabled, + and available or the server rejects the save before starting any work. - `bb thread queue list` shows a Sender for agent threads and system notices. SDK queue rows and `--json` include `initiator` and nullable `senderThreadId`. - A send that cannot run right now does not fail: it joins the thread's queue @@ -96,6 +102,12 @@ hostId, providerId, projectId, parentThreadId, groupBy })`. ## Inspecting Results +- Add `--lifecycle active,draft,archived` to thread list/search to select any + nonempty subset. Draft means pending with a Drafts-held first message; + established threads with saved follow-ups stay active. Archived takes + precedence. Omission retains legacy groups; opt-in search adds a draft group. + List `--archived` intersects this filter. SDK list/search accept `lifecycles` + as an array. Filtering precedes result limits and counts. - Use `bb thread search [--limit <1-50>]` for sidebar search. Use `history`, `read|unread`, and `section` for organization and recall. The `bb thread queue` group contains the queued-message operations. Queue updates From 597c10e7e03035fb88aea70563d30ec08b1ac1d7 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 18 Sep 2026 22:56:25 -0400 Subject: [PATCH 02/57] test: align lifecycle projection fixtures and contract allowlist --- apps/demo-server/src/fixtures/world.ts | 1 + packages/server-contract/test/contract.test.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/demo-server/src/fixtures/world.ts b/apps/demo-server/src/fixtures/world.ts index b1060de990d..5014f6b5fdc 100644 --- a/apps/demo-server/src/fixtures/world.ts +++ b/apps/demo-server/src/fixtures/world.ts @@ -84,6 +84,7 @@ export function threadListEntry( environmentIsWorktree: null, environmentWorkspaceDisplayKind: "other", queuedWork: "none", + lifecycle: "active", }; } diff --git a/packages/server-contract/test/contract.test.ts b/packages/server-contract/test/contract.test.ts index 0ed17717701..61faa28157a 100644 --- a/packages/server-contract/test/contract.test.ts +++ b/packages/server-contract/test/contract.test.ts @@ -506,6 +506,7 @@ const OPTIONAL_SERVER_FIELD_GROUPS: readonly OptionalServerFieldGroup[] = [ "threadListQuerySchema.limit", "threadListQuerySchema.hasParent", "threadListQuerySchema.includeHidden", + "threadListQuerySchema.lifecycles", "threadListQuerySchema.offset", "threadListQuerySchema.originKind", "threadListQuerySchema.originPluginId", From 75e350710b26d0bbc6bdb5a4d43c930e25be5f88 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 18 Sep 2026 23:00:03 -0400 Subject: [PATCH 03/57] test: align CLI assertions with existing search and logging contracts --- .../src/__tests__/command-output/thread-organization.test.ts | 2 +- apps/cli/src/__tests__/command-output/thread-spawn.test.ts | 4 +++- packages/sdk/test/sdk.test.ts | 2 +- packages/templates/src/templates/bb-guide-json.md | 4 ++-- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/cli/src/__tests__/command-output/thread-organization.test.ts b/apps/cli/src/__tests__/command-output/thread-organization.test.ts index 165447871d8..76db1d88c92 100644 --- a/apps/cli/src/__tests__/command-output/thread-organization.test.ts +++ b/apps/cli/src/__tests__/command-output/thread-organization.test.ts @@ -64,7 +64,7 @@ describe("bb thread organization commands", () => { ); expect(search).toHaveBeenCalledWith({ - query: { query: "release", lifecycles: "draft", limitPerGroup: 3 }, + query: { query: "release", lifecycles: "draft", limitPerGroup: "3" }, }); }); diff --git a/apps/cli/src/__tests__/command-output/thread-spawn.test.ts b/apps/cli/src/__tests__/command-output/thread-spawn.test.ts index 813bce58307..faa8279361e 100644 --- a/apps/cli/src/__tests__/command-output/thread-spawn.test.ts +++ b/apps/cli/src/__tests__/command-output/thread-spawn.test.ts @@ -53,7 +53,9 @@ describe("bb thread spawn command output", () => { input: [{ type: "text", text: "Save this", mentions: [] }], }), }); - expect(collectLogLines()[0]).toBe("Draft saved: thread-draft"); + expect(collectLogLines(vi.mocked(console.log))[0]).toBe( + "Draft saved: thread-draft", + ); }); it("rejects explicitly empty lifecycle ownership instead of creating an independent thread", async () => { diff --git a/packages/sdk/test/sdk.test.ts b/packages/sdk/test/sdk.test.ts index a613f52afc9..36b689d23f6 100644 --- a/packages/sdk/test/sdk.test.ts +++ b/packages/sdk/test/sdk.test.ts @@ -1330,7 +1330,7 @@ describe("@bb/sdk", () => { await sdk.threads.search({ query: "release", lifecycles: ["draft"], - limitPerGroup: 3, + limitPerGroup: "3", }); expect( diff --git a/packages/templates/src/templates/bb-guide-json.md b/packages/templates/src/templates/bb-guide-json.md index 02162dd6e2e..7dedda70403 100644 --- a/packages/templates/src/templates/bb-guide-json.md +++ b/packages/templates/src/templates/bb-guide-json.md @@ -38,7 +38,7 @@ Fields beyond those shown exist; these are the ones scripts use. {project: {id, name} | null, thread: {id, status, title, parentThreadId, environment: {hostId, display} | null} | null, childThreads: [{id, status, title}] | null, pendingTodos, pluginsNeedingAttention: [{id, status}], dataDir} bb thread list --json - [{id, projectId, environmentId, providerId, title, status, parentThreadId, sectionId, visibility, archivedAt, pinnedAt, createdAt, updatedAt, activity}] (bare array; title can be null) + [{id, projectId, environmentId, providerId, title, status, lifecycle, parentThreadId, sectionId, visibility, archivedAt, pinnedAt, createdAt, updatedAt, activity}] (bare array; title can be null) bb thread show --json {thread: {id, status, title, projectId, environmentId, parentThreadId, ...}, environment: {id, hostId, path, branchName, ...} | null, pendingTodos} (thread fields are under .thread) @@ -62,7 +62,7 @@ Fields beyond those shown exist; these are the ones scripts use. {total} bb thread search --json - {active: {total, results}, archived: {total, results}} + {active: {total, results}, archived: {total, results}, draft?: {total, results}} (draft group present with --lifecycle) bb thread section list --json [{id, name, createdAt, updatedAt}] From 759f542539b5b1926668e9b42cc61773ed618acb Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 18 Sep 2026 23:04:09 -0400 Subject: [PATCH 04/57] Add sidebar thread lifecycle filters --- .../src/components/sidebar/ProjectList.tsx | 163 +++++++++-------- .../sidebar/SidebarThreadLifecycles.test.tsx | 169 ++++++++++++++++++ .../sidebar/SidebarThreadLifecycles.tsx | 115 ++++++++++++ .../sidebar/sidebarCollapsedAtoms.ts | 4 + .../thread/ThreadLifecycleFilter.test.tsx | 58 ++++++ .../thread/ThreadLifecycleFilter.tsx | 87 +++++++++ .../app/src/hooks/cache-owners/query-cache.ts | 17 +- .../thread-lifecycle-cache.test.ts | 115 ++++++++++++ .../thread-runtime-cache-owner.ts | 1 + .../src/hooks/queries/thread-queries.test.tsx | 34 ++++ docs/configuration.md | 8 + packages/domain/src/ui-preferences.ts | 14 ++ packages/domain/test/ui-preferences.test.ts | 29 +++ .../src/templates/bb-guide-customization.md | 6 + .../skills/bb-cli/references/app-settings.md | 5 + 15 files changed, 754 insertions(+), 71 deletions(-) create mode 100644 apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx create mode 100644 apps/app/src/components/sidebar/SidebarThreadLifecycles.tsx create mode 100644 apps/app/src/components/thread/ThreadLifecycleFilter.test.tsx create mode 100644 apps/app/src/components/thread/ThreadLifecycleFilter.tsx create mode 100644 apps/app/src/hooks/cache-owners/thread-lifecycle-cache.test.ts create mode 100644 packages/domain/test/ui-preferences.test.ts diff --git a/apps/app/src/components/sidebar/ProjectList.tsx b/apps/app/src/components/sidebar/ProjectList.tsx index aa5a6de97c0..d6eba84f72d 100644 --- a/apps/app/src/components/sidebar/ProjectList.tsx +++ b/apps/app/src/components/sidebar/ProjectList.tsx @@ -26,6 +26,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 { SidebarThreadLifecycles } from "./SidebarThreadLifecycles"; import { useSystemConfig } from "@/hooks/queries/system-queries"; import { useReorderPinnedThread } from "@/hooks/mutations/thread-state-mutations"; import { @@ -1325,7 +1326,7 @@ function ProjectListComponent({ () => sidebarNavigation?.projects.map(stripProjectThreads), [sidebarNavigation], ); - const threads = useMemo(() => { + const unarchivedThreads = useMemo(() => { if (!sidebarNavigation) { return []; } @@ -1336,6 +1337,14 @@ function ProjectListComponent({ sidebarThreads.push(...sidebarNavigation.personalProject.threads); return sidebarThreads; }, [sidebarNavigation]); + const threads = useMemo( + () => unarchivedThreads.filter((thread) => thread.lifecycle === "active"), + [unarchivedThreads], + ); + const savedDrafts = useMemo( + () => unarchivedThreads.filter((thread) => thread.lifecycle === "draft"), + [unarchivedThreads], + ); const draftThreadIds = usePromptDraftInputThreadIds(threads); const titleMentionResources = useThreadTitleMentionResources(); const uiPreferencesReady = useUiPreferencesReady(); @@ -1715,71 +1724,23 @@ function ProjectListComponent({ }} > - ( - - )} - renderChronological={() => ( - <> - - - )} - renderProject={() => ( - <> - + ( + - - )} - /> + )} + renderChronological={() => ( + <> + + + )} + renderProject={() => ( + <> + + + )} + /> + {sectionCreateDialog} {sectionRenameDialogContent} diff --git a/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx b/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx new file mode 100644 index 00000000000..ec6e6c846aa --- /dev/null +++ b/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx @@ -0,0 +1,169 @@ +// @vitest-environment jsdom + +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; +import { createStore, Provider } from "jotai"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ThreadLifecycle } from "@bb/domain"; +import { TooltipProvider } from "@bb/shared-ui/tooltip"; +import { makeThreadListEntry } from "@bb/test-helpers/domain-fixtures"; +import { SidebarThreadLifecycles } from "./SidebarThreadLifecycles"; +import { sidebarThreadLifecyclesAtom } from "./sidebarCollapsedAtoms"; + +const archiveQuery = vi.hoisted(() => ({ + fetchNextPage: vi.fn(), + enabled: false, +})); + +vi.mock("@/hooks/queries/thread-queries", () => ({ + useArchivedThreads: (_filters: object, { enabled }: { enabled: boolean }) => { + archiveQuery.enabled = enabled; + return { + data: { + pages: [ + [ + makeThreadListEntry({ + id: "archived-thread", + title: "Archived work", + lifecycle: "archived", + archivedAt: 1, + }), + ], + ], + }, + isFetching: false, + isLoadingError: false, + error: null, + hasNextPage: true, + isFetchingNextPage: false, + isFetchNextPageError: false, + fetchNextPage: archiveQuery.fetchNextPage, + }; + }, +})); + +vi.mock("@/hooks/useServerConnectionState", () => ({ + useServerConnectionState: () => "connected", +})); +vi.mock("@/hooks/useThreadSplitsEnabled", () => ({ + useThreadSplitsEnabled: () => false, +})); +vi.mock("@/hooks/usePromptDraftStorage", () => ({ + usePromptDraftHasInput: () => false, + usePromptDraftInputThreadIds: () => new Set(), +})); +vi.mock("@/components/thread/ThreadActionsProvider", () => ({ + useThreadActions: () => ({ + renameThread: vi.fn(), + requestRename: vi.fn(), + requestDelete: vi.fn(), + archiveThreadAndChildren: vi.fn(), + unarchiveThread: vi.fn(), + togglePin: vi.fn(), + toggleRead: vi.fn(), + }), +})); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +function setup(lifecycles: ThreadLifecycle[] = ["active"], empty = false) { + const store = createStore(); + store.set(sidebarThreadLifecyclesAtom, lifecycles); + render( + + + + 0, + collapsedThreadIds: new Set(), + collapsedEnvironmentIds: new Set(), + onToggleThreadCollapsed: vi.fn(), + onToggleEnvironmentCollapsed: vi.fn(), + }} + > +
Active hierarchy
+
+
+
+
, + ); + return store; +} + +describe("sidebar lifecycle groups", () => { + it.each<{ lifecycles: ThreadLifecycle[] }>( + ([ + ["active"], + ["draft"], + ["archived"], + ["active", "draft"], + ["active", "archived"], + ["draft", "archived"], + ["active", "draft", "archived"], + ] satisfies ThreadLifecycle[][]).map((lifecycles) => ({ lifecycles })), + )("shows only selected semantic groups for $lifecycles", ({ lifecycles }) => { + setup(lifecycles); + expect(screen.queryByText("Active hierarchy") !== null).toBe( + lifecycles.includes("active"), + ); + expect(screen.queryByText("Saved work") !== null).toBe( + lifecycles.includes("draft"), + ); + expect(screen.queryByText("Archived work") !== null).toBe( + lifecycles.includes("archived"), + ); + expect( + screen.getAllByRole("heading").map((heading) => heading.textContent), + ).toEqual( + ["Active", "Drafts", "Archived"].filter((_, index) => + lifecycles.includes((["active", "draft", "archived"] as const)[index]!), + ), + ); + expect(archiveQuery.enabled).toBe(lifecycles.includes("archived")); + }); + + it("starts and stops archived paging when the synced preference changes", () => { + const store = setup(); + expect(archiveQuery.enabled).toBe(false); + act(() => store.set(sidebarThreadLifecyclesAtom, ["archived"])); + expect(archiveQuery.enabled).toBe(true); + fireEvent.click( + screen.getByRole("button", { name: "Load more archived threads" }), + ); + expect(archiveQuery.fetchNextPage).toHaveBeenCalledOnce(); + act(() => store.set(sidebarThreadLifecyclesAtom, ["draft"])); + expect(archiveQuery.enabled).toBe(false); + expect(screen.queryByText("Archived work")).toBeNull(); + }); + + it("reuses the no-threads state for an empty selected group", () => { + setup(["draft"], true); + expect(screen.getByText("No threads")).toBeDefined(); + expect(screen.getByRole("heading", { name: "Drafts" })).toBeDefined(); + }); +}); diff --git a/apps/app/src/components/sidebar/SidebarThreadLifecycles.tsx b/apps/app/src/components/sidebar/SidebarThreadLifecycles.tsx new file mode 100644 index 00000000000..9ef6bd3103b --- /dev/null +++ b/apps/app/src/components/sidebar/SidebarThreadLifecycles.tsx @@ -0,0 +1,115 @@ +import { useId, type ComponentProps, type ReactNode } from "react"; +import { useAtom } from "jotai"; +import type { ThreadListEntry } from "@bb/domain"; +import { Button } from "@bb/shared-ui/button"; +import { ThreadLifecycleFilter } from "@/components/thread/ThreadLifecycleFilter"; +import { useArchivedThreads } from "@/hooks/queries/thread-queries"; +import { + useConnectionAwareQueryState, + type ConnectionAwareQueryStatus, +} from "@/hooks/queries/connection-aware-query-state"; +import { isTransientReadError } from "@/hooks/queries/query-helpers"; +import { ProjectThreadTree } from "./ProjectRow"; +import { sidebarThreadLifecyclesAtom } from "./sidebarCollapsedAtoms"; + +function LifecycleGroup({ + label, + children, +}: { + label: string; + children: ReactNode; +}) { + const headingId = useId(); + return ( +
+

+ {label} +

+ {children} +
+ ); +} + +export function SidebarThreadLifecycles({ + children, + drafts, + status, + treeProps, +}: { + children: ReactNode; + drafts: ThreadListEntry[]; + status: ConnectionAwareQueryStatus; + treeProps: Omit< + ComponentProps, + "threadListState" | "variant" | "progressiveDisclosureEnabled" + >; +}) { + const [lifecycles, setLifecycles] = useAtom(sidebarThreadLifecyclesAtom); + const archived = useArchivedThreads( + {}, + { enabled: lifecycles.includes("archived") }, + ); + const archivedState = useConnectionAwareQueryState({ + hasResolvedData: archived.data !== undefined, + isFetching: archived.isFetching, + isLoadingError: archived.isLoadingError, + isRecoverableLoadingError: isTransientReadError(archived.error), + }); + return ( + <> +
+ +
+ {lifecycles.includes("active") && ( + {children} + )} + {lifecycles.includes("draft") && ( + + + + )} + {lifecycles.includes("archived") && ( + + + {archived.hasNextPage && ( + + )} + + )} + + ); +} diff --git a/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts b/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts index 4cebed98a60..8f5f37e6e1d 100644 --- a/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts +++ b/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts @@ -12,6 +12,10 @@ export type { export type { SidebarChronologicalSort, SidebarOrganizationMode }; +export const sidebarThreadLifecyclesAtom = createSyncedPreferenceAtom( + "sidebar.threadLifecycles", +); + export const collapsedProjectIdsAtom = createSyncedPreferenceAtom( "sidebar.collapsedProjects", ); diff --git a/apps/app/src/components/thread/ThreadLifecycleFilter.test.tsx b/apps/app/src/components/thread/ThreadLifecycleFilter.test.tsx new file mode 100644 index 00000000000..28c39751f41 --- /dev/null +++ b/apps/app/src/components/thread/ThreadLifecycleFilter.test.tsx @@ -0,0 +1,58 @@ +// @vitest-environment jsdom + +import { useState } from "react"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ThreadLifecycle } from "@bb/domain"; +import { ThreadLifecycleFilter } from "./ThreadLifecycleFilter"; + +const viewport = vi.hoisted(() => ({ compact: false })); +vi.mock("@bb/shared-ui/hooks/use-compact-viewport", () => ({ + useIsCompactViewport: () => viewport.compact, +})); + +afterEach(() => { + cleanup(); + viewport.compact = false; +}); + +function Filter() { + const [value, onChange] = useState(["active"]); + return ; +} + +describe("ThreadLifecycleFilter", () => { + it.each([false, true])( + "keeps a nonempty selection through the responsive menu (compact=%s)", + async (compact) => { + viewport.compact = compact; + const { container } = render(); + fireEvent.keyDown( + screen.getByRole("button", { name: "Thread lifecycle: Active" }), + { key: "Enter" }, + ); + const active = await screen.findByRole("menuitemcheckbox", { + name: "Active", + }); + expect(active.getAttribute("aria-disabled")).toBe("true"); + fireEvent.click(active); + expect(active.getAttribute("aria-checked")).toBe("true"); + fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Drafts" })); + await waitFor(() => + expect(active.getAttribute("aria-disabled")).not.toBe("true"), + ); + fireEvent.click(active); + const drafts = screen.getByRole("menuitemcheckbox", { name: "Drafts" }); + expect(drafts.getAttribute("aria-checked")).toBe("true"); + expect(drafts.getAttribute("aria-disabled")).toBe("true"); + expect(container.closest("[inert]")).toBeNull(); + expect(container.closest('[aria-hidden="true"]')).toBeNull(); + }, + ); +}); diff --git a/apps/app/src/components/thread/ThreadLifecycleFilter.tsx b/apps/app/src/components/thread/ThreadLifecycleFilter.tsx new file mode 100644 index 00000000000..7699c6737b2 --- /dev/null +++ b/apps/app/src/components/thread/ThreadLifecycleFilter.tsx @@ -0,0 +1,87 @@ +import type { ThreadLifecycle } from "@bb/domain"; +import { Button } from "@bb/shared-ui/button"; +import { Icon } from "@bb/shared-ui/icon"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "@bb/shared-ui/dropdown-menu"; + +export const THREAD_LIFECYCLE_OPTIONS = [ + { value: "active", label: "Active" }, + { value: "draft", label: "Drafts" }, + { value: "archived", label: "Archived" }, +] as const satisfies readonly { value: ThreadLifecycle; label: string }[]; + +export function ThreadLifecycleFilter({ + value, + onChange, +}: { + value: readonly ThreadLifecycle[]; + onChange: (value: ThreadLifecycle[]) => void; +}) { + const label = THREAD_LIFECYCLE_OPTIONS.filter((option) => + value.includes(option.value), + ) + .map((option) => option.label) + .join(", "); + + return ( + + + + + + + Thread lifecycle + {THREAD_LIFECYCLE_OPTIONS.map((option) => { + const checked = value.includes(option.value); + const required = checked && value.length === 1; + return ( + { + event.preventDefault(); + if (required) return; + onChange( + THREAD_LIFECYCLE_OPTIONS.flatMap((candidate) => + ( + candidate.value === option.value + ? !checked + : value.includes(candidate.value) + ) + ? [candidate.value] + : [], + ), + ); + }} + > + {option.label} + + {checked && } + + + ); + })} + + + + ); +} diff --git a/apps/app/src/hooks/cache-owners/query-cache.ts b/apps/app/src/hooks/cache-owners/query-cache.ts index 7b3cb508289..5b3980511c7 100644 --- a/apps/app/src/hooks/cache-owners/query-cache.ts +++ b/apps/app/src/hooks/cache-owners/query-cache.ts @@ -611,6 +611,10 @@ export function optimisticallyInsertThread( index === existingIndex ? { ...candidate, + lifecycle: + candidate.status === "pending" && lifecycle === "draft" + ? "draft" + : candidate.lifecycle, queuedWork: candidate.queuedWork === "none" ? queuedWork @@ -746,7 +750,18 @@ export function updateCachedThreadListStatusState( return list; } return list.map((thread) => - thread.id === threadId ? { ...thread, ...statusChange } : thread, + thread.id === threadId + ? { + ...thread, + ...statusChange, + lifecycle: + thread.archivedAt !== null + ? "archived" + : statusChange.status === "pending" + ? thread.lifecycle + : "active", + } + : thread, ); }); } diff --git a/apps/app/src/hooks/cache-owners/thread-lifecycle-cache.test.ts b/apps/app/src/hooks/cache-owners/thread-lifecycle-cache.test.ts new file mode 100644 index 00000000000..378b79df22f --- /dev/null +++ b/apps/app/src/hooks/cache-owners/thread-lifecycle-cache.test.ts @@ -0,0 +1,115 @@ +import { QueryClient } from "@tanstack/react-query"; +import { describe, expect, it } from "vitest"; +import { makeThreadListEntry } from "@bb/test-helpers/domain-fixtures"; +import { makeThreadResponse } from "@/test/fixtures/thread-responses"; +import { + makeProjectWithThreadsResponse, + makeSidebarBootstrapResponse, +} from "@/test/fixtures/projects"; +import { + archivedThreadsListQueryKey, + sidebarNavigationQueryKey, +} from "../queries/query-keys"; +import { + getCachedSidebarNavigationThreads, + optimisticallyInsertThread, + updateCachedThreadListStatusState, +} from "./query-cache"; +import { applyQueuedMessageDeleteResult } from "./thread-runtime-cache-owner"; + +function setup() { + const queryClient = new QueryClient(); + queryClient.setQueryData( + sidebarNavigationQueryKey(), + makeSidebarBootstrapResponse({ + projects: [ + makeProjectWithThreadsResponse({ + id: "project-1", + threads: [ + makeThreadListEntry({ + id: "draft", + projectId: "project-1", + lifecycle: "draft", + status: "pending", + }), + ], + }), + ], + }), + ); + return queryClient; +} + +describe("sidebar lifecycle cache", () => { + it("moves a sent draft to Active on realtime status and preserves Archived priority", () => { + const queryClient = setup(); + const archivedKey = archivedThreadsListQueryKey({}); + const archived = makeThreadListEntry({ + id: "archived", + lifecycle: "archived", + archivedAt: 1, + }); + queryClient.setQueryData(archivedKey, { + pages: [[archived]], + pageParams: [0], + }); + const active = makeThreadListEntry({ status: "active" }); + const statusChange = { + status: active.status, + runtime: active.runtime, + activity: active.activity, + latestAttentionAt: active.latestAttentionAt, + updatedAt: active.updatedAt, + }; + updateCachedThreadListStatusState(queryClient, "draft", statusChange); + updateCachedThreadListStatusState(queryClient, "archived", statusChange); + expect(getCachedSidebarNavigationThreads(queryClient)[0]?.lifecycle).toBe( + "active", + ); + expect(queryClient.getQueryData(archivedKey)).toMatchObject({ + pages: [[{ lifecycle: "archived" }]], + }); + }); + + it("corrects an early pending bootstrap row when Save draft completes without reverting an admitted thread", () => { + const queryClient = setup(); + const thread = makeThreadResponse({ + id: "saved", + projectId: "project-1", + status: "pending", + }); + optimisticallyInsertThread(queryClient, thread); + optimisticallyInsertThread(queryClient, thread, "draft"); + expect( + getCachedSidebarNavigationThreads(queryClient).find( + (entry) => entry.id === "saved", + )?.lifecycle, + ).toBe("draft"); + const active = makeThreadListEntry({ status: "active" }); + updateCachedThreadListStatusState(queryClient, "saved", { + status: active.status, + runtime: active.runtime, + activity: active.activity, + latestAttentionAt: active.latestAttentionAt, + updatedAt: active.updatedAt, + }); + optimisticallyInsertThread(queryClient, thread, "draft"); + expect( + getCachedSidebarNavigationThreads(queryClient).find( + (entry) => entry.id === "saved", + )?.lifecycle, + ).toBe("active"); + }); + + it("refreshes bootstrap and archived membership after deleting a held message", () => { + const queryClient = setup(); + const archivedKey = archivedThreadsListQueryKey({}); + queryClient.setQueryData(archivedKey, { pages: [[]], pageParams: [0] }); + applyQueuedMessageDeleteResult({ queryClient, threadId: "draft" }); + expect( + queryClient.getQueryState(sidebarNavigationQueryKey())?.isInvalidated, + ).toBe(true); + expect(queryClient.getQueryState(archivedKey)?.isInvalidated).toBe(true); + expect(getCachedSidebarNavigationThreads(queryClient)[0]?.id).toBe("draft"); + }); +}); diff --git a/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.ts b/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.ts index 9fe2ebb3cb3..3c6eadf3b84 100644 --- a/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.ts +++ b/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.ts @@ -1538,6 +1538,7 @@ export function applyQueuedMessageDeleteResult({ threadId, }: ThreadIdCacheArgs): void { invalidateThreadQueueQueries({ queryClient, threadId }); + invalidateThreadListMembershipQueries({ queryClient, threadId }); } export async function beginStopThreadTransaction({ diff --git a/apps/app/src/hooks/queries/thread-queries.test.tsx b/apps/app/src/hooks/queries/thread-queries.test.tsx index a7be0d344e8..198d2d3c0f4 100644 --- a/apps/app/src/hooks/queries/thread-queries.test.tsx +++ b/apps/app/src/hooks/queries/thread-queries.test.tsx @@ -342,6 +342,40 @@ describe("useThreadDetailBootstrap", () => { }); describe("useArchivedThreads", () => { + it("fetches pages only while selected and continues from the loaded offset", async () => { + const { queryClient, wrapper } = createQueryClientTestHarness(); + vi.mocked(sdk.threads.list) + .mockResolvedValueOnce( + Array.from({ length: ARCHIVED_THREADS_PAGE_SIZE }, (_, index) => + makeThreadListEntry({ + id: `archived-${index}`, + lifecycle: "archived", + archivedAt: 1, + }), + ), + ) + .mockResolvedValueOnce([]); + const { result, rerender } = renderHook( + ({ enabled }) => useArchivedThreads({}, { enabled }), + { wrapper, initialProps: { enabled: false } }, + ); + expect(sdk.threads.list).not.toHaveBeenCalled(); + rerender({ enabled: true }); + await waitFor(() => expect(result.current.hasNextPage).toBe(true)); + await act(async () => { + await result.current.fetchNextPage(); + }); + expect(vi.mocked(sdk.threads.list).mock.calls[1]?.[0]?.offset).toBe( + ARCHIVED_THREADS_PAGE_SIZE, + ); + expect(result.current.hasNextPage).toBe(false); + rerender({ enabled: false }); + await act(async () => { + await queryClient.invalidateQueries(); + }); + expect(sdk.threads.list).toHaveBeenCalledTimes(2); + }); + it("loads archived threads across all projects when no scope is selected", async () => { const { wrapper } = createQueryClientTestHarness(); diff --git a/docs/configuration.md b/docs/configuration.md index 2c7d83de432..50405c4ae10 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -681,6 +681,7 @@ client wrote first, so a stale window cannot silently clobber a newer value. | Key | Value | | --------------------------------- | --------------------------------------------------- | | `sidebar.organizationMode` | `project`, `chronological`, or `machine` | +| `sidebar.threadLifecycles` | Nonempty distinct list of `active`, `draft`, `archived` | | `sidebar.threadGrouping.environment` | `auto`, `true`, or `false` | | `sidebar.chronologicalSort` | `updated`, `created`, `alpha`, or `none` | | `sidebar.sectionOrder` | Section id list for **By project** | @@ -702,6 +703,13 @@ client wrote first, so a stale window cannot silently clobber a newer value. Custom (`chronological`) is the default for `sidebar.organizationMode` when no value is saved. Existing server and legacy browser choices are preserved. +The built-in sidebar defaults to Active. `sidebar.threadLifecycles` selects +named Active, Drafts, and Archived groups while preserving Active's organization. +Drafts come from the available unarchived bootstrap; Archived loads pages only +while selected. For example, `bb settings ui set sidebar.threadLifecycles +'["active","draft"]'` shows active and saved draft threads. Reset restores +`["active"]`. Plugin sidebar replacements own their rendering. + `sidebar.threadGrouping.environment` decides whether two or more sibling threads that share one worktree environment collapse into a single worktree row inside their section. `true` groups them and `false` keeps every thread on its own row, diff --git a/packages/domain/src/ui-preferences.ts b/packages/domain/src/ui-preferences.ts index 5ec43f9aff4..e5261af26c6 100644 --- a/packages/domain/src/ui-preferences.ts +++ b/packages/domain/src/ui-preferences.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { threadLifecycleSchema } from "./thread.js"; const UI_PREFERENCE_STRING_MAX_LENGTH = 1_024; const UI_PREFERENCE_LIST_MAX_LENGTH = 10_000; @@ -36,6 +37,7 @@ const uiPreferenceStringListSchema = z .max(UI_PREFERENCE_LIST_MAX_LENGTH); export const UI_PREFERENCE_KEYS = [ + "sidebar.threadLifecycles", "sidebar.organizationMode", "sidebar.threadGrouping.environment", "sidebar.chronologicalSort", @@ -78,6 +80,18 @@ function defineUiPreference( } export const uiPreferenceDefinitions = { + "sidebar.threadLifecycles": defineUiPreference( + z + .array(threadLifecycleSchema) + .min(1) + .max(3) + .refine( + (values) => new Set(values).size === values.length, + "Thread lifecycles must be unique.", + ), + ["active"], + "Thread lifecycles shown in the built-in sidebar: active, draft, and archived. Select at least one; defaults to active.", + ), "sidebar.organizationMode": defineUiPreference( sidebarOrganizationModeSchema, "chronological", diff --git a/packages/domain/test/ui-preferences.test.ts b/packages/domain/test/ui-preferences.test.ts new file mode 100644 index 00000000000..d59b91dd9c9 --- /dev/null +++ b/packages/domain/test/ui-preferences.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { + getUiPreferenceDefault, + parseUiPreferenceValue, +} from "../src/ui-preferences.js"; + +describe("sidebar lifecycle preference", () => { + it("defaults to Active and accepts a nonempty distinct lifecycle selection", () => { + expect(getUiPreferenceDefault("sidebar.threadLifecycles")).toEqual([ + "active", + ]); + expect( + parseUiPreferenceValue("sidebar.threadLifecycles", ["draft", "archived"]), + ).toEqual({ + success: true, + value: ["draft", "archived"], + }); + }); + + it.each( + [[], ["draft", "draft"], ["unknown"], "active", null].map((value) => ({ + value, + })), + )("rejects invalid selection $value", ({ value }) => { + expect( + parseUiPreferenceValue("sidebar.threadLifecycles", value).success, + ).toBe(false); + }); +}); diff --git a/packages/templates/src/templates/bb-guide-customization.md b/packages/templates/src/templates/bb-guide-customization.md index 800584f7c3a..82bbc7b10de 100644 --- a/packages/templates/src/templates/bb-guide-customization.md +++ b/packages/templates/src/templates/bb-guide-customization.md @@ -283,6 +283,12 @@ once on a conflict. `reset` writes the default. The SDK offers Custom (`chronological`) is the default for `sidebar.organizationMode` when no value is saved. Existing server and legacy browser choices are preserved. +`sidebar.threadLifecycles` is a nonempty distinct list of `active`, `draft`, +and `archived`, defaulting to `["active"]`. The built-in sidebar shows named +groups and fetches archived pages only while selected. For example: +`bb settings ui set sidebar.threadLifecycles '["active","draft"]'`. +Reset the key to restore Active. Plugin sidebar replacements own their filters. + Every thread-list header's actions menu offers New project, New section, Organize, and Sort by. Organize selects By project, By machine, or Custom, and its By environment toggle decides whether sibling threads sharing one worktree diff --git a/plugins/bb-guide/skills/bb-cli/references/app-settings.md b/plugins/bb-guide/skills/bb-cli/references/app-settings.md index 4d7eb501389..4724f269d88 100644 --- a/plugins/bb-guide/skills/bb-cli/references/app-settings.md +++ b/plugins/bb-guide/skills/bb-cli/references/app-settings.md @@ -19,6 +19,11 @@ every window and client sees the same value. orders, the collapsed-id lists, `sidebar.pluginPanelOrder`, `sidebar.visiblePluginPanels`, `sidebar.navigationProvider`, `sidebar.threadListProvider`). +- `sidebar.threadLifecycles` selects a nonempty distinct list of `active`, + `draft`, and `archived` in the built-in sidebar. Default/reset is `["active"]`. + Use `bb settings ui set sidebar.threadLifecycles '["active","draft"]'` to + show active and saved draft groups. Archived pages load only while selected; + plugin sidebar replacements keep ownership of their rendering. - `sidebar.organizationMode` defaults to Custom (`chronological`) when unset; existing server and legacy browser choices are preserved. - `sidebar.threadGrouping.environment` decides whether sibling threads sharing From 9cdd061f528718aba9eb11fbd432b4586b327d05 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 18 Sep 2026 23:06:17 -0400 Subject: [PATCH 05/57] test: supply required identity in draft CLI fixture --- apps/cli/src/__tests__/command-output/thread-spawn.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/__tests__/command-output/thread-spawn.test.ts b/apps/cli/src/__tests__/command-output/thread-spawn.test.ts index faa8279361e..a96f1cddd62 100644 --- a/apps/cli/src/__tests__/command-output/thread-spawn.test.ts +++ b/apps/cli/src/__tests__/command-output/thread-spawn.test.ts @@ -30,7 +30,12 @@ describe("bb thread spawn command output", () => { it("saves the first message with the shipped Drafts submission", async () => { const post = vi.fn(async ({ json }: { json: unknown }) => { createThreadRequestSchema.parse(json); - return fixtures.makeThread({ id: "thread-draft", status: "pending" }); + return fixtures.makeThread({ + id: "thread-draft", + projectId: "proj-1", + providerId: "codex", + status: "pending", + }); }); stubServerApi({ "v1.threads.$post": post }); From f7054df279b9b41ca5bdd41ddbf944f45fd6d8e2 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 18 Sep 2026 23:07:12 -0400 Subject: [PATCH 06/57] Keep lifecycle filtering nonmodal and await query updates --- .../components/thread/ThreadLifecycleFilter.test.tsx | 12 ++++++++---- .../src/components/thread/ThreadLifecycleFilter.tsx | 2 +- apps/app/src/hooks/queries/thread-queries.test.tsx | 2 +- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/app/src/components/thread/ThreadLifecycleFilter.test.tsx b/apps/app/src/components/thread/ThreadLifecycleFilter.test.tsx index 28c39751f41..541d63f805e 100644 --- a/apps/app/src/components/thread/ThreadLifecycleFilter.test.tsx +++ b/apps/app/src/components/thread/ThreadLifecycleFilter.test.tsx @@ -33,10 +33,14 @@ describe("ThreadLifecycleFilter", () => { async (compact) => { viewport.compact = compact; const { container } = render(); - fireEvent.keyDown( - screen.getByRole("button", { name: "Thread lifecycle: Active" }), - { key: "Enter" }, - ); + const trigger = screen.getByRole("button", { + name: "Thread lifecycle: Active", + }); + if (compact) { + fireEvent.click(trigger); + } else { + fireEvent.keyDown(trigger, { key: "Enter" }); + } const active = await screen.findByRole("menuitemcheckbox", { name: "Active", }); diff --git a/apps/app/src/components/thread/ThreadLifecycleFilter.tsx b/apps/app/src/components/thread/ThreadLifecycleFilter.tsx index 7699c6737b2..c3fdc723671 100644 --- a/apps/app/src/components/thread/ThreadLifecycleFilter.tsx +++ b/apps/app/src/components/thread/ThreadLifecycleFilter.tsx @@ -30,7 +30,7 @@ export function ThreadLifecycleFilter({ .join(", "); return ( - + - + + + + + + + {triggerLabel} + @@ -257,8 +386,12 @@ export function SidebarHeaderControls({ {( [ { page: "organize", label: "Organize", icon: "Layers" }, - { page: "sort", label: "Sort by", icon: "Sort" }, - { page: "filter", label: "Filter", icon: "FilterHorizontal" }, + { page: "sort", label: "Sort by", icon: "ArrowUpDown" }, + { + page: "filter", + label: "Filter threads", + icon: "SlidersHorizontal", + }, ] as const ).map((item) => compact ? ( @@ -280,7 +413,7 @@ export function SidebarHeaderControls({ {item.label} - + diff --git a/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx b/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx index 06ee0c90000..ecb88a34da9 100644 --- a/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx +++ b/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx @@ -163,13 +163,15 @@ describe("sidebar lifecycle groups", () => { ).toBeNull(); const label = lifecycle === "draft" ? "Drafts" : "Archived"; fireEvent.keyDown( - screen.getByRole("button", { name: `${label} actions` }), + screen.getByRole("button", { + name: new RegExp(`^${label} actions(?:;|$)`), + }), { key: "Enter", }, ); fireEvent.keyDown( - await screen.findByRole("menuitem", { name: "Filter" }), + await screen.findByRole("menuitem", { name: "Filter threads" }), { key: "ArrowRight", }, @@ -187,7 +189,7 @@ describe("sidebar lifecycle groups", () => { const trigger = screen.getByRole("button", { name: "Active actions" }); fireEvent.keyDown(trigger, { key: "Enter" }); expect( - await screen.findByRole("menuitem", { name: "Filter" }), + await screen.findByRole("menuitem", { name: "Filter threads" }), ).toBeTruthy(); }, ); diff --git a/docs/configuration.md b/docs/configuration.md index ff8fd284fcf..c14edf87b52 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -705,7 +705,9 @@ value is saved. Existing server and legacy browser choices are preserved. The built-in sidebar defaults to Active. `sidebar.threadLifecycles` selects named Active, Drafts, and Archived groups while preserving Active's organization. -Choose Filter in a sidebar header's combined actions menu to change the selection. +Choose Filter threads in a sidebar header's combined actions menu to change the selection. +The combined control highlights non-default organization, sorting, or lifecycle choices. +Each secondary menu has a Reset action that restores only its own defaults. Drafts come from the available unarchived bootstrap; Archived loads pages only while selected. For example, `bb settings ui set sidebar.threadLifecycles '["active","draft"]'` shows active and saved draft threads. Reset restores From 155ebcd1164baf693fd105ce0790a36cbeb7d895 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 09:24:33 -0700 Subject: [PATCH 13/57] Preserve sidebar menu state through tooltip trigger --- .../components/sidebar/ProjectRow.interactions.test.tsx | 2 +- apps/app/src/components/sidebar/SidebarHeaderControls.tsx | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx b/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx index c91095aeb06..9b4f406c865 100644 --- a/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx +++ b/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx @@ -174,7 +174,7 @@ describe("ProjectRow interactions", () => { it("keeps project header controls touch-accessible when their menu opens and closes", async () => { renderProjectRow(); const trigger = screen.getByRole("button", { - name: "Test project actions", + name: /^Test project actions(?:;|$)/, }); const actions = trigger.closest(".bb-sidebar-hover-actions"); expect(actions?.getAttribute("data-sidebar-hover-actions-mobile")).toBe( diff --git a/apps/app/src/components/sidebar/SidebarHeaderControls.tsx b/apps/app/src/components/sidebar/SidebarHeaderControls.tsx index ef4ed94ebf0..1a3db2707af 100644 --- a/apps/app/src/components/sidebar/SidebarHeaderControls.tsx +++ b/apps/app/src/components/sidebar/SidebarHeaderControls.tsx @@ -318,8 +318,8 @@ export function SidebarHeaderControls({ > - - + + - - + + {triggerLabel} Date: Sun, 20 Sep 2026 09:35:03 -0700 Subject: [PATCH 14/57] Defer sidebar view menu contents until opened --- .../sidebar/SidebarHeaderControls.test.tsx | 18 ++ .../sidebar/SidebarHeaderControls.tsx | 203 ++++-------------- .../components/sidebar/SidebarViewItems.tsx | 163 ++++++++++++++ 3 files changed, 227 insertions(+), 157 deletions(-) create mode 100644 apps/app/src/components/sidebar/SidebarViewItems.tsx diff --git a/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx b/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx index b507f18623f..514b607ab12 100644 --- a/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx +++ b/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx @@ -90,6 +90,24 @@ async function openSubmenu(label: string) { } describe("sidebar header controls", () => { + it("supports keyboard selection and Reset when a submenu first loads", async () => { + const { store } = setup("Pinned", false, "chronological"); + await openMenu(); + await openSubmenu("Organize"); + const project = await screen.findByRole("menuitemradio", { + name: "By project", + }); + fireEvent.keyDown(project.closest('[role="menu"]')!, { key: "ArrowDown" }); + await waitFor(() => expect(document.activeElement).toBe(project)); + fireEvent.keyDown(project, { key: "Enter" }); + expect(store.get(sidebarOrganizationModeAtom)).toBe("project"); + const reset = screen.getByRole("menuitem", { name: "Reset" }); + reset.focus(); + fireEvent.keyDown(reset, { key: "Enter" }); + expect(store.get(sidebarOrganizationModeAtom)).toBe("chronological"); + expect(reset.getAttribute("aria-disabled")).toBe("true"); + }); + it("keeps the primary before overflow and applies the shared control state", async () => { const { newThread } = setup("Pinned", false, "chronological"); const primary = screen.getByRole("button", { diff --git a/apps/app/src/components/sidebar/SidebarHeaderControls.tsx b/apps/app/src/components/sidebar/SidebarHeaderControls.tsx index 1a3db2707af..ece39e07610 100644 --- a/apps/app/src/components/sidebar/SidebarHeaderControls.tsx +++ b/apps/app/src/components/sidebar/SidebarHeaderControls.tsx @@ -1,4 +1,11 @@ -import { createContext, useContext, useState, type ReactNode } from "react"; +import { + createContext, + lazy, + Suspense, + useContext, + useState, + type ReactNode, +} from "react"; import { useAtom, useAtomValue } from "jotai"; import { getUiPreferenceDefault } from "@bb/domain"; import { cn } from "@bb/shared-ui/lib/utils"; @@ -10,9 +17,7 @@ import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport"; import { DropdownMenu, DropdownMenuContent, - DropdownMenuGroup, DropdownMenuItem, - DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuSub, @@ -28,10 +33,7 @@ import { sidebarSortDirectionAtom, sidebarThreadLifecyclesAtom, } from "./sidebarCollapsedAtoms"; -import { - ThreadLifecycleFilterItems, - THREAD_LIFECYCLE_OPTIONS, -} from "@/components/thread/ThreadLifecycleFilter"; +import { THREAD_LIFECYCLE_OPTIONS } from "@/components/thread/ThreadLifecycleFilter"; import { SidebarControlButton, SidebarRowControls } from "./SidebarRowControls"; import { SIDEBAR_CONTROL_BUTTON_CLASS } from "./sidebarRowClasses"; @@ -57,6 +59,19 @@ const SIDEBAR_SORT_OPTIONS = [ { label: "Alphabetical", sort: "alpha", direction: "ascending" }, ] as const; +const LazySidebarViewItems = lazy(() => + import("./SidebarViewItems").then(({ SidebarViewItems }) => ({ + default: SidebarViewItems, + })), +); + +export interface SidebarViewItemsProps { + page: "organize" | "sort" | "filter"; + settings: ReturnType; + organizeOptions: typeof SIDEBAR_ORGANIZE_OPTIONS; + sortOptions: typeof SIDEBAR_SORT_OPTIONS; +} + function useSidebarViewSettings() { const [lifecycles, setLifecycles] = useAtom(sidebarThreadLifecyclesAtom); const [organization, setOrganization] = useAtom(sidebarOrganizationModeAtom); @@ -129,153 +144,6 @@ function useSidebarViewSettings() { }; } -function SidebarViewItems({ page }: { page: "organize" | "sort" | "filter" }) { - const { - lifecycles, - setLifecycles, - organization, - setOrganization, - setSort, - savedDirection, - setDirection, - setEnvironmentGrouping, - groupByEnvironment, - selectedSort, - changed, - } = useSidebarViewSettings(); - const reset = ( - <> - - { - event.preventDefault(); - if (page === "organize") { - setOrganization(getUiPreferenceDefault("sidebar.organizationMode")); - setEnvironmentGrouping( - getUiPreferenceDefault("sidebar.threadGrouping.environment"), - ); - } else if (page === "sort") { - setSort(getUiPreferenceDefault("sidebar.chronologicalSort")); - setDirection(getUiPreferenceDefault("sidebar.sortDirection")); - } else { - setLifecycles(getUiPreferenceDefault("sidebar.threadLifecycles")); - } - }} - > - Reset - - - ); - if (page === "filter") { - return ( - <> - - - - {reset} - - ); - } - if (page === "organize") { - return ( - <> - - Sections - {SIDEBAR_ORGANIZE_OPTIONS.map((option) => ( - { - event.preventDefault(); - setOrganization(option.mode); - }} - > - {option.label} - - {organization === option.mode && ( - - )} - - - ))} - - - - Groups - { - event.preventDefault(); - setEnvironmentGrouping(!groupByEnvironment); - }} - > - By environment - - {groupByEnvironment && } - - - - {reset} - - ); - } - return ( - <> - - {SIDEBAR_SORT_OPTIONS.map((option) => { - const selected = selectedSort === option.sort; - const direction = - savedDirection === "default" ? option.direction : savedDirection; - const nextDirection = selected - ? direction === "ascending" - ? "descending" - : "ascending" - : option.direction; - return ( - { - event.preventDefault(); - setSort(option.sort); - setDirection(nextDirection); - }} - > - {option.label} - {selected && ( - - , {direction}. Sort {nextDirection} - - )} - - {selected && ( - - )} - - - ); - })} - - {reset} - - ); -} export function SidebarHeaderControls({ label, @@ -293,7 +161,8 @@ export function SidebarHeaderControls({ onOpenChange?: (open: boolean) => void; }) { const creation = useContext(HeaderCreationContext); - const { summary } = useSidebarViewSettings(); + const settings = useSidebarViewSettings(); + const { summary } = settings; const triggerLabel = summary ? `${label} actions; ${summary}` : `${label} actions`; @@ -364,7 +233,16 @@ export function SidebarHeaderControls({ Back - + Loading…} + > + + ) : ( <> @@ -414,7 +292,18 @@ export function SidebarHeaderControls({ - + Loading… + } + > + + diff --git a/apps/app/src/components/sidebar/SidebarViewItems.tsx b/apps/app/src/components/sidebar/SidebarViewItems.tsx new file mode 100644 index 00000000000..d94d041f1d7 --- /dev/null +++ b/apps/app/src/components/sidebar/SidebarViewItems.tsx @@ -0,0 +1,163 @@ +import { getUiPreferenceDefault } from "@bb/domain"; +import { Icon } from "@bb/shared-ui/icon"; +import { + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, +} from "@bb/shared-ui/dropdown-menu"; +import { ThreadLifecycleFilterItems } from "@/components/thread/ThreadLifecycleFilter"; +import type { SidebarViewItemsProps } from "./SidebarHeaderControls"; + +export function SidebarViewItems({ + page, + settings, + organizeOptions, + sortOptions, +}: SidebarViewItemsProps) { + const { + lifecycles, + setLifecycles, + organization, + setOrganization, + setSort, + savedDirection, + setDirection, + setEnvironmentGrouping, + groupByEnvironment, + selectedSort, + changed, + } = settings; + const reset = ( + <> + + { + event.preventDefault(); + if (page === "organize") { + setOrganization(getUiPreferenceDefault("sidebar.organizationMode")); + setEnvironmentGrouping( + getUiPreferenceDefault("sidebar.threadGrouping.environment"), + ); + } else if (page === "sort") { + setSort(getUiPreferenceDefault("sidebar.chronologicalSort")); + setDirection(getUiPreferenceDefault("sidebar.sortDirection")); + } else { + setLifecycles(getUiPreferenceDefault("sidebar.threadLifecycles")); + } + }} + > + Reset + + + ); + if (page === "filter") { + return ( + <> + + + + {reset} + + ); + } + if (page === "organize") { + return ( + <> + + Sections + {organizeOptions.map((option) => ( + { + event.preventDefault(); + setOrganization(option.mode); + }} + > + {option.label} + + {organization === option.mode && ( + + )} + + + ))} + + + + Groups + { + event.preventDefault(); + setEnvironmentGrouping(!groupByEnvironment); + }} + > + By environment + + {groupByEnvironment && } + + + + {reset} + + ); + } + return ( + <> + + {sortOptions.map((option) => { + const selected = selectedSort === option.sort; + const direction = + savedDirection === "default" ? option.direction : savedDirection; + const nextDirection = selected + ? direction === "ascending" + ? "descending" + : "ascending" + : option.direction; + return ( + { + event.preventDefault(); + setSort(option.sort); + setDirection(nextDirection); + }} + > + {option.label} + {selected && ( + + , {direction}. Sort {nextDirection} + + )} + + {selected && ( + + )} + + + ); + })} + + {reset} + + ); +} From 819e2bdd3e01b65706d183bc4b09d6f2e9f3a03e Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 10:04:32 -0700 Subject: [PATCH 15/57] Render active sidebar hierarchy without a duplicate header --- .../sidebar/SidebarThreadLifecycles.test.tsx | 124 +++++++++++++----- .../sidebar/SidebarThreadLifecycles.tsx | 4 +- 2 files changed, 91 insertions(+), 37 deletions(-) diff --git a/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx b/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx index ecb88a34da9..fad8e866364 100644 --- a/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx +++ b/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx @@ -8,12 +8,15 @@ import { screen, } from "@testing-library/react"; import { createStore, Provider } from "jotai"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MemoryRouter } from "react-router-dom"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { ThreadLifecycle } from "@bb/domain"; import { TooltipProvider } from "@bb/shared-ui/tooltip"; import { makeThreadListEntry } from "@bb/test-helpers/domain-fixtures"; import { SidebarThreadLifecycles } from "./SidebarThreadLifecycles"; +import { SidebarHeaderControls } from "./SidebarHeaderControls"; +import { ChronologicalSectionThreadSections } from "./ProjectRow"; import { sidebarThreadLifecyclesAtom } from "./sidebarCollapsedAtoms"; const archiveQuery = vi.hoisted(() => ({ @@ -85,34 +88,63 @@ function setup(lifecycles: ThreadLifecycle[] = ["active"], empty = false) { render( - - 0, - collapsedThreadIds: new Set(), - collapsedEnvironmentIds: new Set(), - onToggleThreadCollapsed: vi.fn(), - onToggleEnvironmentCollapsed: vi.fn(), - }} - > -
Active hierarchy
-
-
+ + + 0, + collapsedThreadIds: new Set(), + collapsedEnvironmentIds: new Set(), + onToggleThreadCollapsed: vi.fn(), + onToggleEnvironmentCollapsed: vi.fn(), + }} + > + 0} + sections={[]} + collapsedThreadIds={new Set()} + collapsedEnvironmentIds={new Set()} + onToggleThreadCollapsed={vi.fn()} + onToggleEnvironmentCollapsed={vi.fn()} + topLevelSectionOrder={["threads"]} + onTopLevelSectionOrderChange={vi.fn()} + pinnedReorderPending={false} + pinnedThreads={[]} + onReorderPinnedThread={vi.fn()} + builtInSections={{ + collapsedSectionIds: new Set(), + onToggleCollapsed: vi.fn(), + pinned: { label: "Pinned", content: null }, + threads: { + label: "Threads", + actions: , + }, + }} + /> + + +
, ); @@ -134,7 +166,7 @@ describe("sidebar lifecycle groups", () => { ).map((lifecycles) => ({ lifecycles })), )("shows only selected semantic groups for $lifecycles", ({ lifecycles }) => { setup(lifecycles); - expect(screen.queryByText("Active hierarchy") !== null).toBe( + expect(screen.queryByText("Active work") !== null).toBe( lifecycles.includes("active"), ); expect(screen.queryByText("Saved work") !== null).toBe( @@ -144,15 +176,37 @@ describe("sidebar lifecycle groups", () => { lifecycles.includes("archived"), ); expect( - screen.getAllByRole("heading").map((heading) => heading.textContent), + screen.queryAllByRole("heading").map((heading) => heading.textContent), ).toEqual( - ["Active", "Drafts", "Archived"].filter((_, index) => - lifecycles.includes((["active", "draft", "archived"] as const)[index]!), + ["Drafts", "Archived"].filter((_, index) => + lifecycles.includes((["draft", "archived"] as const)[index]!), ), ); expect(archiveQuery.enabled).toBe(lifecycles.includes("archived")); }); + it.each([false, true])( + "uses the existing hierarchy menu without an Active header (empty=%s)", + async (empty) => { + setup(["active"], empty); + expect(screen.queryByRole("heading", { name: "Active" })).toBeNull(); + expect(screen.queryByRole("region", { name: "Active" })).toBeNull(); + expect( + screen.queryByRole("button", { name: /^Active actions/ }), + ).toBeNull(); + expect( + screen.getByText(empty ? "No threads" : "Active work"), + ).toBeTruthy(); + fireEvent.keyDown( + screen.getByRole("button", { name: /^Threads actions(?:;|$)/ }), + { key: "Enter" }, + ); + expect( + await screen.findByRole("menuitem", { name: "Filter threads" }), + ).toBeTruthy(); + }, + ); + it.each(["draft", "archived"] as const)( "keeps the combined menu reachable in an empty %s-only group", async (lifecycle) => { @@ -185,8 +239,10 @@ describe("sidebar lifecycle groups", () => { ]); fireEvent.click(screen.getByRole("menuitemcheckbox", { name: label })); expect(store.get(sidebarThreadLifecyclesAtom)).toEqual(["active"]); - expect(screen.getByText("Active hierarchy")).toBeTruthy(); - const trigger = screen.getByRole("button", { name: "Active actions" }); + expect(screen.getByText("No threads")).toBeTruthy(); + const trigger = screen.getByRole("button", { + name: /^Threads actions(?:;|$)/, + }); fireEvent.keyDown(trigger, { key: "Enter" }); expect( await screen.findByRole("menuitem", { name: "Filter threads" }), diff --git a/apps/app/src/components/sidebar/SidebarThreadLifecycles.tsx b/apps/app/src/components/sidebar/SidebarThreadLifecycles.tsx index 07c3510a064..a157eb5cab7 100644 --- a/apps/app/src/components/sidebar/SidebarThreadLifecycles.tsx +++ b/apps/app/src/components/sidebar/SidebarThreadLifecycles.tsx @@ -63,9 +63,7 @@ export function SidebarThreadLifecycles({ }); return ( <> - {lifecycles.includes("active") && ( - {children} - )} + {lifecycles.includes("active") && children} {lifecycles.includes("draft") && ( Date: Sun, 20 Sep 2026 10:48:25 -0700 Subject: [PATCH 16/57] Integrate archived threads into the sidebar hierarchy --- .../src/components/sidebar/ProjectList.tsx | 29 +-- .../sidebar/SidebarHeaderControls.test.tsx | 109 ++++------ .../sidebar/SidebarHeaderControls.tsx | 123 +++++------ .../sidebar/SidebarThreadLifecycles.test.tsx | 198 ++++++++++++------ .../sidebar/SidebarThreadLifecycles.tsx | 130 +++++++----- .../components/sidebar/SidebarViewItems.tsx | 26 +-- .../src/components/sidebar/ThreadRow.test.tsx | 71 ++++++- apps/app/src/components/sidebar/ThreadRow.tsx | 55 ++++- .../components/thread/ThreadActionsMenu.tsx | 7 +- .../thread-lifecycle-cache.test.ts | 29 +++ docs/configuration.md | 10 +- 11 files changed, 495 insertions(+), 292 deletions(-) diff --git a/apps/app/src/components/sidebar/ProjectList.tsx b/apps/app/src/components/sidebar/ProjectList.tsx index d6eba84f72d..9e26e9aa512 100644 --- a/apps/app/src/components/sidebar/ProjectList.tsx +++ b/apps/app/src/components/sidebar/ProjectList.tsx @@ -26,7 +26,10 @@ import { import { isTransientReadError } from "@/hooks/queries/query-helpers"; import { stripProjectThreads } from "@/hooks/queries/project-queries"; import { useSidebarNavigation } from "@/hooks/queries/sidebar-navigation-query"; -import { SidebarThreadLifecycles } from "./SidebarThreadLifecycles"; +import { + SidebarThreadLifecycles, + useSidebarThreadLifecycles, +} from "./SidebarThreadLifecycles"; import { useSystemConfig } from "@/hooks/queries/system-queries"; import { useReorderPinnedThread } from "@/hooks/mutations/thread-state-mutations"; import { @@ -1337,14 +1340,8 @@ function ProjectListComponent({ sidebarThreads.push(...sidebarNavigation.personalProject.threads); return sidebarThreads; }, [sidebarNavigation]); - const threads = useMemo( - () => unarchivedThreads.filter((thread) => thread.lifecycle === "active"), - [unarchivedThreads], - ); - const savedDrafts = useMemo( - () => unarchivedThreads.filter((thread) => thread.lifecycle === "draft"), - [unarchivedThreads], - ); + const lifecycles = useSidebarThreadLifecycles(unarchivedThreads); + const { threads } = lifecycles; const draftThreadIds = usePromptDraftInputThreadIds(threads); const titleMentionResources = useThreadTitleMentionResources(); const uiPreferencesReady = useUiPreferencesReady(); @@ -1357,6 +1354,12 @@ function ProjectListComponent({ ), }); const { threadId: selectedThreadId } = useRouteState(); + const hierarchyStatus = + projectsState.status === "ready" && + lifecycles.value.includes("archived") && + !lifecycles.value.includes("active") + ? lifecycles.archivedStatus + : projectsState.status; const { isPending: isPinnedReorderPending, mutate: reorderPinnedThreadMutate, @@ -1725,7 +1728,7 @@ function ProjectListComponent({ > { await waitFor(() => expect(document.activeElement).toBe(project)); fireEvent.keyDown(project, { key: "Enter" }); expect(store.get(sidebarOrganizationModeAtom)).toBe("project"); - const reset = screen.getByRole("menuitem", { name: "Reset" }); + const reset = screen.getByRole("menuitem", { name: "Reset to default" }); reset.focus(); fireEvent.keyDown(reset, { key: "Enter" }); expect(store.get(sidebarOrganizationModeAtom)).toBe("chronological"); @@ -148,9 +148,9 @@ describe("sidebar header controls", () => { ).toEqual([ "New project", "New section", - "Organize", - "Sort by", - "Filter threads", + "Organize:By project", + "Sort:Updated at", + "Filter:Active", "Rename", "Remove", ]); @@ -175,10 +175,10 @@ describe("sidebar header controls", () => { if (compact) fireEvent.click(trigger); else await openMenu(); const filter = await screen.findByRole("menuitem", { - name: "Filter threads", + name: /^Filter:/, }); if (compact) fireEvent.click(filter); - else await openSubmenu("Filter threads"); + else await openSubmenu("Filter"); const active = await screen.findByRole("menuitemcheckbox", { name: "Active", }); @@ -202,21 +202,20 @@ describe("sidebar header controls", () => { expect(store.get(sidebarChronologicalSortAtom)).toBe("updated"); if (compact) { expect( - screen.getByRole("dialog", { name: "Filter threads" }), + screen.getByRole("dialog", { name: "Filter" }), ).toBeTruthy(); expect(trigger.closest("[inert], [aria-hidden='true']")).toBeNull(); fireEvent.click(screen.getByRole("menuitem", { name: "Back" })); expect( screen.getByRole("menuitem", { name: "New project" }), ).toBeTruthy(); - expect(screen.getByRole("menuitem", { name: "Organize" })).toBeTruthy(); + expect(screen.getByRole("menuitem", { name: /^Organize:/ })).toBeTruthy(); } }, ); it.each([ "organization", - "grouping", "sort", "direction", "lifecycle", @@ -231,8 +230,6 @@ describe("sidebar header controls", () => { act(() => { if (setting === "organization") store.set(sidebarOrganizationModeAtom, "machine"); - if (setting === "grouping") - store.set(sidebarEnvironmentGroupingAtom, true); if (setting === "sort") store.set(sidebarChronologicalSortAtom, "created"); if (setting === "direction") @@ -245,7 +242,7 @@ describe("sidebar header controls", () => { ).toBeTruthy(); expect(trigger.classList.contains("bg-state-active")).toBe(true); expect(trigger.getAttribute("aria-label")).toMatch( - /Pinned actions; (Organize|Sort by|Filter threads):/, + /Pinned actions; (Organize|Sort|Filter):/, ); expect(trigger.getAttribute("aria-haspopup")).toBe("menu"); expect(trigger.getAttribute("aria-expanded")).toBe("false"); @@ -293,8 +290,8 @@ describe("sidebar header controls", () => { .classList.contains("bg-state-active"), ).toBe(false); await openMenu(); - await openSubmenu("Sort by"); - const reset = await screen.findByRole("menuitem", { name: "Reset" }); + await openSubmenu("Sort"); + const reset = await screen.findByRole("menuitem", { name: "Reset to default" }); expect(reset.getAttribute("aria-disabled")).toBe("true"); expect( screen @@ -320,14 +317,14 @@ describe("sidebar header controls", () => { const trigger = screen.getByRole("button", { name: /^Pinned actions;/ }); if (compact) fireEvent.click(trigger); else await openMenu(); - for (const label of ["Organize", "Sort by", "Filter threads"]) { - const page = await screen.findByRole("menuitem", { name: label }); + for (const label of ["Organize", "Sort", "Filter"]) { + const page = await screen.findByRole("menuitem", { name: new RegExp(`^${label}:`) }); if (compact) fireEvent.click(page); else await openSubmenu(label); - const reset = await screen.findByRole("menuitem", { name: "Reset" }); + const reset = await screen.findByRole("menuitem", { name: "Reset to default" }); expect(reset.getAttribute("aria-disabled")).not.toBe("true"); if (!compact) { - const submenu = screen.getByRole("menu", { name: label }); + const submenu = screen.getByRole("menu", { name: new RegExp(`^${label}:`) }); expect(submenu.classList.contains("w-max")).toBe(true); expect(submenu.classList.contains("min-w-28")).toBe(true); expect(submenu.classList.contains("max-w-64")).toBe(true); @@ -335,11 +332,11 @@ describe("sidebar header controls", () => { fireEvent.click(reset); expect( screen - .getByRole("menuitem", { name: "Reset" }) + .getByRole("menuitem", { name: "Reset to default" }) .getAttribute("aria-disabled"), ).toBe("true"); expect(store.get(sidebarOrganizationModeAtom)).toBe("chronological"); - expect(store.get(sidebarEnvironmentGroupingAtom)).toBe("auto"); + expect(store.get(sidebarEnvironmentGroupingAtom)).toBe(false); expect(store.get(sidebarChronologicalSortAtom)).toBe( label === "Organize" ? "alpha" : "updated", ); @@ -347,13 +344,13 @@ describe("sidebar header controls", () => { label === "Organize" ? "descending" : "default", ); expect(store.get(sidebarThreadLifecyclesAtom)).toEqual( - label === "Filter threads" ? ["active"] : ["draft", "archived"], + label === "Filter" ? ["active"] : ["draft", "archived"], ); if (compact) fireEvent.click(screen.getByRole("menuitem", { name: "Back" })); else fireEvent.keyDown(reset, { key: "ArrowLeft" }); await waitFor(() => - expect(screen.queryByRole("menuitem", { name: "Reset" })).toBeNull(), + expect(screen.queryByRole("menuitem", { name: "Reset to default" })).toBeNull(), ); await screen.findByRole("menuitem", { name: "New project" }); } @@ -403,60 +400,30 @@ describe("sidebar header controls", () => { ); }); - it("resolves auto grouping from the organization mode and pins an explicit choice", async () => { - const { store } = setup(); - await openMenu(); - await openSubmenu("Organize"); - const toggle = await screen.findByRole("menuitemcheckbox", { - name: "By environment", - }); - expect(toggle.getAttribute("aria-checked")).toBe("true"); - - fireEvent.click(toggle); - expect(store.get(sidebarEnvironmentGroupingAtom)).toBe(false); - await waitFor(() => - expect( - screen - .getByRole("menuitemcheckbox", { name: "By environment" }) - .getAttribute("aria-checked"), - ).toBe("false"), - ); - - store.set(sidebarOrganizationModeAtom, "chronological"); - await waitFor(() => - expect( - screen - .getByRole("menuitemcheckbox", { name: "By environment" }) - .getAttribute("aria-checked"), - ).toBe("false"), - ); - }); - - it("leaves auto grouping off in Custom and on in the other modes", async () => { + it("keeps saved environment grouping outside the menu and its reset", async () => { const { store } = setup("Pinned", false, "chronological"); + act(() => store.set(sidebarEnvironmentGroupingAtom, true)); + const trigger = screen.getByRole("button", { name: "Pinned actions" }); + expect(trigger.classList.contains("bg-state-active")).toBe(false); + expect(trigger.hasAttribute("aria-describedby")).toBe(false); await openMenu(); + expect(screen.getByRole("menuitem", { name: "Organize: Custom" })).toBeTruthy(); + expect(screen.getByRole("menuitem", { name: "Sort: Updated at, descending" })).toBeTruthy(); + expect(screen.getByRole("menuitem", { name: "Filter: Active" })).toBeTruthy(); await openSubmenu("Organize"); - expect( - ( - await screen.findByRole("menuitemcheckbox", { name: "By environment" }) - ).getAttribute("aria-checked"), - ).toBe("false"); - - store.set(sidebarOrganizationModeAtom, "machine"); - await waitFor(() => - expect( - screen - .getByRole("menuitemcheckbox", { name: "By environment" }) - .getAttribute("aria-checked"), - ).toBe("true"), - ); - expect(store.get(sidebarEnvironmentGroupingAtom)).toBe("auto"); + expect(screen.queryByRole("group", { name: "Groups" })).toBeNull(); + expect(screen.queryByText("By environment")).toBeNull(); + fireEvent.click(await screen.findByRole("menuitemradio", { name: "By project" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Reset to default" })); + expect(store.get(sidebarOrganizationModeAtom)).toBe("chronological"); + expect(store.get(sidebarEnvironmentGroupingAtom)).toBe(true); + expect(screen.queryByRole("tooltip")).toBeNull(); }); it("toggles sort direction without closing and resets direction for a different field", async () => { const { store } = setup(); await openMenu(); - await openSubmenu("Sort by"); + await openSubmenu("Sort"); const updated = await screen.findByRole("menuitemradio", { name: "Updated at, descending. Sort ascending", }); @@ -488,7 +455,7 @@ describe("sidebar header controls", () => { fireEvent.click( screen.getByRole("button", { name: /^Pinned actions(?:;|$)/ }), ); - fireEvent.click(await screen.findByRole("menuitem", { name: "Sort by" })); + fireEvent.click(await screen.findByRole("menuitem", { name: /^Sort:/ })); fireEvent.click( await screen.findByRole("menuitemradio", { name: /Updated at\s*, descending\. Sort ascending/, @@ -503,7 +470,7 @@ describe("sidebar header controls", () => { .getAttribute("aria-checked"), ).toBe("true"); fireEvent.click(screen.getByRole("menuitem", { name: "Back" })); - fireEvent.click(screen.getByRole("menuitem", { name: "Organize" })); + fireEvent.click(screen.getByRole("menuitem", { name: /^Organize:/ })); fireEvent.click(screen.getByRole("menuitemradio", { name: "Custom" })); expect(store.get(sidebarOrganizationModeAtom)).toBe("chronological"); expect( diff --git a/apps/app/src/components/sidebar/SidebarHeaderControls.tsx b/apps/app/src/components/sidebar/SidebarHeaderControls.tsx index ece39e07610..62c1aa56e81 100644 --- a/apps/app/src/components/sidebar/SidebarHeaderControls.tsx +++ b/apps/app/src/components/sidebar/SidebarHeaderControls.tsx @@ -6,10 +6,9 @@ import { useState, type ReactNode, } from "react"; -import { useAtom, useAtomValue } from "jotai"; +import { useAtom } from "jotai"; import { getUiPreferenceDefault } from "@bb/domain"; import { cn } from "@bb/shared-ui/lib/utils"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@bb/shared-ui/tooltip"; import { Button } from "@bb/shared-ui/button"; import { Icon } from "@bb/shared-ui/icon"; import { COARSE_POINTER_ICON_SIZE_CLASS } from "@bb/shared-ui/coarse-pointer-sizing"; @@ -28,8 +27,6 @@ import { import { sidebarOrganizationModeAtom, sidebarChronologicalSortAtom, - sidebarGroupThreadsByEnvironmentAtom, - sidebarEnvironmentGroupingAtom, sidebarSortDirectionAtom, sidebarThreadLifecyclesAtom, } from "./sidebarCollapsedAtoms"; @@ -77,8 +74,6 @@ function useSidebarViewSettings() { const [organization, setOrganization] = useAtom(sidebarOrganizationModeAtom); const [sort, setSort] = useAtom(sidebarChronologicalSortAtom); const [savedDirection, setDirection] = useAtom(sidebarSortDirectionAtom); - const [, setEnvironmentGrouping] = useAtom(sidebarEnvironmentGroupingAtom); - const groupByEnvironment = useAtomValue(sidebarGroupThreadsByEnvironmentAtom); const selectedSort = sort === "none" ? "updated" : sort; const direction = savedDirection === "default" @@ -89,19 +84,11 @@ function useSidebarViewSettings() { const defaultOrganization = getUiPreferenceDefault( "sidebar.organizationMode", ); - const defaultGrouping = getUiPreferenceDefault( - "sidebar.threadGrouping.environment", - ); const defaultSort = getUiPreferenceDefault("sidebar.chronologicalSort"); const defaultDirection = getUiPreferenceDefault("sidebar.sortDirection"); const defaultLifecycles = getUiPreferenceDefault("sidebar.threadLifecycles"); const changed = { - organize: - organization !== defaultOrganization || - groupByEnvironment !== - (defaultGrouping === "auto" - ? organization !== "chronological" - : defaultGrouping), + organize: organization !== defaultOrganization, sort: selectedSort !== defaultSort || direction !== @@ -114,17 +101,23 @@ function useSidebarViewSettings() { lifecycles.length !== defaultLifecycles.length || lifecycles.some((value) => !defaultLifecycles.includes(value)), }; + const values = { + organize: SIDEBAR_ORGANIZE_OPTIONS.find( + (option) => option.mode === organization, + )!.label, + sort: SIDEBAR_SORT_OPTIONS.find( + (option) => option.sort === selectedSort, + )!.label, + filter: THREAD_LIFECYCLE_OPTIONS.filter((option) => + lifecycles.includes(option.value), + ) + .map((option) => option.label) + .join(", "), + }; const summary = [ - changed.organize && - `Organize: ${SIDEBAR_ORGANIZE_OPTIONS.find((option) => option.mode === organization)?.label}, ${groupByEnvironment ? "grouped by environment" : "ungrouped"}`, - changed.sort && - `Sort by: ${SIDEBAR_SORT_OPTIONS.find((option) => option.sort === selectedSort)?.label}, ${direction}`, - changed.filter && - `Filter threads: ${THREAD_LIFECYCLE_OPTIONS.filter((option) => - lifecycles.includes(option.value), - ) - .map((option) => option.label) - .join(", ")}`, + changed.organize && `Organize: ${values.organize}`, + changed.sort && `Sort: ${values.sort}, ${direction}`, + changed.filter && `Filter: ${values.filter}`, ] .filter(Boolean) .join("; "); @@ -136,15 +129,13 @@ function useSidebarViewSettings() { setSort, savedDirection, setDirection, - setEnvironmentGrouping, - groupByEnvironment, selectedSort, + direction, + values, changed, summary, }; } - - export function SidebarHeaderControls({ label, onNewThread, @@ -186,38 +177,33 @@ export function SidebarHeaderControls({ } > - - - - - - - {triggerLabel} - + + + @@ -264,10 +250,10 @@ export function SidebarHeaderControls({ {( [ { page: "organize", label: "Organize", icon: "Layers" }, - { page: "sort", label: "Sort by", icon: "ArrowUpDown" }, + { page: "sort", label: "Sort", icon: "ArrowUpDown" }, { page: "filter", - label: "Filter threads", + label: "Filter", icon: "SlidersHorizontal", }, ] as const @@ -275,20 +261,39 @@ export function SidebarHeaderControls({ compact ? ( { event.preventDefault(); setPage(item.page); }} > - {item.label} + {item.label}: + + {settings.values[item.page]} + + {item.page === "sort" && ( + + )} ) : ( - + - {item.label} + {item.label}: + + {settings.values[item.page]} + + {item.page === "sort" && ( + + )} diff --git a/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx b/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx index fad8e866364..64225f2676e 100644 --- a/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx +++ b/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx @@ -5,6 +5,7 @@ import { cleanup, fireEvent, render, + renderHook, screen, } from "@testing-library/react"; import { createStore, Provider } from "jotai"; @@ -12,9 +13,15 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MemoryRouter } from "react-router-dom"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { ThreadLifecycle } from "@bb/domain"; +import { + buildMachineThreadGroups, + buildPinnedSidebarState, + buildProjectThreadGroups, + buildSectionThreadList, +} from "@bb/client-core"; import { TooltipProvider } from "@bb/shared-ui/tooltip"; import { makeThreadListEntry } from "@bb/test-helpers/domain-fixtures"; -import { SidebarThreadLifecycles } from "./SidebarThreadLifecycles"; +import { SidebarThreadLifecycles, useSidebarThreadLifecycles } from "./SidebarThreadLifecycles"; import { SidebarHeaderControls } from "./SidebarHeaderControls"; import { ChronologicalSectionThreadSections } from "./ProjectRow"; import { sidebarThreadLifecyclesAtom } from "./sidebarCollapsedAtoms"; @@ -39,6 +46,12 @@ vi.mock("@/hooks/queries/thread-queries", () => ({ title: "Archived work", lifecycle: "archived", archivedAt: 1, + projectId: "archive-project", + sectionId: "archive-section", + environmentId: "archive-environment", + environmentHostId: "archive-host", + pinnedAt: 1, + pinSortKey: "a0", }), ], ], @@ -81,6 +94,60 @@ afterEach(() => { vi.clearAllMocks(); }); +function LifecycleContents({ empty }: { empty: boolean }) { + const lifecycles = useSidebarThreadLifecycles(empty ? [] : [ + makeThreadListEntry({ id: "active-thread", title: "Active work" }), + makeThreadListEntry({ + id: "old-draft", + title: "Saved work", + lifecycle: "draft", + status: "pending", + createdAt: 1, + updatedAt: 1, + }), + ]); + return ( + 0, + collapsedThreadIds: new Set(), + collapsedEnvironmentIds: new Set(), + onToggleThreadCollapsed: vi.fn(), + onToggleEnvironmentCollapsed: vi.fn(), + }} + > + 0} + sections={[]} + collapsedThreadIds={new Set()} + collapsedEnvironmentIds={new Set()} + onToggleThreadCollapsed={vi.fn()} + onToggleEnvironmentCollapsed={vi.fn()} + topLevelSectionOrder={["threads"]} + onTopLevelSectionOrderChange={vi.fn()} + pinnedReorderPending={false} + pinnedThreads={[]} + onReorderPinnedThread={vi.fn()} + builtInSections={{ + collapsedSectionIds: new Set(), + onToggleCollapsed: vi.fn(), + pinned: { label: "Pinned", content: null }, + threads: { + label: "Threads", + actions: , + }, + }} + /> + + ); +} + function setup(lifecycles: ThreadLifecycle[] = ["active"], empty = false) { archiveQuery.empty = empty; const store = createStore(); @@ -90,59 +157,7 @@ function setup(lifecycles: ThreadLifecycle[] = ["active"], empty = false) { - 0, - collapsedThreadIds: new Set(), - collapsedEnvironmentIds: new Set(), - onToggleThreadCollapsed: vi.fn(), - onToggleEnvironmentCollapsed: vi.fn(), - }} - > - 0} - sections={[]} - collapsedThreadIds={new Set()} - collapsedEnvironmentIds={new Set()} - onToggleThreadCollapsed={vi.fn()} - onToggleEnvironmentCollapsed={vi.fn()} - topLevelSectionOrder={["threads"]} - onTopLevelSectionOrderChange={vi.fn()} - pinnedReorderPending={false} - pinnedThreads={[]} - onReorderPinnedThread={vi.fn()} - builtInSections={{ - collapsedSectionIds: new Set(), - onToggleCollapsed: vi.fn(), - pinned: { label: "Pinned", content: null }, - threads: { - label: "Threads", - actions: , - }, - }} - /> - + @@ -151,7 +166,66 @@ function setup(lifecycles: ThreadLifecycle[] = ["active"], empty = false) { return store; } -describe("sidebar lifecycle groups", () => { +describe("sidebar lifecycle placement", () => { + it("merges selected rows once and preserves archived hierarchy metadata", () => { + archiveQuery.empty = false; + const store = createStore(); + store.set(sidebarThreadLifecyclesAtom, ["active", "draft", "archived"]); + const active = makeThreadListEntry({ id: "active" }); + const duplicate = makeThreadListEntry({ id: "archived-thread" }); + const draft = makeThreadListEntry({ id: "draft", lifecycle: "draft" }); + const client = new QueryClient(); + const { result, rerender } = renderHook( + ({ bootstrap }) => useSidebarThreadLifecycles(bootstrap), + { + initialProps: { bootstrap: [active, duplicate, draft] }, + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + expect(result.current.threads).toEqual([duplicate, active]); + expect(result.current.drafts).toEqual([draft]); + rerender({ bootstrap: [active, draft] }); + expect(result.current.threads[0]).toMatchObject({ + id: "archived-thread", + lifecycle: "archived", + projectId: "archive-project", + sectionId: "archive-section", + environmentId: "archive-environment", + environmentHostId: "archive-host", + pinnedAt: 1, + pinSortKey: "a0", + }); + const archived = result.current.threads[0]!; + expect(buildPinnedSidebarState({ threads: result.current.threads }).rootNodes) + .toMatchObject([{ thread: archived }]); + expect(buildProjectThreadGroups([archived])) + .toMatchObject([{ kind: "thread", node: { thread: archived } }]); + expect(buildMachineThreadGroups([archived], [])) + .toMatchObject([{ key: "archive-host", threads: [archived] }]); + expect(buildSectionThreadList([archived], () => 0, [ + { id: "archive-section", name: "Review" }, + ])).toMatchObject([{ + kind: "section", + group: { id: "archive-section", items: [{ kind: "thread", node: { thread: archived } }] }, + }]); + act(() => store.set(sidebarThreadLifecyclesAtom, ["active"])); + expect(result.current.threads).toEqual([active]); + }); + + it("puts the icon-labeled Drafts section before the hierarchy with one divider", () => { + setup(["active", "draft", "archived"]); + const drafts = screen.getByRole("region", { name: "Drafts" }); + expect(drafts.querySelector('[data-icon="Edit"]')).toBeTruthy(); + expect(drafts.nextElementSibling?.getAttribute("role")).toBe("separator"); + expect(drafts.compareDocumentPosition(screen.getByText("Active work")) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(screen.queryByRole("region", { name: "Archived" })).toBeNull(); + expect(screen.getAllByText("Archived work")).toHaveLength(1); + }); + it.each<{ lifecycles: ThreadLifecycle[] }>( ( [ @@ -178,9 +252,7 @@ describe("sidebar lifecycle groups", () => { expect( screen.queryAllByRole("heading").map((heading) => heading.textContent), ).toEqual( - ["Drafts", "Archived"].filter((_, index) => - lifecycles.includes((["draft", "archived"] as const)[index]!), - ), + lifecycles.includes("draft") ? ["Drafts"] : [], ); expect(archiveQuery.enabled).toBe(lifecycles.includes("archived")); }); @@ -202,7 +274,7 @@ describe("sidebar lifecycle groups", () => { { key: "Enter" }, ); expect( - await screen.findByRole("menuitem", { name: "Filter threads" }), + await screen.findByRole("menuitem", { name: /^Filter:/ }), ).toBeTruthy(); }, ); @@ -215,7 +287,7 @@ describe("sidebar lifecycle groups", () => { expect( screen.queryByRole("button", { name: /Thread lifecycle:/ }), ).toBeNull(); - const label = lifecycle === "draft" ? "Drafts" : "Archived"; + const label = lifecycle === "draft" ? "Drafts" : "Threads"; fireEvent.keyDown( screen.getByRole("button", { name: new RegExp(`^${label} actions(?:;|$)`), @@ -225,7 +297,7 @@ describe("sidebar lifecycle groups", () => { }, ); fireEvent.keyDown( - await screen.findByRole("menuitem", { name: "Filter threads" }), + await screen.findByRole("menuitem", { name: /^Filter:/ }), { key: "ArrowRight", }, @@ -237,7 +309,7 @@ describe("sidebar lifecycle groups", () => { "active", lifecycle, ]); - fireEvent.click(screen.getByRole("menuitemcheckbox", { name: label })); + fireEvent.click(screen.getByRole("menuitemcheckbox", { name: lifecycle === "draft" ? "Drafts" : "Archived" })); expect(store.get(sidebarThreadLifecyclesAtom)).toEqual(["active"]); expect(screen.getByText("No threads")).toBeTruthy(); const trigger = screen.getByRole("button", { @@ -245,7 +317,7 @@ describe("sidebar lifecycle groups", () => { }); fireEvent.keyDown(trigger, { key: "Enter" }); expect( - await screen.findByRole("menuitem", { name: "Filter threads" }), + await screen.findByRole("menuitem", { name: /^Filter:/ }), ).toBeTruthy(); }, ); diff --git a/apps/app/src/components/sidebar/SidebarThreadLifecycles.tsx b/apps/app/src/components/sidebar/SidebarThreadLifecycles.tsx index a157eb5cab7..520bb49223b 100644 --- a/apps/app/src/components/sidebar/SidebarThreadLifecycles.tsx +++ b/apps/app/src/components/sidebar/SidebarThreadLifecycles.tsx @@ -1,7 +1,9 @@ -import { useId, type ComponentProps, type ReactNode } from "react"; +import { useId, useMemo, type ComponentProps, type ReactNode } from "react"; import { useAtomValue } from "jotai"; import type { ThreadListEntry } from "@bb/domain"; import { Button } from "@bb/shared-ui/button"; +import { Icon } from "@bb/shared-ui/icon"; +import { Separator } from "@bb/shared-ui/separator"; import { useArchivedThreads } from "@/hooks/queries/thread-queries"; import { useConnectionAwareQueryState, @@ -12,60 +14,79 @@ import { SidebarHeaderControls } from "./SidebarHeaderControls"; import { ProjectThreadTree } from "./ProjectRow"; import { sidebarThreadLifecyclesAtom } from "./sidebarCollapsedAtoms"; -function LifecycleGroup({ - label, - children, -}: { - label: string; - children: ReactNode; -}) { - const headingId = useId(); - return ( -
-
-

- {label} -

- -
- {children} -
+export function useSidebarThreadLifecycles( + unarchivedThreads: ThreadListEntry[], +) { + const value = useAtomValue(sidebarThreadLifecyclesAtom); + const archived = useArchivedThreads( + {}, + { enabled: value.includes("archived") }, + ); + const archivedState = useConnectionAwareQueryState({ + hasResolvedData: archived.data !== undefined, + isFetching: archived.isFetching, + isLoadingError: archived.isLoadingError, + isRecoverableLoadingError: isTransientReadError(archived.error), + }); + const threads = useMemo(() => { + const selected = new Map(); + if (value.includes("archived")) { + for (const thread of archived.data?.pages.flat() ?? []) { + if (thread.lifecycle === "archived") selected.set(thread.id, thread); + } + } + if (value.includes("active")) { + for (const thread of unarchivedThreads) { + if (thread.lifecycle === "active") selected.set(thread.id, thread); + } + } + return [...selected.values()]; + }, [archived.data, unarchivedThreads, value]); + const drafts = useMemo( + () => unarchivedThreads.filter((thread) => thread.lifecycle === "draft"), + [unarchivedThreads], ); + return { + value, + threads, + drafts, + archived, + archivedStatus: archivedState.status, + }; } export function SidebarThreadLifecycles({ children, - drafts, + lifecycles, status, treeProps, }: { children: ReactNode; - drafts: ThreadListEntry[]; + lifecycles: ReturnType; status: ConnectionAwareQueryStatus; treeProps: Omit< ComponentProps, "threadListState" | "variant" | "progressiveDisclosureEnabled" >; }) { - const lifecycles = useAtomValue(sidebarThreadLifecyclesAtom); - const archived = useArchivedThreads( - {}, - { enabled: lifecycles.includes("archived") }, - ); - const archivedState = useConnectionAwareQueryState({ - hasResolvedData: archived.data !== undefined, - isFetching: archived.isFetching, - isLoadingError: archived.isLoadingError, - isRecoverableLoadingError: isTransientReadError(archived.error), - }); + const { value, drafts, archived, archivedStatus } = lifecycles; + const headingId = useId(); + const showHierarchy = + value.includes("active") || value.includes("archived"); return ( <> - {lifecycles.includes("active") && children} - {lifecycles.includes("draft") && ( - + {value.includes("draft") && ( +
+
+

+ + Drafts +

+ +
- +
)} - {lifecycles.includes("archived") && ( - - + {value.includes("draft") && showHierarchy && ( + + )} + {showHierarchy && children} + {value.includes("archived") && ( + <> + {value.includes("active") && archivedStatus !== "ready" && ( + + )} {archived.hasNextPage && ( diff --git a/apps/app/src/hooks/cache-owners/thread-lifecycle-cache.test.ts b/apps/app/src/hooks/cache-owners/thread-lifecycle-cache.test.ts index 378b79df22f..22ad39fe57b 100644 --- a/apps/app/src/hooks/cache-owners/thread-lifecycle-cache.test.ts +++ b/apps/app/src/hooks/cache-owners/thread-lifecycle-cache.test.ts @@ -16,6 +16,10 @@ import { updateCachedThreadListStatusState, } from "./query-cache"; import { applyQueuedMessageDeleteResult } from "./thread-runtime-cache-owner"; +import { + beginUnarchiveThreadTransaction, + rollbackThreadListMutationTransaction, +} from "./thread-state-cache-owner"; function setup() { const queryClient = new QueryClient(); @@ -41,6 +45,31 @@ function setup() { } describe("sidebar lifecycle cache", () => { + it("restores archived hierarchy metadata after an unsuccessful optimistic restore", async () => { + const queryClient = setup(); + const archivedKey = archivedThreadsListQueryKey({}); + const archived = makeThreadListEntry({ + id: "archived", + projectId: "project-1", + lifecycle: "archived", + archivedAt: 1, + sectionId: "section-1", + pinnedAt: 1, + pinSortKey: "a0", + environmentId: "environment-1", + environmentHostId: "host-1", + }); + const pages = { pages: [[archived]], pageParams: [0] }; + queryClient.setQueryData(archivedKey, pages); + const transaction = await beginUnarchiveThreadTransaction({ + queryClient, + threadId: archived.id, + }); + expect(queryClient.getQueryData(archivedKey)).toMatchObject({ pages: [[]] }); + rollbackThreadListMutationTransaction({ queryClient, threadId: archived.id, transaction }); + expect(queryClient.getQueryData(archivedKey)).toEqual(pages); + }); + it("moves a sent draft to Active on realtime status and preserves Archived priority", () => { const queryClient = setup(); const archivedKey = archivedThreadsListQueryKey({}); diff --git a/docs/configuration.md b/docs/configuration.md index c14edf87b52..c5d0b611921 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -704,10 +704,14 @@ Custom (`chronological`) is the default for `sidebar.organizationMode` when no value is saved. Existing server and legacy browser choices are preserved. The built-in sidebar defaults to Active. `sidebar.threadLifecycles` selects -named Active, Drafts, and Archived groups while preserving Active's organization. -Choose Filter threads in a sidebar header's combined actions menu to change the selection. +Active, Drafts, and Archived. Drafts appear above the existing hierarchy; +selected archived threads retain their section, project, machine, and pin placement. +Choose Filter in a sidebar header's combined actions menu to change the selection. The combined control highlights non-default organization, sorting, or lifecycle choices. -Each secondary menu has a Reset action that restores only its own defaults. +Each secondary menu shows its current value and has a Reset to default action +for its displayed settings. The environment-grouping preference remains available +through settings and the CLI. Archived rows have a persistent Archive button +that restores the thread without navigating away. Drafts come from the available unarchived bootstrap; Archived loads pages only while selected. For example, `bb settings ui set sidebar.threadLifecycles '["active","draft"]'` shows active and saved draft threads. Reset restores From 184dbc7c25a63e07a3635c9f0da5316a9d9fa70e Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 10:50:48 -0700 Subject: [PATCH 17/57] Use the sidebar divider token and update grouping guidance --- .../components/sidebar/SidebarThreadLifecycles.test.tsx | 2 +- .../src/components/sidebar/SidebarThreadLifecycles.tsx | 3 +-- docs/configuration.md | 8 ++++---- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx b/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx index 64225f2676e..5a65f1a9c8e 100644 --- a/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx +++ b/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx @@ -220,7 +220,7 @@ describe("sidebar lifecycle placement", () => { setup(["active", "draft", "archived"]); const drafts = screen.getByRole("region", { name: "Drafts" }); expect(drafts.querySelector('[data-icon="Edit"]')).toBeTruthy(); - expect(drafts.nextElementSibling?.getAttribute("role")).toBe("separator"); + expect(drafts.nextElementSibling?.tagName).toBe("HR"); expect(drafts.compareDocumentPosition(screen.getByText("Active work")) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect(screen.queryByRole("region", { name: "Archived" })).toBeNull(); expect(screen.getAllByText("Archived work")).toHaveLength(1); diff --git a/apps/app/src/components/sidebar/SidebarThreadLifecycles.tsx b/apps/app/src/components/sidebar/SidebarThreadLifecycles.tsx index 520bb49223b..c6e79e78d61 100644 --- a/apps/app/src/components/sidebar/SidebarThreadLifecycles.tsx +++ b/apps/app/src/components/sidebar/SidebarThreadLifecycles.tsx @@ -3,7 +3,6 @@ import { useAtomValue } from "jotai"; import type { ThreadListEntry } from "@bb/domain"; import { Button } from "@bb/shared-ui/button"; import { Icon } from "@bb/shared-ui/icon"; -import { Separator } from "@bb/shared-ui/separator"; import { useArchivedThreads } from "@/hooks/queries/thread-queries"; import { useConnectionAwareQueryState, @@ -98,7 +97,7 @@ export function SidebarThreadLifecycles({ )} {value.includes("draft") && showHierarchy && ( - +
)} {showHierarchy && children} {value.includes("archived") && ( diff --git a/docs/configuration.md b/docs/configuration.md index c5d0b611921..c63e0985966 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -714,7 +714,7 @@ through settings and the CLI. Archived rows have a persistent Archive button that restores the thread without navigating away. Drafts come from the available unarchived bootstrap; Archived loads pages only while selected. For example, `bb settings ui set sidebar.threadLifecycles -'["active","draft"]'` shows active and saved draft threads. Reset restores +'["active","draft"]'` shows active and saved draft threads. Reset to default restores `["active"]`. Plugin sidebar replacements own their rendering. `sidebar.threadGrouping.environment` decides whether two or more sibling threads @@ -722,9 +722,9 @@ that share one worktree environment collapse into a single worktree row inside their section. `true` groups them and `false` keeps every thread on its own row, in every organization mode. The default, `auto`, groups them in **By project** and **By machine** and leaves them flat in **Custom**, which is how each mode -behaved before the preference existed. The thread-list header's Organize menu -exposes it under Groups as the By environment toggle, which writes `true` or -`false` and so applies to every mode once you use it. +behaved before the preference existed. Set this preference through settings or +`bb settings ui set sidebar.threadGrouping.environment true`; an explicit +`true` or `false` applies to every mode. Each `sidebar.threadGrouping.*` key toggles one grouping dimension independently, so a future dimension adds a key rather than changing this one. From 5906b721364eeb7af441d464fdd4bac2b4b7aadb Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 10:52:16 -0700 Subject: [PATCH 18/57] Align sidebar settings guides with the combined menu --- .../src/templates/bb-guide-customization.md | 16 +++++++++------- .../skills/bb-cli/references/app-settings.md | 7 ++++--- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/templates/src/templates/bb-guide-customization.md b/packages/templates/src/templates/bb-guide-customization.md index 82bbc7b10de..2ae916431ec 100644 --- a/packages/templates/src/templates/bb-guide-customization.md +++ b/packages/templates/src/templates/bb-guide-customization.md @@ -284,19 +284,21 @@ Custom (`chronological`) is the default for `sidebar.organizationMode` when no value is saved. Existing server and legacy browser choices are preserved. `sidebar.threadLifecycles` is a nonempty distinct list of `active`, `draft`, -and `archived`, defaulting to `["active"]`. The built-in sidebar shows named -groups and fetches archived pages only while selected. For example: +and `archived`, defaulting to `["active"]`. Drafts appear above the existing +hierarchy; selected archived threads use their preserved placement and a restore +action. Archived pages load only while selected. For example: `bb settings ui set sidebar.threadLifecycles '["active","draft"]'`. Reset the key to restore Active. Plugin sidebar replacements own their filters. Every thread-list header's actions menu offers New project, New section, -Organize, and Sort by. Organize selects By project, By machine, or Custom, and -its By environment toggle decides whether sibling threads sharing one worktree -collapse into a single worktree row inside their section, in every organization -mode. `sidebar.threadGrouping.environment` defaults to `auto`, which groups them +Organize, Sort, and Filter, with their current values. Organize selects By project, +By machine, or Custom. Each secondary menu offers Reset to default for its own +displayed settings. The separate `sidebar.threadGrouping.environment` preference +decides whether sibling threads sharing one worktree collapse into a single row. +It defaults to `auto`, which groups them everywhere except Custom: `bb settings ui set sidebar.threadGrouping.environment false` keeps every thread on its own row, and `true` groups them in every mode. -Sort by selects a field, and selecting it again reverses its arrow/direction. +Sort selects a field, and selecting it again reverses its arrow/direction. `sidebar.sortDirection` accepts `ascending`, `descending`, or `default`. The default preserves each field's original order (newest first for dates, A–Z for titles). For example: `bb settings ui set sidebar.sortDirection ascending`. diff --git a/plugins/bb-guide/skills/bb-cli/references/app-settings.md b/plugins/bb-guide/skills/bb-cli/references/app-settings.md index 4724f269d88..dcf12cf5c6f 100644 --- a/plugins/bb-guide/skills/bb-cli/references/app-settings.md +++ b/plugins/bb-guide/skills/bb-cli/references/app-settings.md @@ -22,7 +22,8 @@ every window and client sees the same value. - `sidebar.threadLifecycles` selects a nonempty distinct list of `active`, `draft`, and `archived` in the built-in sidebar. Default/reset is `["active"]`. Use `bb settings ui set sidebar.threadLifecycles '["active","draft"]'` to - show active and saved draft groups. Archived pages load only while selected; + show saved drafts above the existing active hierarchy. Selected archived rows + retain their hierarchy placement and offer a restore action. Archived pages load only while selected; plugin sidebar replacements keep ownership of their rendering. - `sidebar.organizationMode` defaults to Custom (`chronological`) when unset; existing server and legacy browser choices are preserved. @@ -30,8 +31,8 @@ every window and client sees the same value. one worktree environment collapse into a single worktree row inside their section: `true` groups them and `false` keeps every thread on its own row, in every organization mode. The default `auto` groups them in By project and By - machine and leaves them flat in Custom. The thread-list header's Organize menu - exposes it under Groups as By environment. Each `sidebar.threadGrouping.*` key + machine and leaves them flat in Custom. This preference remains available + through settings and the CLI. Each `sidebar.threadGrouping.*` key toggles one grouping dimension independently. - `bb settings ui list [--json]` prints every key with its value, revision, and description; `bb settings ui get [--json]` prints one. From e55cc0fdcb1840676c8cd23bea088ba88602331c Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 10:58:02 -0700 Subject: [PATCH 19/57] Match sidebar lifecycle test harness to retained sections --- .../sidebar/SidebarThreadLifecycles.test.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx b/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx index 5a65f1a9c8e..13fd0661492 100644 --- a/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx +++ b/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx @@ -18,6 +18,7 @@ import { buildPinnedSidebarState, buildProjectThreadGroups, buildSectionThreadList, + buildSidebarEntitySectionId, } from "@bb/client-core"; import { TooltipProvider } from "@bb/shared-ui/tooltip"; import { makeThreadListEntry } from "@bb/test-helpers/domain-fixtures"; @@ -124,12 +125,17 @@ function LifecycleContents({ empty }: { empty: boolean }) { threads: lifecycles.threads, }} compareThreads={() => 0} - sections={[]} + sections={lifecycles.threads.some((thread) => thread.sectionId === "archive-section") + ? [{ id: "archive-section", name: "Review" }] + : []} collapsedThreadIds={new Set()} collapsedEnvironmentIds={new Set()} onToggleThreadCollapsed={vi.fn()} onToggleEnvironmentCollapsed={vi.fn()} - topLevelSectionOrder={["threads"]} + topLevelSectionOrder={[ + "threads", + buildSidebarEntitySectionId("section", "archive-section"), + ]} onTopLevelSectionOrderChange={vi.fn()} pinnedReorderPending={false} pinnedThreads={[]} @@ -312,7 +318,10 @@ describe("sidebar lifecycle placement", () => { fireEvent.click(screen.getByRole("menuitemcheckbox", { name: lifecycle === "draft" ? "Drafts" : "Archived" })); expect(store.get(sidebarThreadLifecyclesAtom)).toEqual(["active"]); expect(screen.getByText("No threads")).toBeTruthy(); - const trigger = screen.getByRole("button", { + for (const menu of screen.queryAllByRole("menu").reverse()) { + fireEvent.keyDown(menu, { key: "Escape" }); + } + const trigger = await screen.findByRole("button", { name: /^Threads actions(?:;|$)/, }); fireEvent.keyDown(trigger, { key: "Enter" }); From 85b3eed6c6d9a2d65345ec94a9af613ab0f7891f Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 11:09:25 -0700 Subject: [PATCH 20/57] Defer palette recent queries with thread search mode --- .../commands/CommandPalette.test.tsx | 19 +++++++------ .../commands/ThreadSearchPaletteMode.tsx | 2 +- .../hooks/queries/palette-thread-queries.ts | 27 +++++++++++++++++++ .../src/hooks/queries/thread-queries.test.tsx | 2 +- apps/app/src/hooks/queries/thread-queries.ts | 20 +------------- 5 files changed, 41 insertions(+), 29 deletions(-) create mode 100644 apps/app/src/hooks/queries/palette-thread-queries.ts diff --git a/apps/app/src/components/commands/CommandPalette.test.tsx b/apps/app/src/components/commands/CommandPalette.test.tsx index 65c1fbff38d..2bc7c028772 100644 --- a/apps/app/src/components/commands/CommandPalette.test.tsx +++ b/apps/app/src/components/commands/CommandPalette.test.tsx @@ -238,19 +238,22 @@ vi.mock("@/hooks/queries/sidebar-navigation-query", () => ({ }), })); +vi.mock("@/hooks/queries/palette-thread-queries", () => ({ + usePaletteRecentThreads: (lifecycle: "draft" | "archived") => ({ + data: + lifecycle === "draft" + ? modeState.draftRecents + : modeState.archivedRecents, + isLoading: false, + isError: false, + }), +})); + vi.mock("@/hooks/queries/thread-queries", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - usePaletteRecentThreads: (lifecycle: "draft" | "archived") => ({ - data: - lifecycle === "draft" - ? modeState.draftRecents - : modeState.archivedRecents, - isLoading: false, - isError: false, - }), useThreadSearch: ({ query }: { query: string }) => ({ data: modeState.searchResponse, debouncedQuery: query.trim(), diff --git a/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx b/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx index 99366af172e..3f14903b90a 100644 --- a/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx +++ b/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx @@ -29,9 +29,9 @@ import { } from "@/components/thread/ThreadLifecycleFilter"; import { paletteThreadLifecyclesAtom } from "@/lib/command-palette/palette-preferences"; import { useSidebarNavigation } from "@/hooks/queries/sidebar-navigation-query"; +import { usePaletteRecentThreads } from "@/hooks/queries/palette-thread-queries"; import { hasThreadSearchableQuery, - usePaletteRecentThreads, useThreadSearch, } from "@/hooks/queries/thread-queries"; import { useRouteNavigate } from "@/components/ui/app-route-anchor"; diff --git a/apps/app/src/hooks/queries/palette-thread-queries.ts b/apps/app/src/hooks/queries/palette-thread-queries.ts new file mode 100644 index 00000000000..61272cadbf8 --- /dev/null +++ b/apps/app/src/hooks/queries/palette-thread-queries.ts @@ -0,0 +1,27 @@ +import { useQuery } from "@tanstack/react-query"; +import type { ThreadListResponse } from "@bb/server-contract"; +import { useThreadListRealtimeSubscription } from "@/hooks/useRealtimeSubscription"; +import { sdk } from "@/lib/sdk"; +import { threadListQueryKey } from "./query-keys"; +import { + THREAD_LIST_STALE_TIME_MS, + THREAD_SEARCH_LIMIT_PER_GROUP, +} from "./thread-queries"; + +export function usePaletteRecentThreads( + lifecycle: "draft" | "archived", + { enabled }: { enabled: boolean }, +) { + useThreadListRealtimeSubscription({ enabled }); + const filters = { + archived: lifecycle === "archived", + lifecycles: [lifecycle], + limit: THREAD_SEARCH_LIMIT_PER_GROUP, + }; + return useQuery({ + queryKey: threadListQueryKey(filters), + queryFn: ({ signal }) => sdk.threads.list({ ...filters, signal }), + enabled, + staleTime: THREAD_LIST_STALE_TIME_MS, + }); +} diff --git a/apps/app/src/hooks/queries/thread-queries.test.tsx b/apps/app/src/hooks/queries/thread-queries.test.tsx index 17753cc0e9a..33bc5490687 100644 --- a/apps/app/src/hooks/queries/thread-queries.test.tsx +++ b/apps/app/src/hooks/queries/thread-queries.test.tsx @@ -24,13 +24,13 @@ import { threadSearchQueryKey, threadTimelineQueryKey, } from "./query-keys"; +import { usePaletteRecentThreads } from "./palette-thread-queries"; import { COMPACT_THREAD_TIMELINE_SEGMENT_LIMIT, didThreadDetailBootstrapRefreshAfterMount, isPendingInteractionStateUnknown, useArchivedThreads, useChildThreads, - usePaletteRecentThreads, useThreadSearch, useThread, useThreadDetailBootstrap, diff --git a/apps/app/src/hooks/queries/thread-queries.ts b/apps/app/src/hooks/queries/thread-queries.ts index 4f32aafb696..befcd0ade13 100644 --- a/apps/app/src/hooks/queries/thread-queries.ts +++ b/apps/app/src/hooks/queries/thread-queries.ts @@ -98,7 +98,7 @@ interface QueryOptions { staleTime?: number; } -const THREAD_LIST_STALE_TIME_MS = 10_000; +export const THREAD_LIST_STALE_TIME_MS = 10_000; const THREAD_SEARCH_STALE_TIME_MS = 10_000; const THREAD_DETAIL_STALE_TIME_MS = 5_000; const THREAD_MENTION_CANDIDATE_LIMIT = 200; @@ -625,24 +625,6 @@ export function useThreadSearch({ }; } -export function usePaletteRecentThreads( - lifecycle: "draft" | "archived", - { enabled }: { enabled: boolean }, -) { - useThreadListRealtimeSubscription({ enabled }); - const filters = { - archived: lifecycle === "archived", - lifecycles: [lifecycle], - limit: THREAD_SEARCH_LIMIT_PER_GROUP, - }; - return useQuery({ - queryKey: threadListQueryKey(filters), - queryFn: ({ signal }) => sdk.threads.list({ ...filters, signal }), - enabled, - staleTime: THREAD_LIST_STALE_TIME_MS, - }); -} - export function useThread(id: string, options?: QueryOptions) { const queryClient = useQueryClient(); const enabled = (options?.enabled ?? true) && Boolean(id); From bcdbe008655b2ea8bec1ed6d7555f1d656c17457 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 16:07:56 -0700 Subject: [PATCH 21/57] Quiet sidebar view controls and archived restore action --- .../sidebar/SidebarHeaderControls.test.tsx | 109 +++++++----------- .../sidebar/SidebarHeaderControls.tsx | 68 ++--------- .../sidebar/SidebarThreadLifecycles.test.tsx | 6 +- .../src/components/sidebar/ThreadRow.test.tsx | 3 +- apps/app/src/components/sidebar/ThreadRow.tsx | 2 +- docs/configuration.md | 4 +- .../src/templates/bb-guide-customization.md | 4 +- 7 files changed, 58 insertions(+), 138 deletions(-) diff --git a/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx b/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx index f956b20aab4..b63ff17e9d5 100644 --- a/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx +++ b/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx @@ -9,10 +9,7 @@ import { waitFor, } from "@testing-library/react"; import { createStore, Provider } from "jotai"; -import { - getUiPreferenceDefault, - type SidebarOrganizationMode, -} from "@bb/domain"; +import type { SidebarOrganizationMode } from "@bb/domain"; import { afterEach, describe, expect, it, vi } from "vitest"; import { TooltipProvider } from "@bb/shared-ui/tooltip"; import { SIDEBAR_CONTROL_STATE_CLASS } from "./sidebarRowClasses"; @@ -84,7 +81,7 @@ async function openMenu(label = "Pinned") { } async function openSubmenu(label: string) { - fireEvent.keyDown(screen.getByRole("menuitem", { name: new RegExp(`^${label}:`) }), { + fireEvent.keyDown(screen.getByRole("menuitem", { name: label }), { key: "ArrowRight", }); } @@ -148,9 +145,9 @@ describe("sidebar header controls", () => { ).toEqual([ "New project", "New section", - "Organize:By project", - "Sort:Updated at", - "Filter:Active", + "Organize", + "Sort by", + "Filter", "Rename", "Remove", ]); @@ -175,7 +172,7 @@ describe("sidebar header controls", () => { if (compact) fireEvent.click(trigger); else await openMenu(); const filter = await screen.findByRole("menuitem", { - name: /^Filter:/, + name: "Filter", }); if (compact) fireEvent.click(filter); else await openSubmenu("Filter"); @@ -209,71 +206,45 @@ describe("sidebar header controls", () => { expect( screen.getByRole("menuitem", { name: "New project" }), ).toBeTruthy(); - expect(screen.getByRole("menuitem", { name: /^Organize:/ })).toBeTruthy(); + expect(screen.getByRole("menuitem", { name: "Organize" })).toBeTruthy(); } }, ); - it.each([ - "organization", - "sort", - "direction", - "lifecycle", - ] as const)( - "reflects a synced %s change without changing menu semantics", - (setting) => { + it.each([false, true])( + "keeps ordinary controls and plain menu labels after synced changes (compact=%s)", + async (compact) => { + viewport.compact = compact; const { store } = setup("Pinned", false, "chronological"); const trigger = screen.getByRole("button", { name: "Pinned actions" }); expect( trigger.querySelector('[data-icon="MoreHorizontal"]'), ).toBeTruthy(); act(() => { - if (setting === "organization") - store.set(sidebarOrganizationModeAtom, "machine"); - if (setting === "sort") - store.set(sidebarChronologicalSortAtom, "created"); - if (setting === "direction") - store.set(sidebarSortDirectionAtom, "ascending"); - if (setting === "lifecycle") - store.set(sidebarThreadLifecyclesAtom, ["draft"]); + store.set(sidebarOrganizationModeAtom, "machine"); + store.set(sidebarChronologicalSortAtom, "created"); + store.set(sidebarSortDirectionAtom, "ascending"); + store.set(sidebarThreadLifecyclesAtom, ["draft", "archived"]); }); expect( - trigger.querySelector('[data-icon="FilterHorizontal"]'), + trigger.querySelector('[data-icon="MoreHorizontal"]'), ).toBeTruthy(); - expect(trigger.classList.contains("bg-state-active")).toBe(true); - expect(trigger.getAttribute("aria-label")).toMatch( - /Pinned actions; (Organize|Sort|Filter):/, - ); + expect(trigger.classList.contains("bg-state-active")).toBe(false); + expect(trigger.getAttribute("aria-label")).toBe("Pinned actions"); expect(trigger.getAttribute("aria-haspopup")).toBe("menu"); expect(trigger.getAttribute("aria-expanded")).toBe("false"); expect(trigger.hasAttribute("aria-pressed")).toBe(false); - act(() => { - store.set( - sidebarOrganizationModeAtom, - getUiPreferenceDefault("sidebar.organizationMode"), - ); - store.set( - sidebarEnvironmentGroupingAtom, - getUiPreferenceDefault("sidebar.threadGrouping.environment"), - ); - store.set( - sidebarChronologicalSortAtom, - getUiPreferenceDefault("sidebar.chronologicalSort"), - ); - store.set( - sidebarSortDirectionAtom, - getUiPreferenceDefault("sidebar.sortDirection"), - ); - store.set( - sidebarThreadLifecyclesAtom, - getUiPreferenceDefault("sidebar.threadLifecycles"), - ); - }); - expect(trigger.getAttribute("aria-label")).toBe("Pinned actions"); - expect( - trigger.querySelector('[data-icon="MoreHorizontal"]'), - ).toBeTruthy(); - expect(trigger.classList.contains("bg-state-active")).toBe(false); + expect(trigger.hasAttribute("aria-describedby")).toBe(false); + if (compact) fireEvent.click(trigger); + else await openMenu(); + await screen.findByRole("menuitem", { name: "Filter" }); + expect(screen.getAllByRole("menuitem").map((item) => item.textContent)).toEqual([ + "New project", "New section", "Organize", "Sort by", "Filter", + ]); + for (const item of screen.getAllByRole("menuitem")) { + expect(item.querySelector('[data-icon="ArrowUp"], [data-icon="ArrowDown"]')).toBeNull(); + } + expect(screen.queryByRole("tooltip")).toBeNull(); }, ); @@ -290,7 +261,7 @@ describe("sidebar header controls", () => { .classList.contains("bg-state-active"), ).toBe(false); await openMenu(); - await openSubmenu("Sort"); + await openSubmenu("Sort by"); const reset = await screen.findByRole("menuitem", { name: "Reset to default" }); expect(reset.getAttribute("aria-disabled")).toBe("true"); expect( @@ -314,17 +285,17 @@ describe("sidebar header controls", () => { store.set(sidebarSortDirectionAtom, "descending"); store.set(sidebarThreadLifecyclesAtom, ["draft", "archived"]); }); - const trigger = screen.getByRole("button", { name: /^Pinned actions;/ }); + const trigger = screen.getByRole("button", { name: "Pinned actions" }); if (compact) fireEvent.click(trigger); else await openMenu(); - for (const label of ["Organize", "Sort", "Filter"]) { - const page = await screen.findByRole("menuitem", { name: new RegExp(`^${label}:`) }); + for (const label of ["Organize", "Sort by", "Filter"]) { + const page = await screen.findByRole("menuitem", { name: label }); if (compact) fireEvent.click(page); else await openSubmenu(label); const reset = await screen.findByRole("menuitem", { name: "Reset to default" }); expect(reset.getAttribute("aria-disabled")).not.toBe("true"); if (!compact) { - const submenu = screen.getByRole("menu", { name: new RegExp(`^${label}:`) }); + const submenu = screen.getByRole("menu", { name: label }); expect(submenu.classList.contains("w-max")).toBe(true); expect(submenu.classList.contains("min-w-28")).toBe(true); expect(submenu.classList.contains("max-w-64")).toBe(true); @@ -407,9 +378,9 @@ describe("sidebar header controls", () => { expect(trigger.classList.contains("bg-state-active")).toBe(false); expect(trigger.hasAttribute("aria-describedby")).toBe(false); await openMenu(); - expect(screen.getByRole("menuitem", { name: "Organize: Custom" })).toBeTruthy(); - expect(screen.getByRole("menuitem", { name: "Sort: Updated at, descending" })).toBeTruthy(); - expect(screen.getByRole("menuitem", { name: "Filter: Active" })).toBeTruthy(); + expect(screen.getByRole("menuitem", { name: "Organize" })).toBeTruthy(); + expect(screen.getByRole("menuitem", { name: "Sort by" })).toBeTruthy(); + expect(screen.getByRole("menuitem", { name: "Filter" })).toBeTruthy(); await openSubmenu("Organize"); expect(screen.queryByRole("group", { name: "Groups" })).toBeNull(); expect(screen.queryByText("By environment")).toBeNull(); @@ -423,7 +394,7 @@ describe("sidebar header controls", () => { it("toggles sort direction without closing and resets direction for a different field", async () => { const { store } = setup(); await openMenu(); - await openSubmenu("Sort"); + await openSubmenu("Sort by"); const updated = await screen.findByRole("menuitemradio", { name: "Updated at, descending. Sort ascending", }); @@ -455,7 +426,7 @@ describe("sidebar header controls", () => { fireEvent.click( screen.getByRole("button", { name: /^Pinned actions(?:;|$)/ }), ); - fireEvent.click(await screen.findByRole("menuitem", { name: /^Sort:/ })); + fireEvent.click(await screen.findByRole("menuitem", { name: "Sort by" })); fireEvent.click( await screen.findByRole("menuitemradio", { name: /Updated at\s*, descending\. Sort ascending/, @@ -470,7 +441,7 @@ describe("sidebar header controls", () => { .getAttribute("aria-checked"), ).toBe("true"); fireEvent.click(screen.getByRole("menuitem", { name: "Back" })); - fireEvent.click(screen.getByRole("menuitem", { name: /^Organize:/ })); + fireEvent.click(screen.getByRole("menuitem", { name: "Organize" })); fireEvent.click(screen.getByRole("menuitemradio", { name: "Custom" })); expect(store.get(sidebarOrganizationModeAtom)).toBe("chronological"); expect( diff --git a/apps/app/src/components/sidebar/SidebarHeaderControls.tsx b/apps/app/src/components/sidebar/SidebarHeaderControls.tsx index 62c1aa56e81..67e6a7c5b0b 100644 --- a/apps/app/src/components/sidebar/SidebarHeaderControls.tsx +++ b/apps/app/src/components/sidebar/SidebarHeaderControls.tsx @@ -8,7 +8,6 @@ import { } from "react"; import { useAtom } from "jotai"; import { getUiPreferenceDefault } from "@bb/domain"; -import { cn } from "@bb/shared-ui/lib/utils"; import { Button } from "@bb/shared-ui/button"; import { Icon } from "@bb/shared-ui/icon"; import { COARSE_POINTER_ICON_SIZE_CLASS } from "@bb/shared-ui/coarse-pointer-sizing"; @@ -30,7 +29,6 @@ import { sidebarSortDirectionAtom, sidebarThreadLifecyclesAtom, } from "./sidebarCollapsedAtoms"; -import { THREAD_LIFECYCLE_OPTIONS } from "@/components/thread/ThreadLifecycleFilter"; import { SidebarControlButton, SidebarRowControls } from "./SidebarRowControls"; import { SIDEBAR_CONTROL_BUTTON_CLASS } from "./sidebarRowClasses"; @@ -101,26 +99,6 @@ function useSidebarViewSettings() { lifecycles.length !== defaultLifecycles.length || lifecycles.some((value) => !defaultLifecycles.includes(value)), }; - const values = { - organize: SIDEBAR_ORGANIZE_OPTIONS.find( - (option) => option.mode === organization, - )!.label, - sort: SIDEBAR_SORT_OPTIONS.find( - (option) => option.sort === selectedSort, - )!.label, - filter: THREAD_LIFECYCLE_OPTIONS.filter((option) => - lifecycles.includes(option.value), - ) - .map((option) => option.label) - .join(", "), - }; - const summary = [ - changed.organize && `Organize: ${values.organize}`, - changed.sort && `Sort: ${values.sort}, ${direction}`, - changed.filter && `Filter: ${values.filter}`, - ] - .filter(Boolean) - .join("; "); return { lifecycles, setLifecycles, @@ -130,10 +108,7 @@ function useSidebarViewSettings() { savedDirection, setDirection, selectedSort, - direction, - values, changed, - summary, }; } export function SidebarHeaderControls({ @@ -153,10 +128,6 @@ export function SidebarHeaderControls({ }) { const creation = useContext(HeaderCreationContext); const settings = useSidebarViewSettings(); - const { summary } = settings; - const triggerLabel = summary - ? `${label} actions; ${summary}` - : `${label} actions`; const compact = useIsCompactViewport(); const [page, setPage] = useState<"organize" | "sort" | "filter" | null>(null); const changeOpen = (next: boolean) => { @@ -182,15 +153,11 @@ export function SidebarHeaderControls({ type="button" variant="ghost" size="icon" - aria-label={triggerLabel} - className={cn( - SIDEBAR_CONTROL_BUTTON_CLASS, - summary && - "bg-state-active text-foreground hover:bg-state-active hover:text-foreground focus-visible:text-foreground data-[state=open]:text-foreground", - )} + aria-label={`${label} actions`} + className={SIDEBAR_CONTROL_BUTTON_CLASS} > @@ -201,7 +168,7 @@ export function SidebarHeaderControls({ page === "organize" ? "Organize" : page === "sort" - ? "Sort" + ? "Sort by" : page === "filter" ? "Filter" : `${label} actions` @@ -250,7 +217,7 @@ export function SidebarHeaderControls({ {( [ { page: "organize", label: "Organize", icon: "Layers" }, - { page: "sort", label: "Sort", icon: "ArrowUpDown" }, + { page: "sort", label: "Sort by", icon: "ArrowUpDown" }, { page: "filter", label: "Filter", @@ -261,39 +228,20 @@ export function SidebarHeaderControls({ compact ? ( { event.preventDefault(); setPage(item.page); }} > - {item.label}: - - {settings.values[item.page]} - - {item.page === "sort" && ( - - )} + {item.label} ) : ( - + - {item.label}: - - {settings.values[item.page]} - - {item.page === "sort" && ( - - )} + {item.label} diff --git a/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx b/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx index 13fd0661492..e6b6f4b7d7d 100644 --- a/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx +++ b/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx @@ -280,7 +280,7 @@ describe("sidebar lifecycle placement", () => { { key: "Enter" }, ); expect( - await screen.findByRole("menuitem", { name: /^Filter:/ }), + await screen.findByRole("menuitem", { name: "Filter" }), ).toBeTruthy(); }, ); @@ -303,7 +303,7 @@ describe("sidebar lifecycle placement", () => { }, ); fireEvent.keyDown( - await screen.findByRole("menuitem", { name: /^Filter:/ }), + await screen.findByRole("menuitem", { name: "Filter" }), { key: "ArrowRight", }, @@ -326,7 +326,7 @@ describe("sidebar lifecycle placement", () => { }); fireEvent.keyDown(trigger, { key: "Enter" }); expect( - await screen.findByRole("menuitem", { name: /^Filter:/ }), + await screen.findByRole("menuitem", { name: "Filter" }), ).toBeTruthy(); }, ); diff --git a/apps/app/src/components/sidebar/ThreadRow.test.tsx b/apps/app/src/components/sidebar/ThreadRow.test.tsx index 04f96930f0f..390bed826d7 100644 --- a/apps/app/src/components/sidebar/ThreadRow.test.tsx +++ b/apps/app/src/components/sidebar/ThreadRow.test.tsx @@ -246,7 +246,8 @@ describe("ThreadRow", () => { ); const restore = screen.getByRole("button", { name: "Unarchive thread" }); expect(restore.querySelector('[data-icon="Archive"]')).toBeTruthy(); - expect(restore.classList.contains("bg-state-active")).toBe(true); + expect(restore.classList.contains("bg-state-hover")).toBe(true); + expect(restore.classList.contains("bg-state-active")).toBe(false); expect(restore.closest("[data-sidebar-hover-actions-open]")).toBeNull(); expect(screen.queryByRole("button", { name: "Archive thread" })).toBeNull(); fireEvent.pointerDown(restore, { pointerType: "touch", button: 0 }); diff --git a/apps/app/src/components/sidebar/ThreadRow.tsx b/apps/app/src/components/sidebar/ThreadRow.tsx index 3518f07918a..e50878ce2d6 100644 --- a/apps/app/src/components/sidebar/ThreadRow.tsx +++ b/apps/app/src/components/sidebar/ThreadRow.tsx @@ -289,7 +289,7 @@ function ThreadRestoreStatusAction({ thread }: { thread: ThreadListEntry }) { disabled={pending > 0} className={cn( SIDEBAR_CONTROL_BUTTON_CLASS, - "bg-state-active text-foreground hover:bg-state-active hover:text-foreground", + "bg-state-hover", )} /> diff --git a/docs/configuration.md b/docs/configuration.md index c63e0985966..bb6c8c6d690 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -707,8 +707,8 @@ The built-in sidebar defaults to Active. `sidebar.threadLifecycles` selects Active, Drafts, and Archived. Drafts appear above the existing hierarchy; selected archived threads retain their section, project, machine, and pin placement. Choose Filter in a sidebar header's combined actions menu to change the selection. -The combined control highlights non-default organization, sorting, or lifecycle choices. -Each secondary menu shows its current value and has a Reset to default action +The combined menu offers Organize, Sort by, and Filter. +Each secondary menu has a Reset to default action for its displayed settings. The environment-grouping preference remains available through settings and the CLI. Archived rows have a persistent Archive button that restores the thread without navigating away. diff --git a/packages/templates/src/templates/bb-guide-customization.md b/packages/templates/src/templates/bb-guide-customization.md index 2ae916431ec..9e556305ec5 100644 --- a/packages/templates/src/templates/bb-guide-customization.md +++ b/packages/templates/src/templates/bb-guide-customization.md @@ -291,14 +291,14 @@ action. Archived pages load only while selected. For example: Reset the key to restore Active. Plugin sidebar replacements own their filters. Every thread-list header's actions menu offers New project, New section, -Organize, Sort, and Filter, with their current values. Organize selects By project, +Organize, Sort by, and Filter. Organize selects By project, By machine, or Custom. Each secondary menu offers Reset to default for its own displayed settings. The separate `sidebar.threadGrouping.environment` preference decides whether sibling threads sharing one worktree collapse into a single row. It defaults to `auto`, which groups them everywhere except Custom: `bb settings ui set sidebar.threadGrouping.environment false` keeps every thread on its own row, and `true` groups them in every mode. -Sort selects a field, and selecting it again reverses its arrow/direction. +Sort by selects a field, and selecting it again reverses its arrow/direction. `sidebar.sortDirection` accepts `ascending`, `descending`, or `default`. The default preserves each field's original order (newest first for dates, A–Z for titles). For example: `bb settings ui set sidebar.sortDirection ascending`. From bc4be2677e83a4a8bfbf7a9bc799bb74558c7698 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 16:46:43 -0700 Subject: [PATCH 22/57] Preserve Organize controls and simplify lifecycle actions --- .../sidebar/SidebarHeaderControls.test.tsx | 54 +++++++++---------- .../sidebar/SidebarHeaderControls.tsx | 20 ++++--- .../components/sidebar/SidebarViewItems.tsx | 31 ++++++++--- .../src/components/sidebar/ThreadRow.test.tsx | 2 +- apps/app/src/components/sidebar/ThreadRow.tsx | 5 +- docs/configuration.md | 11 ++-- .../src/templates/bb-guide-customization.md | 5 +- .../skills/bb-cli/references/app-settings.md | 4 +- 8 files changed, 74 insertions(+), 58 deletions(-) diff --git a/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx b/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx index b63ff17e9d5..5371e2ada18 100644 --- a/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx +++ b/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx @@ -87,7 +87,7 @@ async function openSubmenu(label: string) { } describe("sidebar header controls", () => { - it("supports keyboard selection and Reset when a submenu first loads", async () => { + it("supports keyboard selection when a submenu first loads", async () => { const { store } = setup("Pinned", false, "chronological"); await openMenu(); await openSubmenu("Organize"); @@ -98,11 +98,7 @@ describe("sidebar header controls", () => { await waitFor(() => expect(document.activeElement).toBe(project)); fireEvent.keyDown(project, { key: "Enter" }); expect(store.get(sidebarOrganizationModeAtom)).toBe("project"); - const reset = screen.getByRole("menuitem", { name: "Reset to default" }); - reset.focus(); - fireEvent.keyDown(reset, { key: "Enter" }); - expect(store.get(sidebarOrganizationModeAtom)).toBe("chronological"); - expect(reset.getAttribute("aria-disabled")).toBe("true"); + expect(screen.queryByRole("menuitem", { name: /^Reset/ })).toBeNull(); }); it("keeps the primary before overflow and applies the shared control state", async () => { @@ -179,6 +175,7 @@ describe("sidebar header controls", () => { const active = await screen.findByRole("menuitemcheckbox", { name: "Active", }); + expect(screen.queryByRole("menuitem", { name: "Reset" })).toBeNull(); expect(active.getAttribute("aria-disabled")).toBe("true"); fireEvent.click(active); expect(store.get(sidebarThreadLifecyclesAtom)).toEqual(["active"]); @@ -262,8 +259,10 @@ describe("sidebar header controls", () => { ).toBe(false); await openMenu(); await openSubmenu("Sort by"); - const reset = await screen.findByRole("menuitem", { name: "Reset to default" }); - expect(reset.getAttribute("aria-disabled")).toBe("true"); + await screen.findByRole("menuitemradio", { + name: "Updated at, descending. Sort ascending", + }); + expect(screen.queryByRole("menuitem", { name: "Reset" })).toBeNull(); expect( screen .getByRole("menuitemradio", { @@ -288,11 +287,11 @@ describe("sidebar header controls", () => { const trigger = screen.getByRole("button", { name: "Pinned actions" }); if (compact) fireEvent.click(trigger); else await openMenu(); - for (const label of ["Organize", "Sort by", "Filter"]) { + for (const label of ["Sort by", "Filter"]) { const page = await screen.findByRole("menuitem", { name: label }); if (compact) fireEvent.click(page); else await openSubmenu(label); - const reset = await screen.findByRole("menuitem", { name: "Reset to default" }); + const reset = await screen.findByRole("menuitem", { name: "Reset" }); expect(reset.getAttribute("aria-disabled")).not.toBe("true"); if (!compact) { const submenu = screen.getByRole("menu", { name: label }); @@ -301,27 +300,19 @@ describe("sidebar header controls", () => { expect(submenu.classList.contains("max-w-64")).toBe(true); } fireEvent.click(reset); - expect( - screen - .getByRole("menuitem", { name: "Reset to default" }) - .getAttribute("aria-disabled"), - ).toBe("true"); - expect(store.get(sidebarOrganizationModeAtom)).toBe("chronological"); + expect(screen.queryByRole("menuitem", { name: "Reset" })).toBeNull(); + expect(store.get(sidebarOrganizationModeAtom)).toBe("machine"); expect(store.get(sidebarEnvironmentGroupingAtom)).toBe(false); - expect(store.get(sidebarChronologicalSortAtom)).toBe( - label === "Organize" ? "alpha" : "updated", - ); - expect(store.get(sidebarSortDirectionAtom)).toBe( - label === "Organize" ? "descending" : "default", - ); + expect(store.get(sidebarChronologicalSortAtom)).toBe("updated"); + expect(store.get(sidebarSortDirectionAtom)).toBe("default"); expect(store.get(sidebarThreadLifecyclesAtom)).toEqual( label === "Filter" ? ["active"] : ["draft", "archived"], ); if (compact) fireEvent.click(screen.getByRole("menuitem", { name: "Back" })); - else fireEvent.keyDown(reset, { key: "ArrowLeft" }); + else fireEvent.keyDown(screen.getByRole("menu", { name: label }), { key: "ArrowLeft" }); await waitFor(() => - expect(screen.queryByRole("menuitem", { name: "Reset to default" })).toBeNull(), + expect(screen.queryByRole("menuitem", { name: "Reset" })).toBeNull(), ); await screen.findByRole("menuitem", { name: "New project" }); } @@ -371,7 +362,7 @@ describe("sidebar header controls", () => { ); }); - it("keeps saved environment grouping outside the menu and its reset", async () => { + it("preserves the existing Organize groups without a Reset action", async () => { const { store } = setup("Pinned", false, "chronological"); act(() => store.set(sidebarEnvironmentGroupingAtom, true)); const trigger = screen.getByRole("button", { name: "Pinned actions" }); @@ -382,12 +373,15 @@ describe("sidebar header controls", () => { expect(screen.getByRole("menuitem", { name: "Sort by" })).toBeTruthy(); expect(screen.getByRole("menuitem", { name: "Filter" })).toBeTruthy(); await openSubmenu("Organize"); - expect(screen.queryByRole("group", { name: "Groups" })).toBeNull(); - expect(screen.queryByText("By environment")).toBeNull(); + const grouping = await screen.findByRole("menuitemcheckbox", { name: "By environment" }); + expect(screen.getByRole("group", { name: "Groups" })).toBeTruthy(); + expect(grouping.getAttribute("aria-checked")).toBe("true"); + fireEvent.click(grouping); + expect(store.get(sidebarEnvironmentGroupingAtom)).toBe(false); fireEvent.click(await screen.findByRole("menuitemradio", { name: "By project" })); - fireEvent.click(screen.getByRole("menuitem", { name: "Reset to default" })); - expect(store.get(sidebarOrganizationModeAtom)).toBe("chronological"); - expect(store.get(sidebarEnvironmentGroupingAtom)).toBe(true); + expect(store.get(sidebarOrganizationModeAtom)).toBe("project"); + expect(store.get(sidebarEnvironmentGroupingAtom)).toBe(false); + expect(screen.queryByRole("menuitem", { name: /^Reset/ })).toBeNull(); expect(screen.queryByRole("tooltip")).toBeNull(); }); diff --git a/apps/app/src/components/sidebar/SidebarHeaderControls.tsx b/apps/app/src/components/sidebar/SidebarHeaderControls.tsx index 67e6a7c5b0b..1148390fd43 100644 --- a/apps/app/src/components/sidebar/SidebarHeaderControls.tsx +++ b/apps/app/src/components/sidebar/SidebarHeaderControls.tsx @@ -6,7 +6,7 @@ import { useState, type ReactNode, } from "react"; -import { useAtom } from "jotai"; +import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { getUiPreferenceDefault } from "@bb/domain"; import { Button } from "@bb/shared-ui/button"; import { Icon } from "@bb/shared-ui/icon"; @@ -28,6 +28,8 @@ import { sidebarChronologicalSortAtom, sidebarSortDirectionAtom, sidebarThreadLifecyclesAtom, + sidebarGroupThreadsByEnvironmentAtom, + sidebarEnvironmentGroupingAtom, } from "./sidebarCollapsedAtoms"; import { SidebarControlButton, SidebarRowControls } from "./SidebarRowControls"; import { SIDEBAR_CONTROL_BUTTON_CLASS } from "./sidebarRowClasses"; @@ -72,6 +74,8 @@ function useSidebarViewSettings() { const [organization, setOrganization] = useAtom(sidebarOrganizationModeAtom); const [sort, setSort] = useAtom(sidebarChronologicalSortAtom); const [savedDirection, setDirection] = useAtom(sidebarSortDirectionAtom); + const setEnvironmentGrouping = useSetAtom(sidebarEnvironmentGroupingAtom); + const groupByEnvironment = useAtomValue(sidebarGroupThreadsByEnvironmentAtom); const selectedSort = sort === "none" ? "updated" : sort; const direction = savedDirection === "default" @@ -79,14 +83,10 @@ function useSidebarViewSettings() { ? "ascending" : "descending" : savedDirection; - const defaultOrganization = getUiPreferenceDefault( - "sidebar.organizationMode", - ); const defaultSort = getUiPreferenceDefault("sidebar.chronologicalSort"); const defaultDirection = getUiPreferenceDefault("sidebar.sortDirection"); const defaultLifecycles = getUiPreferenceDefault("sidebar.threadLifecycles"); const changed = { - organize: organization !== defaultOrganization, sort: selectedSort !== defaultSort || direction !== @@ -104,6 +104,8 @@ function useSidebarViewSettings() { setLifecycles, organization, setOrganization, + groupByEnvironment, + setEnvironmentGrouping, setSort, savedDirection, setDirection, @@ -244,7 +246,13 @@ export function SidebarHeaderControls({ {item.label} - + Loading… diff --git a/apps/app/src/components/sidebar/SidebarViewItems.tsx b/apps/app/src/components/sidebar/SidebarViewItems.tsx index a44074e16ba..84565ba4ac3 100644 --- a/apps/app/src/components/sidebar/SidebarViewItems.tsx +++ b/apps/app/src/components/sidebar/SidebarViewItems.tsx @@ -20,23 +20,22 @@ export function SidebarViewItems({ setLifecycles, organization, setOrganization, + groupByEnvironment, + setEnvironmentGrouping, setSort, savedDirection, setDirection, selectedSort, changed, } = settings; - const reset = ( + const reset = page !== "organize" && changed[page] ? ( <> { event.preventDefault(); - if (page === "organize") { - setOrganization(getUiPreferenceDefault("sidebar.organizationMode")); - } else if (page === "sort") { + if (page === "sort") { setSort(getUiPreferenceDefault("sidebar.chronologicalSort")); setDirection(getUiPreferenceDefault("sidebar.sortDirection")); } else { @@ -44,10 +43,10 @@ export function SidebarViewItems({ } }} > - Reset to default + Reset - ); + ) : null; if (page === "filter") { return ( <> @@ -85,7 +84,23 @@ export function SidebarViewItems({ ))} - {reset} + + + Groups + { + event.preventDefault(); + setEnvironmentGrouping(!groupByEnvironment); + }} + > + By environment + + {groupByEnvironment && } + + + ); } diff --git a/apps/app/src/components/sidebar/ThreadRow.test.tsx b/apps/app/src/components/sidebar/ThreadRow.test.tsx index 390bed826d7..58c2b69e6d4 100644 --- a/apps/app/src/components/sidebar/ThreadRow.test.tsx +++ b/apps/app/src/components/sidebar/ThreadRow.test.tsx @@ -246,7 +246,7 @@ describe("ThreadRow", () => { ); const restore = screen.getByRole("button", { name: "Unarchive thread" }); expect(restore.querySelector('[data-icon="Archive"]')).toBeTruthy(); - expect(restore.classList.contains("bg-state-hover")).toBe(true); + expect(restore.classList.contains("bg-state-hover")).toBe(false); expect(restore.classList.contains("bg-state-active")).toBe(false); expect(restore.closest("[data-sidebar-hover-actions-open]")).toBeNull(); expect(screen.queryByRole("button", { name: "Archive thread" })).toBeNull(); diff --git a/apps/app/src/components/sidebar/ThreadRow.tsx b/apps/app/src/components/sidebar/ThreadRow.tsx index e50878ce2d6..8c8581087cd 100644 --- a/apps/app/src/components/sidebar/ThreadRow.tsx +++ b/apps/app/src/components/sidebar/ThreadRow.tsx @@ -287,10 +287,7 @@ function ThreadRestoreStatusAction({ thread }: { thread: ThreadListEntry }) { thread={thread} icon="Archive" disabled={pending > 0} - className={cn( - SIDEBAR_CONTROL_BUTTON_CLASS, - "bg-state-hover", - )} + className={SIDEBAR_CONTROL_BUTTON_CLASS} /> ); diff --git a/docs/configuration.md b/docs/configuration.md index bb6c8c6d690..41c2194cb0d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -708,13 +708,13 @@ Active, Drafts, and Archived. Drafts appear above the existing hierarchy; selected archived threads retain their section, project, machine, and pin placement. Choose Filter in a sidebar header's combined actions menu to change the selection. The combined menu offers Organize, Sort by, and Filter. -Each secondary menu has a Reset to default action -for its displayed settings. The environment-grouping preference remains available -through settings and the CLI. Archived rows have a persistent Archive button +Sort by and Filter show Reset only when their displayed settings differ from the +default. Organize retains its Sections choices and Groups → By environment toggle. +Archived rows have a persistent Archive icon that restores the thread without navigating away. Drafts come from the available unarchived bootstrap; Archived loads pages only while selected. For example, `bb settings ui set sidebar.threadLifecycles -'["active","draft"]'` shows active and saved draft threads. Reset to default restores +'["active","draft"]'` shows active and saved draft threads. Reset restores `["active"]`. Plugin sidebar replacements own their rendering. `sidebar.threadGrouping.environment` decides whether two or more sibling threads @@ -722,7 +722,8 @@ that share one worktree environment collapse into a single worktree row inside their section. `true` groups them and `false` keeps every thread on its own row, in every organization mode. The default, `auto`, groups them in **By project** and **By machine** and leaves them flat in **Custom**, which is how each mode -behaved before the preference existed. Set this preference through settings or +behaved before the preference existed. Set this preference through Organize → +Groups → By environment, settings, or `bb settings ui set sidebar.threadGrouping.environment true`; an explicit `true` or `false` applies to every mode. diff --git a/packages/templates/src/templates/bb-guide-customization.md b/packages/templates/src/templates/bb-guide-customization.md index 9e556305ec5..e13c7189232 100644 --- a/packages/templates/src/templates/bb-guide-customization.md +++ b/packages/templates/src/templates/bb-guide-customization.md @@ -292,8 +292,9 @@ Reset the key to restore Active. Plugin sidebar replacements own their filters. Every thread-list header's actions menu offers New project, New section, Organize, Sort by, and Filter. Organize selects By project, -By machine, or Custom. Each secondary menu offers Reset to default for its own -displayed settings. The separate `sidebar.threadGrouping.environment` preference +By machine, or Custom and retains Groups → By environment. Sort by and Filter +show Reset only when their displayed settings differ from the default. +The separate `sidebar.threadGrouping.environment` preference decides whether sibling threads sharing one worktree collapse into a single row. It defaults to `auto`, which groups them everywhere except Custom: `bb settings ui set sidebar.threadGrouping.environment diff --git a/plugins/bb-guide/skills/bb-cli/references/app-settings.md b/plugins/bb-guide/skills/bb-cli/references/app-settings.md index dcf12cf5c6f..2a8d3ec11c4 100644 --- a/plugins/bb-guide/skills/bb-cli/references/app-settings.md +++ b/plugins/bb-guide/skills/bb-cli/references/app-settings.md @@ -31,8 +31,8 @@ every window and client sees the same value. one worktree environment collapse into a single worktree row inside their section: `true` groups them and `false` keeps every thread on its own row, in every organization mode. The default `auto` groups them in By project and By - machine and leaves them flat in Custom. This preference remains available - through settings and the CLI. Each `sidebar.threadGrouping.*` key + machine and leaves them flat in Custom. Set it through Organize → Groups → + By environment, settings, or the CLI. Each `sidebar.threadGrouping.*` key toggles one grouping dimension independently. - `bb settings ui list [--json]` prints every key with its value, revision, and description; `bb settings ui get [--json]` prints one. From 97842a593fa093d8134ab1e270fdf88e9845d99b Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 17:17:48 -0700 Subject: [PATCH 23/57] Remove sidebar sort and filter reset actions --- .../sidebar/SidebarHeaderControls.test.tsx | 127 ++++++------------ .../sidebar/SidebarHeaderControls.tsx | 24 ---- .../components/sidebar/SidebarViewItems.tsx | 123 +++++++---------- docs/configuration.md | 7 +- .../src/templates/bb-guide-customization.md | 5 +- 5 files changed, 94 insertions(+), 192 deletions(-) diff --git a/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx b/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx index 5371e2ada18..7940c35862f 100644 --- a/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx +++ b/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx @@ -192,6 +192,7 @@ describe("sidebar header controls", () => { "draft", "archived", ]); + expect(screen.queryByRole("menuitem", { name: /^Reset/ })).toBeNull(); expect(store.get(sidebarOrganizationModeAtom)).toBe("project"); expect(store.get(sidebarChronologicalSortAtom)).toBe("updated"); if (compact) { @@ -245,18 +246,12 @@ describe("sidebar header controls", () => { }, ); - it("treats legacy none, explicit descending, and equivalent grouping as defaults", async () => { + it("keeps legacy none equivalent to Updated at", async () => { const { store } = setup("Pinned", false, "chronological"); act(() => { store.set(sidebarChronologicalSortAtom, "none"); store.set(sidebarSortDirectionAtom, "descending"); - store.set(sidebarEnvironmentGroupingAtom, false); }); - expect( - screen - .getByRole("button", { name: "Pinned actions" }) - .classList.contains("bg-state-active"), - ).toBe(false); await openMenu(); await openSubmenu("Sort by"); await screen.findByRole("menuitemradio", { @@ -272,57 +267,6 @@ describe("sidebar header controls", () => { ).toBe("true"); }); - it.each([false, true])( - "resets each family independently and stays in its menu (compact=%s)", - async (compact) => { - viewport.compact = compact; - const { store } = setup("Pinned", false, "chronological"); - act(() => { - store.set(sidebarOrganizationModeAtom, "machine"); - store.set(sidebarEnvironmentGroupingAtom, false); - store.set(sidebarChronologicalSortAtom, "alpha"); - store.set(sidebarSortDirectionAtom, "descending"); - store.set(sidebarThreadLifecyclesAtom, ["draft", "archived"]); - }); - const trigger = screen.getByRole("button", { name: "Pinned actions" }); - if (compact) fireEvent.click(trigger); - else await openMenu(); - for (const label of ["Sort by", "Filter"]) { - const page = await screen.findByRole("menuitem", { name: label }); - if (compact) fireEvent.click(page); - else await openSubmenu(label); - const reset = await screen.findByRole("menuitem", { name: "Reset" }); - expect(reset.getAttribute("aria-disabled")).not.toBe("true"); - if (!compact) { - const submenu = screen.getByRole("menu", { name: label }); - expect(submenu.classList.contains("w-max")).toBe(true); - expect(submenu.classList.contains("min-w-28")).toBe(true); - expect(submenu.classList.contains("max-w-64")).toBe(true); - } - fireEvent.click(reset); - expect(screen.queryByRole("menuitem", { name: "Reset" })).toBeNull(); - expect(store.get(sidebarOrganizationModeAtom)).toBe("machine"); - expect(store.get(sidebarEnvironmentGroupingAtom)).toBe(false); - expect(store.get(sidebarChronologicalSortAtom)).toBe("updated"); - expect(store.get(sidebarSortDirectionAtom)).toBe("default"); - expect(store.get(sidebarThreadLifecyclesAtom)).toEqual( - label === "Filter" ? ["active"] : ["draft", "archived"], - ); - if (compact) - fireEvent.click(screen.getByRole("menuitem", { name: "Back" })); - else fireEvent.keyDown(screen.getByRole("menu", { name: label }), { key: "ArrowLeft" }); - await waitFor(() => - expect(screen.queryByRole("menuitem", { name: "Reset" })).toBeNull(), - ); - await screen.findByRole("menuitem", { name: "New project" }); - } - expect( - trigger.querySelector('[data-icon="MoreHorizontal"]'), - ).toBeTruthy(); - expect(trigger.classList.contains("bg-state-active")).toBe(false); - }, - ); - it("keeps Organize open and exclusive across selections", async () => { const { store } = setup(); await openMenu(); @@ -385,34 +329,45 @@ describe("sidebar header controls", () => { expect(screen.queryByRole("tooltip")).toBeNull(); }); - it("toggles sort direction without closing and resets direction for a different field", async () => { - const { store } = setup(); - await openMenu(); - await openSubmenu("Sort by"); - const updated = await screen.findByRole("menuitemradio", { - name: "Updated at, descending. Sort ascending", - }); - fireEvent.click(updated); - expect(store.get(sidebarSortDirectionAtom)).toBe("ascending"); - fireEvent.click( - screen.getByRole("menuitemradio", { - name: "Updated at, ascending. Sort descending", - }), - ); - expect(store.get(sidebarSortDirectionAtom)).toBe("descending"); - fireEvent.click( - screen.getByRole("menuitemradio", { name: "Alphabetical" }), - ); - expect(store.get(sidebarChronologicalSortAtom)).toBe("alpha"); - expect(store.get(sidebarSortDirectionAtom)).toBe("ascending"); - expect( - screen - .getByRole("menuitemradio", { - name: "Alphabetical, ascending. Sort descending", - }) - .getAttribute("aria-checked"), - ).toBe("true"); - }); + it.each([false, true])( + "changes sort without adding a Reset action (compact=%s)", + async (compact) => { + viewport.compact = compact; + const { store } = setup(); + if (compact) { + fireEvent.click(screen.getByRole("button", { name: "Pinned actions" })); + fireEvent.click(await screen.findByRole("menuitem", { name: "Sort by" })); + } else { + await openMenu(); + await openSubmenu("Sort by"); + } + const updated = await screen.findByRole("menuitemradio", { + name: "Updated at, descending. Sort ascending", + }); + expect(screen.queryByRole("menuitem", { name: /^Reset/ })).toBeNull(); + fireEvent.click(updated); + expect(store.get(sidebarSortDirectionAtom)).toBe("ascending"); + fireEvent.click( + screen.getByRole("menuitemradio", { + name: "Updated at, ascending. Sort descending", + }), + ); + expect(store.get(sidebarSortDirectionAtom)).toBe("descending"); + fireEvent.click( + screen.getByRole("menuitemradio", { name: "Alphabetical" }), + ); + expect(store.get(sidebarChronologicalSortAtom)).toBe("alpha"); + expect(store.get(sidebarSortDirectionAtom)).toBe("ascending"); + expect(screen.queryByRole("menuitem", { name: /^Reset/ })).toBeNull(); + expect( + screen + .getByRole("menuitemradio", { + name: "Alphabetical, ascending. Sort descending", + }) + .getAttribute("aria-checked"), + ).toBe("true"); + }, + ); it("announces compact sort direction and resets the nested page after closing", async () => { viewport.compact = true; diff --git a/apps/app/src/components/sidebar/SidebarHeaderControls.tsx b/apps/app/src/components/sidebar/SidebarHeaderControls.tsx index 1148390fd43..9aba4f79f57 100644 --- a/apps/app/src/components/sidebar/SidebarHeaderControls.tsx +++ b/apps/app/src/components/sidebar/SidebarHeaderControls.tsx @@ -7,7 +7,6 @@ import { type ReactNode, } from "react"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; -import { getUiPreferenceDefault } from "@bb/domain"; import { Button } from "@bb/shared-ui/button"; import { Icon } from "@bb/shared-ui/icon"; import { COARSE_POINTER_ICON_SIZE_CLASS } from "@bb/shared-ui/coarse-pointer-sizing"; @@ -77,28 +76,6 @@ function useSidebarViewSettings() { const setEnvironmentGrouping = useSetAtom(sidebarEnvironmentGroupingAtom); const groupByEnvironment = useAtomValue(sidebarGroupThreadsByEnvironmentAtom); const selectedSort = sort === "none" ? "updated" : sort; - const direction = - savedDirection === "default" - ? selectedSort === "alpha" - ? "ascending" - : "descending" - : savedDirection; - const defaultSort = getUiPreferenceDefault("sidebar.chronologicalSort"); - const defaultDirection = getUiPreferenceDefault("sidebar.sortDirection"); - const defaultLifecycles = getUiPreferenceDefault("sidebar.threadLifecycles"); - const changed = { - sort: - selectedSort !== defaultSort || - direction !== - (defaultDirection === "default" - ? defaultSort === "alpha" - ? "ascending" - : "descending" - : defaultDirection), - filter: - lifecycles.length !== defaultLifecycles.length || - lifecycles.some((value) => !defaultLifecycles.includes(value)), - }; return { lifecycles, setLifecycles, @@ -110,7 +87,6 @@ function useSidebarViewSettings() { savedDirection, setDirection, selectedSort, - changed, }; } export function SidebarHeaderControls({ diff --git a/apps/app/src/components/sidebar/SidebarViewItems.tsx b/apps/app/src/components/sidebar/SidebarViewItems.tsx index 84565ba4ac3..dd3bab622d4 100644 --- a/apps/app/src/components/sidebar/SidebarViewItems.tsx +++ b/apps/app/src/components/sidebar/SidebarViewItems.tsx @@ -1,4 +1,3 @@ -import { getUiPreferenceDefault } from "@bb/domain"; import { Icon } from "@bb/shared-ui/icon"; import { DropdownMenuGroup, @@ -26,38 +25,15 @@ export function SidebarViewItems({ savedDirection, setDirection, selectedSort, - changed, } = settings; - const reset = page !== "organize" && changed[page] ? ( - <> - - { - event.preventDefault(); - if (page === "sort") { - setSort(getUiPreferenceDefault("sidebar.chronologicalSort")); - setDirection(getUiPreferenceDefault("sidebar.sortDirection")); - } else { - setLifecycles(getUiPreferenceDefault("sidebar.threadLifecycles")); - } - }} - > - Reset - - - ) : null; if (page === "filter") { return ( - <> - - - - {reset} - + + + ); } if (page === "organize") { @@ -105,52 +81,49 @@ export function SidebarViewItems({ ); } return ( - <> - - {sortOptions.map((option) => { - const selected = selectedSort === option.sort; - const direction = - savedDirection === "default" ? option.direction : savedDirection; - const nextDirection = selected - ? direction === "ascending" - ? "descending" - : "ascending" - : option.direction; - return ( - { - event.preventDefault(); - setSort(option.sort); - setDirection(nextDirection); - }} - > - {option.label} + + {sortOptions.map((option) => { + const selected = selectedSort === option.sort; + const direction = + savedDirection === "default" ? option.direction : savedDirection; + const nextDirection = selected + ? direction === "ascending" + ? "descending" + : "ascending" + : option.direction; + return ( + { + event.preventDefault(); + setSort(option.sort); + setDirection(nextDirection); + }} + > + {option.label} + {selected && ( + + , {direction}. Sort {nextDirection} + + )} + {selected && ( - - , {direction}. Sort {nextDirection} - + )} - - {selected && ( - - )} - - - ); - })} - - {reset} - + + + ); + })} + ); } diff --git a/docs/configuration.md b/docs/configuration.md index 41c2194cb0d..8967b882323 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -708,14 +708,13 @@ Active, Drafts, and Archived. Drafts appear above the existing hierarchy; selected archived threads retain their section, project, machine, and pin placement. Choose Filter in a sidebar header's combined actions menu to change the selection. The combined menu offers Organize, Sort by, and Filter. -Sort by and Filter show Reset only when their displayed settings differ from the -default. Organize retains its Sections choices and Groups → By environment toggle. +Organize retains its Sections choices and Groups → By environment toggle. Archived rows have a persistent Archive icon that restores the thread without navigating away. Drafts come from the available unarchived bootstrap; Archived loads pages only while selected. For example, `bb settings ui set sidebar.threadLifecycles -'["active","draft"]'` shows active and saved draft threads. Reset restores -`["active"]`. Plugin sidebar replacements own their rendering. +'["active","draft"]'` shows active and saved draft threads. +Plugin sidebar replacements own their rendering. `sidebar.threadGrouping.environment` decides whether two or more sibling threads that share one worktree environment collapse into a single worktree row inside diff --git a/packages/templates/src/templates/bb-guide-customization.md b/packages/templates/src/templates/bb-guide-customization.md index e13c7189232..03992751e29 100644 --- a/packages/templates/src/templates/bb-guide-customization.md +++ b/packages/templates/src/templates/bb-guide-customization.md @@ -288,12 +288,11 @@ and `archived`, defaulting to `["active"]`. Drafts appear above the existing hierarchy; selected archived threads use their preserved placement and a restore action. Archived pages load only while selected. For example: `bb settings ui set sidebar.threadLifecycles '["active","draft"]'`. -Reset the key to restore Active. Plugin sidebar replacements own their filters. +Plugin sidebar replacements own their filters. Every thread-list header's actions menu offers New project, New section, Organize, Sort by, and Filter. Organize selects By project, -By machine, or Custom and retains Groups → By environment. Sort by and Filter -show Reset only when their displayed settings differ from the default. +By machine, or Custom and retains Groups → By environment. The separate `sidebar.threadGrouping.environment` preference decides whether sibling threads sharing one worktree collapse into a single row. It defaults to `auto`, which groups them From 67ca2d9e5beab4dd0d6f133a08a402bbfc696afe Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 17:38:56 -0700 Subject: [PATCH 24/57] Keep selected lifecycle filter rows at normal contrast --- .../components/sidebar/SidebarHeaderControls.test.tsx | 6 ++++-- .../components/thread/ThreadLifecycleFilter.test.tsx | 10 ++++++++-- .../src/components/thread/ThreadLifecycleFilter.tsx | 1 - 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx b/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx index 7940c35862f..186427cd9b7 100644 --- a/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx +++ b/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx @@ -176,7 +176,7 @@ describe("sidebar header controls", () => { name: "Active", }); expect(screen.queryByRole("menuitem", { name: "Reset" })).toBeNull(); - expect(active.getAttribute("aria-disabled")).toBe("true"); + expect(active.getAttribute("aria-disabled")).not.toBe("true"); fireEvent.click(active); expect(store.get(sidebarThreadLifecyclesAtom)).toEqual(["active"]); fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Drafts" })); @@ -184,7 +184,9 @@ describe("sidebar header controls", () => { expect(store.get(sidebarThreadLifecyclesAtom)).toEqual(["draft"]); const drafts = screen.getByRole("menuitemcheckbox", { name: "Drafts" }); expect(drafts.getAttribute("aria-checked")).toBe("true"); - expect(drafts.getAttribute("aria-disabled")).toBe("true"); + expect(drafts.getAttribute("aria-disabled")).not.toBe("true"); + fireEvent.click(drafts); + expect(store.get(sidebarThreadLifecyclesAtom)).toEqual(["draft"]); fireEvent.click( screen.getByRole("menuitemcheckbox", { name: "Archived" }), ); diff --git a/apps/app/src/components/thread/ThreadLifecycleFilter.test.tsx b/apps/app/src/components/thread/ThreadLifecycleFilter.test.tsx index 541d63f805e..056ac95183c 100644 --- a/apps/app/src/components/thread/ThreadLifecycleFilter.test.tsx +++ b/apps/app/src/components/thread/ThreadLifecycleFilter.test.tsx @@ -44,9 +44,12 @@ describe("ThreadLifecycleFilter", () => { const active = await screen.findByRole("menuitemcheckbox", { name: "Active", }); - expect(active.getAttribute("aria-disabled")).toBe("true"); + expect(active.getAttribute("aria-disabled")).not.toBe("true"); + expect(active.hasAttribute("data-disabled")).toBe(false); fireEvent.click(active); expect(active.getAttribute("aria-checked")).toBe("true"); + fireEvent.keyDown(active, { key: "Enter" }); + expect(active.getAttribute("aria-checked")).toBe("true"); fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Drafts" })); await waitFor(() => expect(active.getAttribute("aria-disabled")).not.toBe("true"), @@ -54,7 +57,10 @@ describe("ThreadLifecycleFilter", () => { fireEvent.click(active); const drafts = screen.getByRole("menuitemcheckbox", { name: "Drafts" }); expect(drafts.getAttribute("aria-checked")).toBe("true"); - expect(drafts.getAttribute("aria-disabled")).toBe("true"); + expect(drafts.getAttribute("aria-disabled")).not.toBe("true"); + expect(drafts.hasAttribute("data-disabled")).toBe(false); + fireEvent.click(drafts); + expect(drafts.getAttribute("aria-checked")).toBe("true"); expect(container.closest("[inert]")).toBeNull(); expect(container.closest('[aria-hidden="true"]')).toBeNull(); }, diff --git a/apps/app/src/components/thread/ThreadLifecycleFilter.tsx b/apps/app/src/components/thread/ThreadLifecycleFilter.tsx index 54aa965f9b6..923be522d70 100644 --- a/apps/app/src/components/thread/ThreadLifecycleFilter.tsx +++ b/apps/app/src/components/thread/ThreadLifecycleFilter.tsx @@ -35,7 +35,6 @@ export function ThreadLifecycleFilterItems({ key={option.value} role="menuitemcheckbox" aria-checked={checked} - disabled={required} title={ required ? "Keep at least one lifecycle selected" : undefined } From 3205aa38259bef4a68d3ea7e9fb98d12003ed5c1 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 18:09:01 -0700 Subject: [PATCH 25/57] Index saved queued messages in thread search --- .../db/drizzle/0127_queued_message_search.sql | 86 + packages/db/drizzle/meta/0127_snapshot.json | 5001 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + .../test/data/queued-message-search.test.ts | 123 + 4 files changed, 5217 insertions(+) create mode 100644 packages/db/drizzle/0127_queued_message_search.sql create mode 100644 packages/db/drizzle/meta/0127_snapshot.json create mode 100644 packages/db/test/data/queued-message-search.test.ts diff --git a/packages/db/drizzle/0127_queued_message_search.sql b/packages/db/drizzle/0127_queued_message_search.sql new file mode 100644 index 00000000000..d4d889867e9 --- /dev/null +++ b/packages/db/drizzle/0127_queued_message_search.sql @@ -0,0 +1,86 @@ +INSERT INTO thread_search_segments (`id`, `thread_id`, `source_kind`, `source_key`, `source_seq`, `text`, `created_at`, `updated_at`) +SELECT + q.thread_id || ':user_message:queued:' || q.id, + q.thread_id, + 'user_message', + 'queued:' || q.id, + NULL, + trim(COALESCE(( + SELECT group_concat(json_extract(part.value, '$.text'), char(10)) + FROM json_each(q.content) AS part + WHERE json_extract(part.value, '$.type') = 'text' + AND COALESCE(json_extract(part.value, '$.visibility'), '') <> 'agent-only' + ), '')), + q.created_at, + q.updated_at +FROM queued_thread_messages AS q +WHERE q.system_notice IS NULL + AND EXISTS ( + SELECT 1 FROM json_each(q.content) AS part + WHERE json_extract(part.value, '$.type') = 'text' + AND COALESCE(json_extract(part.value, '$.visibility'), '') <> 'agent-only' + AND trim(json_extract(part.value, '$.text')) <> '' + ); +--> statement-breakpoint +CREATE TRIGGER queued_thread_messages_search_insert +AFTER INSERT ON queued_thread_messages +WHEN NEW.system_notice IS NULL +BEGIN + INSERT INTO thread_search_segments (`id`, `thread_id`, `source_kind`, `source_key`, `source_seq`, `text`, `created_at`, `updated_at`) + SELECT + NEW.thread_id || ':user_message:queued:' || NEW.id, + NEW.thread_id, + 'user_message', + 'queued:' || NEW.id, + NULL, + trim(COALESCE(( + SELECT group_concat(json_extract(part.value, '$.text'), char(10)) + FROM json_each(NEW.content) AS part + WHERE json_extract(part.value, '$.type') = 'text' + AND COALESCE(json_extract(part.value, '$.visibility'), '') <> 'agent-only' + ), '')), + NEW.created_at, + NEW.updated_at + WHERE EXISTS ( + SELECT 1 FROM json_each(NEW.content) AS part + WHERE json_extract(part.value, '$.type') = 'text' + AND COALESCE(json_extract(part.value, '$.visibility'), '') <> 'agent-only' + AND trim(json_extract(part.value, '$.text')) <> '' + ); +END; +--> statement-breakpoint +CREATE TRIGGER queued_thread_messages_search_update +AFTER UPDATE OF content, system_notice ON queued_thread_messages +BEGIN + DELETE FROM thread_search_segments + WHERE id = OLD.thread_id || ':user_message:queued:' || OLD.id; + INSERT INTO thread_search_segments (`id`, `thread_id`, `source_kind`, `source_key`, `source_seq`, `text`, `created_at`, `updated_at`) + SELECT + NEW.thread_id || ':user_message:queued:' || NEW.id, + NEW.thread_id, + 'user_message', + 'queued:' || NEW.id, + NULL, + trim(COALESCE(( + SELECT group_concat(json_extract(part.value, '$.text'), char(10)) + FROM json_each(NEW.content) AS part + WHERE json_extract(part.value, '$.type') = 'text' + AND COALESCE(json_extract(part.value, '$.visibility'), '') <> 'agent-only' + ), '')), + NEW.created_at, + NEW.updated_at + WHERE NEW.system_notice IS NULL + AND EXISTS ( + SELECT 1 FROM json_each(NEW.content) AS part + WHERE json_extract(part.value, '$.type') = 'text' + AND COALESCE(json_extract(part.value, '$.visibility'), '') <> 'agent-only' + AND trim(json_extract(part.value, '$.text')) <> '' + ); +END; +--> statement-breakpoint +CREATE TRIGGER queued_thread_messages_search_delete +AFTER DELETE ON queued_thread_messages +BEGIN + DELETE FROM thread_search_segments + WHERE id = OLD.thread_id || ':user_message:queued:' || OLD.id; +END; diff --git a/packages/db/drizzle/meta/0127_snapshot.json b/packages/db/drizzle/meta/0127_snapshot.json new file mode 100644 index 00000000000..a337674ca42 --- /dev/null +++ b/packages/db/drizzle/meta/0127_snapshot.json @@ -0,0 +1,5001 @@ +{ + "id": "b82c0f1e-74b0-4992-ba56-dce2ea5f74d6", + "prevId": "48979c46-bc2b-411d-a6cc-18dfcb7cd457", + "version": "6", + "dialect": "sqlite", + "tables": { + "app_settings": { + "name": "app_settings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "caffeinate": { + "name": "caffeinate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_keyboard_hints": { + "name": "show_keyboard_hints", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "steer_active_thread_on_enter": { + "name": "steer_active_thread_on_enter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_unhandled_provider_events": { + "name": "show_unhandled_provider_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "codex_memory_enabled": { + "name": "codex_memory_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "claude_code_memory_enabled": { + "name": "claude_code_memory_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "codex_subagents_disabled": { + "name": "codex_subagents_disabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "claude_code_subagents_disabled": { + "name": "claude_code_subagents_disabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "claude_code_workflows_disabled": { + "name": "claude_code_workflows_disabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "keybinding_overrides": { + "name": "keybinding_overrides", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "app_settings_values": { + "name": "app_settings_values", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "app_theme": { + "name": "app_theme", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "theme_id": { + "name": "theme_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "favicon_color": { + "name": "favicon_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apikey": { + "name": "apikey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "referenceId": { + "name": "referenceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refillInterval": { + "name": "refillInterval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refillAmount": { + "name": "refillAmount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastRefillAt": { + "name": "lastRefillAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rateLimitEnabled": { + "name": "rateLimitEnabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rateLimitTimeWindow": { + "name": "rateLimitTimeWindow", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rateLimitMax": { + "name": "rateLimitMax", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requestCount": { + "name": "requestCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastRequest": { + "name": "lastRequest", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "configId": { + "name": "configId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "apikey_key_unique": { + "name": "apikey_key_unique", + "columns": [ + "key" + ], + "isUnique": true + }, + "apikey_reference_id_idx": { + "name": "apikey_reference_id_idx", + "columns": [ + "referenceId" + ], + "isUnique": false + }, + "apikey_config_id_idx": { + "name": "apikey_config_id_idx", + "columns": [ + "configId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "apikey_referenceId_user_id_fk": { + "name": "apikey_referenceId_user_id_fk", + "tableFrom": "apikey", + "columnsFrom": [ + "referenceId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "environment_hook_operations": { + "name": "environment_hook_operations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "environment_variables": { + "name": "environment_variables", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encryption_version": { + "name": "encryption_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "environment_variables_global_name": { + "name": "environment_variables_global_name", + "columns": [ + "name" + ], + "where": "\"environment_variables\".\"project_id\" IS NULL", + "isUnique": true + }, + "environment_variables_project_name": { + "name": "environment_variables_project_name", + "columns": [ + "project_id", + "name" + ], + "where": "\"environment_variables\".\"project_id\" IS NOT NULL", + "isUnique": true + } + }, + "foreignKeys": { + "environment_variables_project_id_projects_id_fk": { + "name": "environment_variables_project_id_projects_id_fk", + "tableFrom": "environment_variables", + "columnsFrom": [ + "project_id" + ], + "tableTo": "projects", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "environments": { + "name": "environments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_git_repo": { + "name": "is_git_repo", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_worktree": { + "name": "is_worktree", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "merge_base_branch": { + "name": "merge_base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "environment_provider_id": { + "name": "environment_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "environment_provider_plugin_id": { + "name": "environment_provider_plugin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_owns_path": { + "name": "provider_owns_path", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "environment_provider_selection": { + "name": "environment_provider_selection", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "environment_provider_instance_key": { + "name": "environment_provider_instance_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retire_at": { + "name": "retire_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "teardown_attempt": { + "name": "teardown_attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "teardown_status": { + "name": "teardown_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "teardown_message": { + "name": "teardown_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_thread_id": { + "name": "owner_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status_message": { + "name": "status_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pending_log": { + "name": "pending_log", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "claim_path": { + "name": "claim_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'provisioning'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "environments_project_host_path_idx": { + "name": "environments_project_host_path_idx", + "columns": [ + "project_id", + "host_id", + "path" + ], + "isUnique": true + }, + "environments_host_path_lookup_idx": { + "name": "environments_host_path_lookup_idx", + "columns": [ + "host_id", + "path" + ], + "isUnique": false + }, + "environments_owner_thread_idx": { + "name": "environments_owner_thread_idx", + "columns": [ + "owner_thread_id" + ], + "isUnique": true + }, + "environments_claim_idx": { + "name": "environments_claim_idx", + "columns": [ + "host_id", + "claim_path" + ], + "isUnique": false + }, + "environments_project_idx": { + "name": "environments_project_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "environments_status_idx": { + "name": "environments_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "environments_provider_instance_idx": { + "name": "environments_provider_instance_idx", + "columns": [ + "environment_provider_id", + "environment_provider_instance_key" + ], + "isUnique": false + } + }, + "foreignKeys": { + "environments_project_id_projects_id_fk": { + "name": "environments_project_id_projects_id_fk", + "tableFrom": "environments", + "columnsFrom": [ + "project_id" + ], + "tableTo": "projects", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "environments_host_id_hosts_id_fk": { + "name": "environments_host_id_hosts_id_fk", + "tableFrom": "environments", + "columnsFrom": [ + "host_id" + ], + "tableTo": "hosts", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "events": { + "name": "events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_thread_id": { + "name": "provider_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "item_kind": { + "name": "item_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_tool_call_id": { + "name": "parent_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "events_thread_sequence_idx": { + "name": "events_thread_sequence_idx", + "columns": [ + "thread_id", + "sequence" + ], + "isUnique": true + }, + "events_delegating_item_lookup_idx": { + "name": "events_delegating_item_lookup_idx", + "columns": [ + "thread_id", + "item_id", + "sequence", + "item_kind" + ], + "where": "\"events\".\"item_kind\" IN ('toolCall', 'delegation')", + "isUnique": false + }, + "events_plan_steps_thread_sequence_idx": { + "name": "events_plan_steps_thread_sequence_idx", + "columns": [ + "thread_id", + "sequence" + ], + "where": "(\"events\".\"item_kind\" = 'planSteps' AND \"events\".\"type\" = 'item/completed') OR \"events\".\"type\" = 'turn/plan/updated'", + "isUnique": false + }, + "events_parent_tool_call_thread_parent_sequence_idx": { + "name": "events_parent_tool_call_thread_parent_sequence_idx", + "columns": [ + "thread_id", + "parent_tool_call_id", + "sequence" + ], + "where": "\"events\".\"parent_tool_call_id\" IS NOT NULL", + "isUnique": false + }, + "events_thread_type_item_kind_sequence_idx": { + "name": "events_thread_type_item_kind_sequence_idx", + "columns": [ + "thread_id", + "type", + "item_kind", + "sequence" + ], + "isUnique": false + }, + "events_background_task_thread_type_item_sequence_idx": { + "name": "events_background_task_thread_type_item_sequence_idx", + "columns": [ + "thread_id", + "type", + "item_id", + "sequence" + ], + "where": "\"events\".\"item_kind\" = 'backgroundTask'", + "isUnique": false + }, + "events_thread_type_sequence_idx": { + "name": "events_thread_type_sequence_idx", + "columns": [ + "thread_id", + "type", + "sequence" + ], + "isUnique": false + }, + "events_thread_turn_type_item_sequence_idx": { + "name": "events_thread_turn_type_item_sequence_idx", + "columns": [ + "thread_id", + "turn_id", + "type", + "item_id", + "sequence" + ], + "isUnique": false + }, + "events_item_lifecycle_thread_item_sequence_idx": { + "name": "events_item_lifecycle_thread_item_sequence_idx", + "columns": [ + "thread_id", + "item_id", + "sequence" + ], + "where": "\"events\".\"type\" IN ('item/started', 'item/completed', 'item/backgroundTask/completed')", + "isUnique": false + }, + "events_environment_idx": { + "name": "events_environment_idx", + "columns": [ + "environment_id" + ], + "isUnique": false + }, + "events_provider_identity_idx": { + "name": "events_provider_identity_idx", + "columns": [ + "provider_thread_id", + "created_at" + ], + "where": "\"events\".\"type\" = 'thread/identity'", + "isUnique": false + }, + "events_completed_item_truncation_idx": { + "name": "events_completed_item_truncation_idx", + "columns": [ + "item_kind", + "created_at", + "id" + ], + "where": "\"events\".\"type\" = 'item/completed'", + "isUnique": false + }, + "events_thread_state_thread_sequence_idx": { + "name": "events_thread_state_thread_sequence_idx", + "columns": [ + "thread_id", + "sequence" + ], + "where": "\"events\".\"type\" IN ('thread/goal/updated', 'thread/goal/cleared', 'thread/extensionState/updated')", + "isUnique": false + } + }, + "foreignKeys": { + "events_thread_id_threads_id_fk": { + "name": "events_thread_id_threads_id_fk", + "tableFrom": "events", + "columnsFrom": [ + "thread_id" + ], + "tableTo": "threads", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "events_environment_id_environments_id_fk": { + "name": "events_environment_id_environments_id_fk", + "tableFrom": "events", + "columnsFrom": [ + "environment_id" + ], + "tableTo": "environments", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "events_scope_shape_check": { + "name": "events_scope_shape_check", + "value": "(\n (\"events\".\"scope_kind\" = 'turn' AND \"events\".\"turn_id\" IS NOT NULL)\n OR\n (\"events\".\"scope_kind\" = 'thread' AND \"events\".\"turn_id\" IS NULL)\n )" + } + } + }, + "host_daemon_sessions": { + "name": "host_daemon_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_name": { + "name": "host_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data_dir": { + "name": "data_dir", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "heartbeat_interval_ms": { + "name": "heartbeat_interval_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lease_timeout_ms": { + "name": "lease_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "closed_at": { + "name": "closed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "close_reason": { + "name": "close_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "host_daemon_sessions_host_status_idx": { + "name": "host_daemon_sessions_host_status_idx", + "columns": [ + "host_id", + "status" + ], + "isUnique": false + }, + "host_daemon_sessions_host_latest_idx": { + "name": "host_daemon_sessions_host_latest_idx", + "columns": [ + "host_id", + "updated_at", + "created_at", + "id" + ], + "isUnique": false + }, + "host_daemon_sessions_closed_prune_idx": { + "name": "host_daemon_sessions_closed_prune_idx", + "columns": [ + "status", + "closed_at", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "host_daemon_sessions_host_id_hosts_id_fk": { + "name": "host_daemon_sessions_host_id_hosts_id_fk", + "tableFrom": "host_daemon_sessions", + "columnsFrom": [ + "host_id" + ], + "tableTo": "hosts", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "hosts": { + "name": "hosts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connect_machine_id": { + "name": "connect_machine_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "machine_provider_id": { + "name": "machine_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "launch_key": { + "name": "launch_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "machine_inputs": { + "name": "machine_inputs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "machine_attempt": { + "name": "machine_attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "pending_log": { + "name": "pending_log", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "machine_operation_id": { + "name": "machine_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "server_access_provider_id": { + "name": "server_access_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "server_access_grant_id": { + "name": "server_access_grant_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "suspended_at": { + "name": "suspended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_message": { + "name": "status_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "suspend_retry_at": { + "name": "suspend_retry_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remove_retry_at": { + "name": "remove_retry_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "teardown_attempt": { + "name": "teardown_attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "teardown_status": { + "name": "teardown_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_permission_mode": { + "name": "max_permission_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'full'" + }, + "destroyed_at": { + "name": "destroyed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_rejected_protocol_version": { + "name": "last_rejected_protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "hosts_last_seen_idx": { + "name": "hosts_last_seen_idx", + "columns": [ + "last_seen_at" + ], + "isUnique": false + }, + "hosts_live_launch_key_idx": { + "name": "hosts_live_launch_key_idx", + "columns": [ + "launch_key" + ], + "where": "\"hosts\".\"destroyed_at\" is null", + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugins": { + "name": "plugins", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'direct'" + }, + "catalog_entry_id": { + "name": "catalog_entry_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "catalog_marketplace_name": { + "name": "catalog_marketplace_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'path'" + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_builtin_name": { + "name": "source_builtin_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_npm_package": { + "name": "source_npm_package", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_npm_registry": { + "name": "source_npm_registry", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_npm_requested_spec": { + "name": "source_npm_requested_spec", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_npm_spec_kind": { + "name": "source_npm_spec_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_url": { + "name": "source_git_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_subdirectory": { + "name": "source_git_subdirectory", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_requested_ref": { + "name": "source_git_requested_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_ref_kind": { + "name": "source_git_ref_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_range": { + "name": "source_git_range", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_tag_prefix": { + "name": "source_git_tag_prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_resolved_tag": { + "name": "source_git_resolved_tag", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "npm_resolved_version": { + "name": "npm_resolved_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "npm_integrity": { + "name": "npm_integrity", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_resolved_commit": { + "name": "git_resolved_commit", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_update_check_at": { + "name": "last_update_check_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "available_compatible_version": { + "name": "available_compatible_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "newest_incompatible_version": { + "name": "newest_incompatible_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "update_status_detail": { + "name": "update_status_detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_failure_version": { + "name": "last_failure_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_failure_at": { + "name": "last_failure_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_failure_detail": { + "name": "last_failure_detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_artifact_id": { + "name": "active_artifact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "normalization_version": { + "name": "normalization_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "root_dir": { + "name": "root_dir", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "removed_at": { + "name": "removed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "installed_at": { + "name": "installed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "plugins_active_artifact_id_plugin_artifacts_id_fk": { + "name": "plugins_active_artifact_id_plugin_artifacts_id_fk", + "tableFrom": "plugins", + "columnsFrom": [ + "active_artifact_id" + ], + "tableTo": "plugin_artifacts", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "maintenance_scan_cursors": { + "name": "maintenance_scan_cursors", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_kind": { + "name": "item_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_path": { + "name": "output_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_created_at": { + "name": "last_created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_event_id": { + "name": "last_event_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "maintenance_scan_cursors_path_idx": { + "name": "maintenance_scan_cursors_path_idx", + "columns": [ + "policy", + "version", + "item_kind", + "output_path" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pending_interactions": { + "name": "pending_interactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'provider'" + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_thread_id": { + "name": "provider_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_request_id": { + "name": "provider_request_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "renderer_id": { + "name": "renderer_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolution": { + "name": "resolution", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_reason": { + "name": "status_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "pending_interactions_provider_request_idx": { + "name": "pending_interactions_provider_request_idx", + "columns": [ + "provider_id", + "provider_thread_id", + "provider_request_id" + ], + "isUnique": true + }, + "pending_interactions_thread_created_idx": { + "name": "pending_interactions_thread_created_idx", + "columns": [ + "thread_id", + "created_at" + ], + "isUnique": false + }, + "pending_interactions_thread_status_created_idx": { + "name": "pending_interactions_thread_status_created_idx", + "columns": [ + "thread_id", + "status", + "created_at" + ], + "isUnique": false + }, + "pending_interactions_status_created_idx": { + "name": "pending_interactions_status_created_idx", + "columns": [ + "status", + "created_at" + ], + "isUnique": false + }, + "pending_interactions_plugin_status_created_idx": { + "name": "pending_interactions_plugin_status_created_idx", + "columns": [ + "plugin_id", + "status", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "pending_interactions_thread_id_threads_id_fk": { + "name": "pending_interactions_thread_id_threads_id_fk", + "tableFrom": "pending_interactions", + "columnsFrom": [ + "thread_id" + ], + "tableTo": "threads", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_artifacts": { + "name": "plugin_artifacts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "npm_resolved_version": { + "name": "npm_resolved_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_resolved_commit": { + "name": "git_resolved_commit", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_checkout_root": { + "name": "git_checkout_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integrity": { + "name": "integrity", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "validation_result": { + "name": "validation_result", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "validated_at": { + "name": "validated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "plugin_artifacts_plugin_idx": { + "name": "plugin_artifacts_plugin_idx", + "columns": [ + "plugin_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_kv": { + "name": "plugin_kv", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_kv_plugin_id_key_pk": { + "columns": [ + "plugin_id", + "key" + ], + "name": "plugin_kv_plugin_id_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_marketplace_icons": { + "name": "plugin_marketplace_icons", + "columns": { + "marketplace_name": { + "name": "marketplace_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entry_id": { + "name": "entry_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_marketplace_icons_marketplace_name_entry_id_pk": { + "columns": [ + "marketplace_name", + "entry_id" + ], + "name": "plugin_marketplace_icons_marketplace_name_entry_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_marketplaces": { + "name": "plugin_marketplaces", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'https'" + }, + "manifest_url": { + "name": "manifest_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_git_ref": { + "name": "source_git_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_commit": { + "name": "source_git_commit", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "manifest_json": { + "name": "manifest_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stats_json": { + "name": "stats_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_modified": { + "name": "last_modified", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_successful_refresh_at": { + "name": "last_successful_refresh_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_attempted_refresh_at": { + "name": "last_attempted_refresh_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_schedules": { + "name": "plugin_schedules", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_status": { + "name": "last_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_schedules_plugin_id_name_pk": { + "columns": [ + "plugin_id", + "name" + ], + "name": "plugin_schedules_plugin_id_name_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_settings": { + "name": "plugin_settings", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_settings_plugin_id_key_pk": { + "columns": [ + "plugin_id", + "key" + ], + "name": "plugin_settings_plugin_id_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_state_snapshots": { + "name": "plugin_state_snapshots", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_artifact_id": { + "name": "from_artifact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "to_artifact_id": { + "name": "to_artifact_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "snapshot_path": { + "name": "snapshot_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "database_path": { + "name": "database_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state_path": { + "name": "state_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secrets_path": { + "name": "secrets_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "registration_path": { + "name": "registration_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rollback_candidate_version": { + "name": "rollback_candidate_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rollback_source_fingerprint": { + "name": "rollback_source_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rollback_bb_version": { + "name": "rollback_bb_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rollback_sdk_version": { + "name": "rollback_sdk_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rollback_detail": { + "name": "rollback_detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retained_until": { + "name": "retained_until", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "plugin_state_snapshots_plugin_idx": { + "name": "plugin_state_snapshots_plugin_idx", + "columns": [ + "plugin_id" + ], + "isUnique": false + }, + "plugin_state_snapshots_retention_idx": { + "name": "plugin_state_snapshots_retention_idx", + "columns": [ + "retained_until" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_attachment_backfills": { + "name": "project_attachment_backfills", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thread_cursor": { + "name": "thread_cursor", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_cursor": { + "name": "input_cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_id": { + "name": "input_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_sequence": { + "name": "input_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempted_at": { + "name": "attempted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "project_attachment_backfills_project_id_projects_id_fk": { + "name": "project_attachment_backfills_project_id_projects_id_fk", + "tableFrom": "project_attachment_backfills", + "columnsFrom": [ + "project_id" + ], + "tableTo": "projects", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_attachment_threads": { + "name": "project_attachment_threads", + "columns": { + "attachment_id": { + "name": "attachment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_attachment_threads_thread_idx": { + "name": "project_attachment_threads_thread_idx", + "columns": [ + "thread_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "project_attachment_threads_attachment_id_project_attachments_id_fk": { + "name": "project_attachment_threads_attachment_id_project_attachments_id_fk", + "tableFrom": "project_attachment_threads", + "columnsFrom": [ + "attachment_id" + ], + "tableTo": "project_attachments", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "project_attachment_threads_thread_id_threads_id_fk": { + "name": "project_attachment_threads_thread_id_threads_id_fk", + "tableFrom": "project_attachment_threads", + "columnsFrom": [ + "thread_id" + ], + "tableTo": "threads", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "project_attachment_threads_attachment_id_thread_id_pk": { + "columns": [ + "attachment_id", + "thread_id" + ], + "name": "project_attachment_threads_attachment_id_thread_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_attachments": { + "name": "project_attachments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stored_path": { + "name": "stored_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ready_at": { + "name": "ready_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deletion_claimed_at": { + "name": "deletion_claimed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "project_attachments_project_path_idx": { + "name": "project_attachments_project_path_idx", + "columns": [ + "project_id", + "stored_path" + ], + "isUnique": true + }, + "project_attachments_project_created_idx": { + "name": "project_attachments_project_created_idx", + "columns": [ + "project_id", + "created_at" + ], + "isUnique": false + }, + "project_attachments_deletion_idx": { + "name": "project_attachments_deletion_idx", + "columns": [ + "project_id", + "deletion_claimed_at", + "id" + ], + "where": "\"project_attachments\".\"deletion_claimed_at\" IS NOT NULL", + "isUnique": false + } + }, + "foreignKeys": { + "project_attachments_project_id_projects_id_fk": { + "name": "project_attachments_project_id_projects_id_fk", + "tableFrom": "project_attachments", + "columnsFrom": [ + "project_id" + ], + "tableTo": "projects", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "project_attachments_size_check": { + "name": "project_attachments_size_check", + "value": "\"project_attachments\".\"size_bytes\" >= 0" + } + } + }, + "project_execution_defaults": { + "name": "project_execution_defaults", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "service_tier": { + "name": "service_tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reasoning_level": { + "name": "reasoning_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_mode": { + "name": "permission_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_execution_defaults_project_idx": { + "name": "project_execution_defaults_project_idx", + "columns": [ + "project_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_execution_defaults_project_id_projects_id_fk": { + "name": "project_execution_defaults_project_id_projects_id_fk", + "tableFrom": "project_execution_defaults", + "columnsFrom": [ + "project_id" + ], + "tableTo": "projects", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_sources": { + "name": "project_sources", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owns_path": { + "name": "owns_path", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_sources_project_idx": { + "name": "project_sources_project_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "project_sources_host_idx": { + "name": "project_sources_host_idx", + "columns": [ + "host_id" + ], + "isUnique": false + }, + "project_sources_project_host_idx": { + "name": "project_sources_project_host_idx", + "columns": [ + "project_id", + "host_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_sources_project_id_projects_id_fk": { + "name": "project_sources_project_id_projects_id_fk", + "tableFrom": "project_sources", + "columnsFrom": [ + "project_id" + ], + "tableTo": "projects", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "project_sources_host_id_hosts_id_fk": { + "name": "project_sources_host_id_hosts_id_fk", + "tableFrom": "project_sources", + "columnsFrom": [ + "host_id" + ], + "tableTo": "hosts", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "project_sources_shape_check": { + "name": "project_sources_shape_check", + "value": "(\n \"project_sources\".\"type\" = 'local_path' AND \"project_sources\".\"host_id\" IS NOT NULL AND \"project_sources\".\"path\" IS NOT NULL\n )" + } + } + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'standard'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "git_remote_url": { + "name": "git_remote_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_key": { + "name": "sort_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'V'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "projects_updated_idx": { + "name": "projects_updated_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + }, + "projects_deleted_idx": { + "name": "projects_deleted_idx", + "columns": [ + "deleted_at" + ], + "isUnique": false + }, + "projects_sort_idx": { + "name": "projects_sort_idx", + "columns": [ + "sort_key", + "id" + ], + "isUnique": false + }, + "projects_personal_singleton_idx": { + "name": "projects_personal_singleton_idx", + "columns": [ + "kind" + ], + "where": "\"projects\".\"kind\" = 'personal'", + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "prompt_history_entries": { + "name": "prompt_history_entries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_sequence": { + "name": "request_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input": { + "name": "input", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "prompt_history_entries_thread_request_idx": { + "name": "prompt_history_entries_thread_request_idx", + "columns": [ + "thread_id", + "request_sequence" + ], + "isUnique": true + }, + "prompt_history_entries_project_scope_created_idx": { + "name": "prompt_history_entries_project_scope_created_idx", + "columns": [ + "project_id", + "scope", + "created_at", + "request_sequence", + "id" + ], + "isUnique": false + }, + "prompt_history_entries_thread_scope_created_idx": { + "name": "prompt_history_entries_thread_scope_created_idx", + "columns": [ + "thread_id", + "scope", + "created_at", + "request_sequence", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "prompt_history_entries_project_id_projects_id_fk": { + "name": "prompt_history_entries_project_id_projects_id_fk", + "tableFrom": "prompt_history_entries", + "columnsFrom": [ + "project_id" + ], + "tableTo": "projects", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "prompt_history_entries_thread_id_threads_id_fk": { + "name": "prompt_history_entries_thread_id_threads_id_fk", + "tableFrom": "prompt_history_entries", + "columnsFrom": [ + "thread_id" + ], + "tableTo": "threads", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "provider_model_catalogs": { + "name": "provider_model_catalogs", + "columns": { + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "models_json": { + "name": "models_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "selected_only_models_json": { + "name": "selected_only_models_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "provider_model_catalogs_host_id_hosts_id_fk": { + "name": "provider_model_catalogs_host_id_hosts_id_fk", + "tableFrom": "provider_model_catalogs", + "columnsFrom": [ + "host_id" + ], + "tableTo": "hosts", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "provider_model_catalogs_host_id_provider_id_scope_key_pk": { + "columns": [ + "host_id", + "provider_id", + "scope_key" + ], + "name": "provider_model_catalogs_host_id_provider_id_scope_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "queued_thread_messages": { + "name": "queued_thread_messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "system_notice": { + "name": "system_notice", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender_thread_id": { + "name": "sender_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_plugin_id": { + "name": "origin_plugin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "requested_by_initiator": { + "name": "requested_by_initiator", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "requested_by_thread_id": { + "name": "requested_by_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reasoning_level": { + "name": "reasoning_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_mode": { + "name": "permission_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "service_tier": { + "name": "service_tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_with_next": { + "name": "group_with_next", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "send_at": { + "name": "send_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "waiting_on": { + "name": "waiting_on", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "wait_holder": { + "name": "wait_holder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload_kind": { + "name": "payload_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'inline'" + }, + "retry_of_turn_request_id": { + "name": "retry_of_turn_request_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retry_attempt": { + "name": "retry_attempt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retry_reason": { + "name": "retry_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "claim_token": { + "name": "claim_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_key": { + "name": "sort_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "queued_thread_messages_thread_created_idx": { + "name": "queued_thread_messages_thread_created_idx", + "columns": [ + "thread_id", + "created_at", + "id" + ], + "isUnique": false + }, + "queued_thread_messages_thread_sort_idx": { + "name": "queued_thread_messages_thread_sort_idx", + "columns": [ + "thread_id", + "sort_key", + "id" + ], + "isUnique": false + }, + "queued_thread_messages_due_idx": { + "name": "queued_thread_messages_due_idx", + "columns": [ + "send_at", + "id" + ], + "where": "\"queued_thread_messages\".\"send_at\" IS NOT NULL AND \"queued_thread_messages\".\"claimed_at\" IS NULL AND \"queued_thread_messages\".\"claim_token\" IS NULL", + "isUnique": false + }, + "queued_thread_messages_wait_holder_idx": { + "name": "queued_thread_messages_wait_holder_idx", + "columns": [ + "wait_holder", + "id" + ], + "where": "\"queued_thread_messages\".\"wait_holder\" IS NOT NULL", + "isUnique": false + } + }, + "foreignKeys": { + "queued_thread_messages_thread_id_threads_id_fk": { + "name": "queued_thread_messages_thread_id_threads_id_fk", + "tableFrom": "queued_thread_messages", + "columnsFrom": [ + "thread_id" + ], + "tableTo": "threads", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "retained_event_outputs": { + "name": "retained_event_outputs", + "columns": { + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "output_path": { + "name": "output_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "retained_event_outputs_expiry_idx": { + "name": "retained_event_outputs_expiry_idx", + "columns": [ + "expires_at", + "event_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "retained_event_outputs_event_id_events_id_fk": { + "name": "retained_event_outputs_event_id_events_id_fk", + "tableFrom": "retained_event_outputs", + "columnsFrom": [ + "event_id" + ], + "tableTo": "events", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "system_experiments": { + "name": "system_experiments", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "terminal_sessions": { + "name": "terminal_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "daemon_session_id": { + "name": "daemon_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "initial_cwd": { + "name": "initial_cwd", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cols": { + "name": "cols", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rows": { + "name": "rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "close_reason": { + "name": "close_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_user_input_at": { + "name": "last_user_input_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "terminal_sessions_thread_status_updated_idx": { + "name": "terminal_sessions_thread_status_updated_idx", + "columns": [ + "thread_id", + "status", + "updated_at" + ], + "isUnique": false + }, + "terminal_sessions_environment_status_idx": { + "name": "terminal_sessions_environment_status_idx", + "columns": [ + "environment_id", + "status" + ], + "isUnique": false + }, + "terminal_sessions_host_status_idx": { + "name": "terminal_sessions_host_status_idx", + "columns": [ + "host_id", + "status" + ], + "isUnique": false + }, + "terminal_sessions_daemon_session_idx": { + "name": "terminal_sessions_daemon_session_idx", + "columns": [ + "daemon_session_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "terminal_sessions_thread_id_threads_id_fk": { + "name": "terminal_sessions_thread_id_threads_id_fk", + "tableFrom": "terminal_sessions", + "columnsFrom": [ + "thread_id" + ], + "tableTo": "threads", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "terminal_sessions_environment_id_environments_id_fk": { + "name": "terminal_sessions_environment_id_environments_id_fk", + "tableFrom": "terminal_sessions", + "columnsFrom": [ + "environment_id" + ], + "tableTo": "environments", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "terminal_sessions_host_id_hosts_id_fk": { + "name": "terminal_sessions_host_id_hosts_id_fk", + "tableFrom": "terminal_sessions", + "columnsFrom": [ + "host_id" + ], + "tableTo": "hosts", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "terminal_sessions_daemon_session_id_host_daemon_sessions_id_fk": { + "name": "terminal_sessions_daemon_session_id_host_daemon_sessions_id_fk", + "tableFrom": "terminal_sessions", + "columnsFrom": [ + "daemon_session_id" + ], + "tableTo": "host_daemon_sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_conversation_outlines": { + "name": "thread_conversation_outlines", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "projection_key": { + "name": "projection_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "items_json": { + "name": "items_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "thread_conversation_outlines_thread_id_threads_id_fk": { + "name": "thread_conversation_outlines_thread_id_threads_id_fk", + "tableFrom": "thread_conversation_outlines", + "columnsFrom": [ + "thread_id" + ], + "tableTo": "threads", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_dynamic_context_file_states": { + "name": "thread_dynamic_context_file_states", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_key": { + "name": "file_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_status": { + "name": "content_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shown_at": { + "name": "shown_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "thread_dynamic_context_file_states_thread_file_idx": { + "name": "thread_dynamic_context_file_states_thread_file_idx", + "columns": [ + "thread_id", + "file_key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "thread_dynamic_context_file_states_thread_id_threads_id_fk": { + "name": "thread_dynamic_context_file_states_thread_id_threads_id_fk", + "tableFrom": "thread_dynamic_context_file_states", + "columnsFrom": [ + "thread_id" + ], + "tableTo": "threads", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_plugin_metadata": { + "name": "thread_plugin_metadata", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "thread_plugin_metadata_thread_id_threads_id_fk": { + "name": "thread_plugin_metadata_thread_id_threads_id_fk", + "tableFrom": "thread_plugin_metadata", + "columnsFrom": [ + "thread_id" + ], + "tableTo": "threads", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "thread_plugin_metadata_thread_id_plugin_id_pk": { + "columns": [ + "thread_id", + "plugin_id" + ], + "name": "thread_plugin_metadata_thread_id_plugin_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_pruning_cursors": { + "name": "thread_pruning_cursors", + "columns": { + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_thread_id": { + "name": "last_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "current_thread_id": { + "name": "current_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "step": { + "name": "step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "upper_sequence": { + "name": "upper_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cycle": { + "name": "cycle", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "latest_root_sequence": { + "name": "latest_root_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "latest_context_sequence": { + "name": "latest_context_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "probe_event_id": { + "name": "probe_event_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "probe_phase": { + "name": "probe_phase", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "probe_sequence": { + "name": "probe_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "probe_witness_id": { + "name": "probe_witness_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "thread_pruning_cursors_thread_idx": { + "name": "thread_pruning_cursors_thread_idx", + "columns": [ + "thread_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "thread_pruning_cursors_thread_id_threads_id_fk": { + "name": "thread_pruning_cursors_thread_id_threads_id_fk", + "tableFrom": "thread_pruning_cursors", + "columnsFrom": [ + "thread_id" + ], + "tableTo": "threads", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "thread_pruning_cursors_policy_scope_pk": { + "columns": [ + "policy", + "scope" + ], + "name": "thread_pruning_cursors_policy_scope_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": { + "thread_pruning_cursors_scope_check": { + "name": "thread_pruning_cursors_scope_check", + "value": "\"thread_pruning_cursors\".\"scope\" = coalesce(\"thread_pruning_cursors\".\"thread_id\", '')" + } + } + }, + "thread_search_segments": { + "name": "thread_search_segments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_key": { + "name": "source_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_seq": { + "name": "source_seq", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "thread_search_segments_source_idx": { + "name": "thread_search_segments_source_idx", + "columns": [ + "thread_id", + "source_kind", + "source_key" + ], + "isUnique": true + }, + "thread_search_segments_thread_source_seq_idx": { + "name": "thread_search_segments_thread_source_seq_idx", + "columns": [ + "thread_id", + "source_seq" + ], + "isUnique": false + } + }, + "foreignKeys": { + "thread_search_segments_thread_id_threads_id_fk": { + "name": "thread_search_segments_thread_id_threads_id_fk", + "tableFrom": "thread_search_segments", + "columnsFrom": [ + "thread_id" + ], + "tableTo": "threads", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_sections": { + "name": "thread_sections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "thread_sections_name_idx": { + "name": "thread_sections_name_idx", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_tabs": { + "name": "thread_tabs", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tabs_json": { + "name": "tabs_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "thread_tabs_thread_id_threads_id_fk": { + "name": "thread_tabs_thread_id_threads_id_fk", + "tableFrom": "thread_tabs", + "columnsFrom": [ + "thread_id" + ], + "tableTo": "threads", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "threads": { + "name": "threads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_override": { + "name": "model_override", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reasoning_level_override": { + "name": "reasoning_level_override", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title_fallback": { + "name": "title_fallback", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'starting'" + }, + "startup_context": { + "name": "startup_context", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_thread_id": { + "name": "parent_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lifecycle_owner_thread_id": { + "name": "lifecycle_owner_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_thread_id": { + "name": "source_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_plugin_id": { + "name": "origin_plugin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'visible'" + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin_sort_key": { + "name": "pin_sort_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "storage_deleted_at": { + "name": "storage_deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_read_at": { + "name": "last_read_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "latest_attention_at": { + "name": "latest_attention_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "threads_project_id_idx": { + "name": "threads_project_id_idx", + "columns": [ + "project_id", + "id" + ], + "isUnique": false + }, + "threads_project_updated_idx": { + "name": "threads_project_updated_idx", + "columns": [ + "project_id", + "updated_at" + ], + "isUnique": false + }, + "threads_project_archived_deleted_idx": { + "name": "threads_project_archived_deleted_idx", + "columns": [ + "project_id", + "archived_at", + "deleted_at", + "id" + ], + "isUnique": false + }, + "threads_pin_sort_idx": { + "name": "threads_pin_sort_idx", + "columns": [ + "archived_at", + "deleted_at", + "pin_sort_key", + "id" + ], + "where": "\"threads\".\"pinned_at\" IS NOT NULL", + "isUnique": false + }, + "threads_environment_idx": { + "name": "threads_environment_idx", + "columns": [ + "environment_id" + ], + "isUnique": false + }, + "threads_lifecycle_owner_idx": { + "name": "threads_lifecycle_owner_idx", + "columns": [ + "lifecycle_owner_thread_id" + ], + "isUnique": false + }, + "threads_parent_idx": { + "name": "threads_parent_idx", + "columns": [ + "parent_thread_id" + ], + "isUnique": false + }, + "threads_source_origin_idx": { + "name": "threads_source_origin_idx", + "columns": [ + "source_thread_id", + "origin_kind" + ], + "isUnique": false + }, + "threads_origin_plugin_archived_idx": { + "name": "threads_origin_plugin_archived_idx", + "columns": [ + "origin_plugin_id", + "archived_at" + ], + "isUnique": false + }, + "threads_section_archived_deleted_idx": { + "name": "threads_section_archived_deleted_idx", + "columns": [ + "section_id", + "archived_at", + "deleted_at", + "id" + ], + "isUnique": false + }, + "threads_archived_status_idx": { + "name": "threads_archived_status_idx", + "columns": [ + "archived_at", + "status" + ], + "isUnique": false + }, + "threads_environment_archived_deleted_idx": { + "name": "threads_environment_archived_deleted_idx", + "columns": [ + "environment_id", + "archived_at", + "deleted_at" + ], + "isUnique": false + }, + "threads_active_maintenance_idx": { + "name": "threads_active_maintenance_idx", + "columns": [ + "status" + ], + "where": "\"threads\".\"deleted_at\" IS NULL", + "isUnique": false + } + }, + "foreignKeys": { + "threads_project_id_projects_id_fk": { + "name": "threads_project_id_projects_id_fk", + "tableFrom": "threads", + "columnsFrom": [ + "project_id" + ], + "tableTo": "projects", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "threads_environment_id_environments_id_fk": { + "name": "threads_environment_id_environments_id_fk", + "tableFrom": "threads", + "columnsFrom": [ + "environment_id" + ], + "tableTo": "environments", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "threads_section_id_thread_sections_id_fk": { + "name": "threads_section_id_thread_sections_id_fk", + "tableFrom": "threads", + "columnsFrom": [ + "section_id" + ], + "tableTo": "thread_sections", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "threads_parent_thread_id_threads_id_fk": { + "name": "threads_parent_thread_id_threads_id_fk", + "tableFrom": "threads", + "columnsFrom": [ + "parent_thread_id" + ], + "tableTo": "threads", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "threads_lifecycle_owner_thread_id_threads_id_fk": { + "name": "threads_lifecycle_owner_thread_id_threads_id_fk", + "tableFrom": "threads", + "columnsFrom": [ + "lifecycle_owner_thread_id" + ], + "tableTo": "threads", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "restrict" + }, + "threads_source_thread_id_threads_id_fk": { + "name": "threads_source_thread_id_threads_id_fk", + "tableFrom": "threads", + "columnsFrom": [ + "source_thread_id" + ], + "tableTo": "threads", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ui_preferences": { + "name": "ui_preferences", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value_json": { + "name": "value_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 6a30ce673b3..4e47f078fa6 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -890,6 +890,13 @@ "when": 1789607725218, "tag": "0126_overconfident_vin_gonzales", "breakpoints": true + }, + { + "idx": 127, + "version": "6", + "when": 1789952709078, + "tag": "0127_queued_message_search", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/test/data/queued-message-search.test.ts b/packages/db/test/data/queued-message-search.test.ts new file mode 100644 index 00000000000..eb79fde3369 --- /dev/null +++ b/packages/db/test/data/queued-message-search.test.ts @@ -0,0 +1,123 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { type PromptInput } from "@bb/domain"; +import { noopNotifier } from "../../src/notifier.js"; +import { createMigratedConnection } from "../helpers/migrated-connection.js"; +import { upsertHost } from "../../src/data/hosts.js"; +import { createProject } from "../../src/data/projects.js"; +import { + archiveThread, + createThread, + deleteThread, + searchThreadsWithPendingInteractionState, +} from "../../src/data/threads.js"; +import { + createQueuedThreadMessage, + deleteQueuedThreadMessage, + updateQueuedThreadMessage, +} from "../../src/data/queued-thread-messages.js"; + +function text(text: string, visibility?: "agent-only"): PromptInput { + return { type: "text", text, mentions: [], ...(visibility ? { visibility } : {}) }; +} + +function setup() { + const db = createMigratedConnection(); + const host = upsertHost(db, noopNotifier, { name: "search-host" }); + const { project } = createProject(db, noopNotifier, { + name: "search-project", + source: { type: "local_path", hostId: host.id, path: "/tmp/search" }, + }); + const thread = (status: "pending" | "idle" = "pending", visibility: "visible" | "hidden" = "visible") => + createThread(db, noopNotifier, { projectId: project.id, providerId: "codex", title: "Conversation", status, visibility }); + const save = (threadId: string, content: PromptInput[]) => + createQueuedThreadMessage(db, noopNotifier, { + threadId, content, model: "gpt-5", reasoningLevel: "medium", + permissionMode: "full", serviceTier: "default", + waitingOn: { kind: "plugin", pluginId: "drafts", reason: "Draft" }, + sendAt: null, payload: { kind: "inline" }, systemNotice: null, + }); + const search = (query: string, limitPerGroup = 20) => + searchThreadsWithPendingInteractionState(db, { query, limitPerGroup }); + return { db, thread, save, search }; +} + +describe("saved message thread search", () => { + it("finds both first messages and follow-ups once per thread with normal snippets and no event anchor", () => { + const { db, thread, save, search } = setup(); + try { + const pending = thread(); + const existing = thread("idle"); + save(pending.id, [text("violet launch plan"), text("privatecode", "agent-only")]); + save(existing.id, [text("violet launch follow-up")]); + save(existing.id, [text("another violet launch note")]); + const result = search("violet launch"); + expect(result.active.total).toBe(2); + expect(new Set(result.active.results.map((row) => row.thread.id))).toEqual(new Set([pending.id, existing.id])); + for (const row of result.active.results) { + expect(row.matches[0]).toMatchObject({ sourceKind: "user_message", sourceSeq: null }); + expect(row.matches[0]?.highlightRanges.length).toBeGreaterThan(0); + } + expect(search("privatecode").active.total).toBe(0); + expect(search("violet", 1).active.results).toHaveLength(1); + expect(search("violet", 1).active.total).toBe(2); + archiveThread(db, noopNotifier, pending.id); + expect(search("violet").archived.results[0]?.thread.id).toBe(pending.id); + const hidden = thread("idle", "hidden"); + save(hidden.id, [text("violet launch hidden")]); + deleteThread(db, noopNotifier, existing.id); + expect(search("violet").active.total).toBe(0); + } finally { + db.$client.close(); + } + }); + + it("replaces edited content and removes deleted or emptied messages from the index", () => { + const { db, thread, save, search } = setup(); + try { + const owner = thread(); + const message = save(owner.id, [text("oldword")]); + const edited = updateQueuedThreadMessage(db, noopNotifier, { + id: message.id, threadId: owner.id, expectedUpdatedAt: message.updatedAt, + content: [text("newword")], + }); + expect(edited.kind).toBe("updated"); + expect(search("oldword").active.total).toBe(0); + expect(search("newword").active.total).toBe(1); + if (edited.kind !== "updated") throw new Error("Expected edited message"); + updateQueuedThreadMessage(db, noopNotifier, { + id: message.id, threadId: owner.id, expectedUpdatedAt: edited.queuedMessage.updatedAt, + content: [text("privateword", "agent-only")], + }); + expect(search("newword").active.total).toBe(0); + expect(search("privateword").active.total).toBe(0); + const removed = save(owner.id, [text("removedword")]); + deleteQueuedThreadMessage(db, noopNotifier, removed.id); + expect(search("removedword").active.total).toBe(0); + } finally { + db.$client.close(); + } + }); + + it("backfills previously saved messages without changing their queue rows", () => { + const { db, thread, save, search } = setup(); + try { + db.$client.exec(` + DROP TRIGGER queued_thread_messages_search_insert; + DROP TRIGGER queued_thread_messages_search_update; + DROP TRIGGER queued_thread_messages_search_delete; + `); + const owner = thread(); + const saved = save(owner.id, [text("preexistingmessage")]); + expect(search("preexistingmessage").active.total).toBe(0); + const migration = readFileSync(resolve(__dirname, "../../drizzle/0127_queued_message_search.sql"), "utf8"); + db.$client.exec(migration); + expect(search("preexistingmessage").active.results[0]?.thread.id).toBe(owner.id); + const persisted = db.$client.prepare("SELECT content, waiting_on FROM queued_thread_messages WHERE id = ?").get(saved.id); + expect(persisted).toEqual({ content: saved.content, waiting_on: saved.waitingOn }); + } finally { + db.$client.close(); + } + }); +}); From ff7f214a7120bed9e247186e0389bd0695157495 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 18:11:02 -0700 Subject: [PATCH 26/57] Treat saved drafts as messages in the ordinary thread list --- .../src/components/sidebar/ProjectList.tsx | 1 - .../sidebar/SidebarHeaderControls.test.tsx | 20 +++--- .../sidebar/SidebarThreadLifecycles.test.tsx | 65 ++++++------------- .../sidebar/SidebarThreadLifecycles.tsx | 50 ++------------ .../thread/ThreadLifecycleFilter.test.tsx | 15 +++-- .../thread/ThreadLifecycleFilter.tsx | 8 ++- apps/app/src/lib/thread-lifecycle-filter.ts | 12 ++++ docs/configuration.md | 13 ++-- .../src/templates/bb-guide-customization.md | 11 ++-- .../skills/bb-cli/references/app-settings.md | 9 +-- 10 files changed, 79 insertions(+), 125 deletions(-) create mode 100644 apps/app/src/lib/thread-lifecycle-filter.ts diff --git a/apps/app/src/components/sidebar/ProjectList.tsx b/apps/app/src/components/sidebar/ProjectList.tsx index 9e26e9aa512..b7339e2823a 100644 --- a/apps/app/src/components/sidebar/ProjectList.tsx +++ b/apps/app/src/components/sidebar/ProjectList.tsx @@ -1729,7 +1729,6 @@ function ProjectListComponent({ { expect(active.getAttribute("aria-disabled")).not.toBe("true"); fireEvent.click(active); expect(store.get(sidebarThreadLifecyclesAtom)).toEqual(["active"]); - fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Drafts" })); + fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Archived" })); fireEvent.click(active); - expect(store.get(sidebarThreadLifecyclesAtom)).toEqual(["draft"]); - const drafts = screen.getByRole("menuitemcheckbox", { name: "Drafts" }); - expect(drafts.getAttribute("aria-checked")).toBe("true"); - expect(drafts.getAttribute("aria-disabled")).not.toBe("true"); - fireEvent.click(drafts); - expect(store.get(sidebarThreadLifecyclesAtom)).toEqual(["draft"]); + expect(store.get(sidebarThreadLifecyclesAtom)).toEqual(["archived"]); + const archived = screen.getByRole("menuitemcheckbox", { name: "Archived" }); + expect(archived.getAttribute("aria-checked")).toBe("true"); + expect(archived.getAttribute("aria-disabled")).not.toBe("true"); + fireEvent.click(archived); + expect(store.get(sidebarThreadLifecyclesAtom)).toEqual(["archived"]); fireEvent.click( - screen.getByRole("menuitemcheckbox", { name: "Archived" }), + screen.getByRole("menuitemcheckbox", { name: "Active" }), ); expect(store.get(sidebarThreadLifecyclesAtom)).toEqual([ - "draft", + "active", "archived", ]); expect(screen.queryByRole("menuitem", { name: /^Reset/ })).toBeNull(); @@ -224,7 +224,7 @@ describe("sidebar header controls", () => { store.set(sidebarOrganizationModeAtom, "machine"); store.set(sidebarChronologicalSortAtom, "created"); store.set(sidebarSortDirectionAtom, "ascending"); - store.set(sidebarThreadLifecyclesAtom, ["draft", "archived"]); + store.set(sidebarThreadLifecyclesAtom, ["active", "archived"]); }); expect( trigger.querySelector('[data-icon="MoreHorizontal"]'), diff --git a/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx b/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx index e6b6f4b7d7d..7ff865bcd46 100644 --- a/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx +++ b/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx @@ -109,7 +109,6 @@ function LifecycleContents({ empty }: { empty: boolean }) { ]); return ( 0, @@ -192,8 +191,7 @@ describe("sidebar lifecycle placement", () => { ), }, ); - expect(result.current.threads).toEqual([duplicate, active]); - expect(result.current.drafts).toEqual([draft]); + expect(result.current.threads).toEqual([duplicate, active, draft]); rerender({ bootstrap: [active, draft] }); expect(result.current.threads[0]).toMatchObject({ id: "archived-thread", @@ -219,48 +217,23 @@ describe("sidebar lifecycle placement", () => { group: { id: "archive-section", items: [{ kind: "thread", node: { thread: archived } }] }, }]); act(() => store.set(sidebarThreadLifecyclesAtom, ["active"])); - expect(result.current.threads).toEqual([active]); + expect(result.current.threads).toEqual([active, draft]); }); - it("puts the icon-labeled Drafts section before the hierarchy with one divider", () => { - setup(["active", "draft", "archived"]); - const drafts = screen.getByRole("region", { name: "Drafts" }); - expect(drafts.querySelector('[data-icon="Edit"]')).toBeTruthy(); - expect(drafts.nextElementSibling?.tagName).toBe("HR"); - expect(drafts.compareDocumentPosition(screen.getByText("Active work")) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); - expect(screen.queryByRole("region", { name: "Archived" })).toBeNull(); - expect(screen.getAllByText("Archived work")).toHaveLength(1); - }); - - it.each<{ lifecycles: ThreadLifecycle[] }>( - ( - [ - ["active"], - ["draft"], - ["archived"], - ["active", "draft"], - ["active", "archived"], - ["draft", "archived"], - ["active", "draft", "archived"], - ] satisfies ThreadLifecycle[][] - ).map((lifecycles) => ({ lifecycles })), - )("shows only selected semantic groups for $lifecycles", ({ lifecycles }) => { + it.each<{ lifecycles: ThreadLifecycle[]; active: boolean; archived: boolean }>([ + { lifecycles: ["active"], active: true, archived: false }, + { lifecycles: ["archived"], active: false, archived: true }, + { lifecycles: ["active", "archived"], active: true, archived: true }, + { lifecycles: ["draft"], active: true, archived: false }, + { lifecycles: ["draft", "archived"], active: true, archived: true }, + ])("includes saved messages in the ordinary hierarchy for $lifecycles", ({ lifecycles, active, archived }) => { setup(lifecycles); - expect(screen.queryByText("Active work") !== null).toBe( - lifecycles.includes("active"), - ); - expect(screen.queryByText("Saved work") !== null).toBe( - lifecycles.includes("draft"), - ); - expect(screen.queryByText("Archived work") !== null).toBe( - lifecycles.includes("archived"), - ); - expect( - screen.queryAllByRole("heading").map((heading) => heading.textContent), - ).toEqual( - lifecycles.includes("draft") ? ["Drafts"] : [], - ); - expect(archiveQuery.enabled).toBe(lifecycles.includes("archived")); + expect(screen.queryByText("Active work") !== null).toBe(active); + expect(screen.queryByText("Saved work") !== null).toBe(active); + expect(screen.queryByText("Archived work") !== null).toBe(archived); + expect(screen.queryByRole("region", { name: "Drafts" })).toBeNull(); + expect(screen.queryByRole("heading", { name: "Drafts" })).toBeNull(); + expect(archiveQuery.enabled).toBe(archived); }); it.each([false, true])( @@ -285,7 +258,7 @@ describe("sidebar lifecycle placement", () => { }, ); - it.each(["draft", "archived"] as const)( + it.each(["archived"] as const)( "keeps the combined menu reachable in an empty %s-only group", async (lifecycle) => { const store = setup([lifecycle], true); @@ -293,7 +266,7 @@ describe("sidebar lifecycle placement", () => { expect( screen.queryByRole("button", { name: /Thread lifecycle:/ }), ).toBeNull(); - const label = lifecycle === "draft" ? "Drafts" : "Threads"; + const label = "Threads"; fireEvent.keyDown( screen.getByRole("button", { name: new RegExp(`^${label} actions(?:;|$)`), @@ -315,7 +288,7 @@ describe("sidebar lifecycle placement", () => { "active", lifecycle, ]); - fireEvent.click(screen.getByRole("menuitemcheckbox", { name: lifecycle === "draft" ? "Drafts" : "Archived" })); + fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Archived" })); expect(store.get(sidebarThreadLifecyclesAtom)).toEqual(["active"]); expect(screen.getByText("No threads")).toBeTruthy(); for (const menu of screen.queryAllByRole("menu").reverse()) { @@ -348,6 +321,6 @@ describe("sidebar lifecycle placement", () => { it("reuses the no-threads state for an empty selected group", () => { setup(["draft"], true); expect(screen.getByText("No threads")).toBeDefined(); - expect(screen.getByRole("heading", { name: "Drafts" })).toBeDefined(); + expect(screen.queryByRole("heading", { name: "Drafts" })).toBeNull(); }); }); diff --git a/apps/app/src/components/sidebar/SidebarThreadLifecycles.tsx b/apps/app/src/components/sidebar/SidebarThreadLifecycles.tsx index c6e79e78d61..2eb91bba71b 100644 --- a/apps/app/src/components/sidebar/SidebarThreadLifecycles.tsx +++ b/apps/app/src/components/sidebar/SidebarThreadLifecycles.tsx @@ -1,22 +1,21 @@ -import { useId, useMemo, type ComponentProps, type ReactNode } from "react"; +import { useMemo, type ComponentProps, type ReactNode } from "react"; import { useAtomValue } from "jotai"; import type { ThreadListEntry } from "@bb/domain"; import { Button } from "@bb/shared-ui/button"; -import { Icon } from "@bb/shared-ui/icon"; +import { normalizeThreadLifecycleFilter } from "@/lib/thread-lifecycle-filter"; import { useArchivedThreads } from "@/hooks/queries/thread-queries"; import { useConnectionAwareQueryState, - type ConnectionAwareQueryStatus, } from "@/hooks/queries/connection-aware-query-state"; import { isTransientReadError } from "@/hooks/queries/query-helpers"; -import { SidebarHeaderControls } from "./SidebarHeaderControls"; import { ProjectThreadTree } from "./ProjectRow"; import { sidebarThreadLifecyclesAtom } from "./sidebarCollapsedAtoms"; export function useSidebarThreadLifecycles( unarchivedThreads: ThreadListEntry[], ) { - const value = useAtomValue(sidebarThreadLifecyclesAtom); + const savedValue = useAtomValue(sidebarThreadLifecyclesAtom); + const value = useMemo(() => normalizeThreadLifecycleFilter(savedValue), [savedValue]); const archived = useArchivedThreads( {}, { enabled: value.includes("archived") }, @@ -36,19 +35,14 @@ export function useSidebarThreadLifecycles( } if (value.includes("active")) { for (const thread of unarchivedThreads) { - if (thread.lifecycle === "active") selected.set(thread.id, thread); + if (thread.archivedAt === null) selected.set(thread.id, thread); } } return [...selected.values()]; }, [archived.data, unarchivedThreads, value]); - const drafts = useMemo( - () => unarchivedThreads.filter((thread) => thread.lifecycle === "draft"), - [unarchivedThreads], - ); return { value, threads, - drafts, archived, archivedStatus: archivedState.status, }; @@ -57,49 +51,19 @@ export function useSidebarThreadLifecycles( export function SidebarThreadLifecycles({ children, lifecycles, - status, treeProps, }: { children: ReactNode; lifecycles: ReturnType; - status: ConnectionAwareQueryStatus; treeProps: Omit< ComponentProps, "threadListState" | "variant" | "progressiveDisclosureEnabled" >; }) { - const { value, drafts, archived, archivedStatus } = lifecycles; - const headingId = useId(); - const showHierarchy = - value.includes("active") || value.includes("archived"); + const { value, archived, archivedStatus } = lifecycles; return ( <> - {value.includes("draft") && ( -
-
-

- - Drafts -

- -
- -
- )} - {value.includes("draft") && showHierarchy && ( -
- )} - {showHierarchy && children} + {children} {value.includes("archived") && ( <> {value.includes("active") && archivedStatus !== "ready" && ( diff --git a/apps/app/src/components/thread/ThreadLifecycleFilter.test.tsx b/apps/app/src/components/thread/ThreadLifecycleFilter.test.tsx index 056ac95183c..163fad9d7df 100644 --- a/apps/app/src/components/thread/ThreadLifecycleFilter.test.tsx +++ b/apps/app/src/components/thread/ThreadLifecycleFilter.test.tsx @@ -41,6 +41,7 @@ describe("ThreadLifecycleFilter", () => { } else { fireEvent.keyDown(trigger, { key: "Enter" }); } + expect(screen.queryByRole("menuitemcheckbox", { name: "Drafts" })).toBeNull(); const active = await screen.findByRole("menuitemcheckbox", { name: "Active", }); @@ -50,17 +51,17 @@ describe("ThreadLifecycleFilter", () => { expect(active.getAttribute("aria-checked")).toBe("true"); fireEvent.keyDown(active, { key: "Enter" }); expect(active.getAttribute("aria-checked")).toBe("true"); - fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Drafts" })); + fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Archived" })); await waitFor(() => expect(active.getAttribute("aria-disabled")).not.toBe("true"), ); fireEvent.click(active); - const drafts = screen.getByRole("menuitemcheckbox", { name: "Drafts" }); - expect(drafts.getAttribute("aria-checked")).toBe("true"); - expect(drafts.getAttribute("aria-disabled")).not.toBe("true"); - expect(drafts.hasAttribute("data-disabled")).toBe(false); - fireEvent.click(drafts); - expect(drafts.getAttribute("aria-checked")).toBe("true"); + const archived = screen.getByRole("menuitemcheckbox", { name: "Archived" }); + expect(archived.getAttribute("aria-checked")).toBe("true"); + expect(archived.getAttribute("aria-disabled")).not.toBe("true"); + expect(archived.hasAttribute("data-disabled")).toBe(false); + fireEvent.click(archived); + expect(archived.getAttribute("aria-checked")).toBe("true"); expect(container.closest("[inert]")).toBeNull(); expect(container.closest('[aria-hidden="true"]')).toBeNull(); }, diff --git a/apps/app/src/components/thread/ThreadLifecycleFilter.tsx b/apps/app/src/components/thread/ThreadLifecycleFilter.tsx index 923be522d70..8efa0bf8daa 100644 --- a/apps/app/src/components/thread/ThreadLifecycleFilter.tsx +++ b/apps/app/src/components/thread/ThreadLifecycleFilter.tsx @@ -1,6 +1,7 @@ import type { ThreadLifecycle } from "@bb/domain"; import { Button } from "@bb/shared-ui/button"; import { Icon } from "@bb/shared-ui/icon"; +import { normalizeThreadLifecycleFilter } from "@/lib/thread-lifecycle-filter"; import { DropdownMenu, DropdownMenuContent, @@ -12,7 +13,6 @@ import { export const THREAD_LIFECYCLE_OPTIONS = [ { value: "active", label: "Active" }, - { value: "draft", label: "Drafts" }, { value: "archived", label: "Archived" }, ] as const satisfies readonly { value: ThreadLifecycle; label: string }[]; @@ -22,9 +22,10 @@ interface ThreadLifecycleFilterProps { } export function ThreadLifecycleFilterItems({ - value, + value: savedValue, onChange, }: ThreadLifecycleFilterProps) { + const value = normalizeThreadLifecycleFilter(savedValue); return ( <> {THREAD_LIFECYCLE_OPTIONS.map((option) => { @@ -66,9 +67,10 @@ export function ThreadLifecycleFilterItems({ } export function ThreadLifecycleFilter({ - value, + value: savedValue, onChange, }: ThreadLifecycleFilterProps) { + const value = normalizeThreadLifecycleFilter(savedValue); const label = THREAD_LIFECYCLE_OPTIONS.filter((option) => value.includes(option.value), ) diff --git a/apps/app/src/lib/thread-lifecycle-filter.ts b/apps/app/src/lib/thread-lifecycle-filter.ts new file mode 100644 index 00000000000..10c04d36fd5 --- /dev/null +++ b/apps/app/src/lib/thread-lifecycle-filter.ts @@ -0,0 +1,12 @@ +import type { ThreadLifecycle } from "@bb/domain"; + +export function normalizeThreadLifecycleFilter( + value: readonly ThreadLifecycle[], +): ThreadLifecycle[] { + return [ + ...(value.includes("active") || value.includes("draft") || value.length === 0 + ? ["active" as const] + : []), + ...(value.includes("archived") ? ["archived" as const] : []), + ]; +} diff --git a/docs/configuration.md b/docs/configuration.md index 8967b882323..95c5a8f7c79 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -681,7 +681,7 @@ client wrote first, so a stale window cannot silently clobber a newer value. | Key | Value | | --------------------------------- | --------------------------------------------------- | | `sidebar.organizationMode` | `project`, `chronological`, or `machine` | -| `sidebar.threadLifecycles` | Nonempty distinct list of `active`, `draft`, `archived` | +| `sidebar.threadLifecycles` | Nonempty selection of `active`, `archived` | | `sidebar.threadGrouping.environment` | `auto`, `true`, or `false` | | `sidebar.chronologicalSort` | `updated`, `created`, `alpha`, or `none` | | `sidebar.sectionOrder` | Section id list for **By project** | @@ -703,17 +703,18 @@ client wrote first, so a stale window cannot silently clobber a newer value. Custom (`chronological`) is the default for `sidebar.organizationMode` when no value is saved. Existing server and legacy browser choices are preserved. -The built-in sidebar defaults to Active. `sidebar.threadLifecycles` selects -Active, Drafts, and Archived. Drafts appear above the existing hierarchy; +The built-in sidebar defaults to Active, including threads with saved messages. +`sidebar.threadLifecycles` selects Active and Archived. There is no separate +Drafts section or filter; saved messages remain in their owning thread. The selected archived threads retain their section, project, machine, and pin placement. Choose Filter in a sidebar header's combined actions menu to change the selection. The combined menu offers Organize, Sort by, and Filter. Organize retains its Sections choices and Groups → By environment toggle. Archived rows have a persistent Archive icon that restores the thread without navigating away. -Drafts come from the available unarchived bootstrap; Archived loads pages only -while selected. For example, `bb settings ui set sidebar.threadLifecycles -'["active","draft"]'` shows active and saved draft threads. +Archived loads pages only while selected. For example, +`bb settings ui set sidebar.threadLifecycles '["active","archived"]'` shows both. +Previously saved `draft` selections display as Active. Plugin sidebar replacements own their rendering. `sidebar.threadGrouping.environment` decides whether two or more sibling threads diff --git a/packages/templates/src/templates/bb-guide-customization.md b/packages/templates/src/templates/bb-guide-customization.md index 03992751e29..9ce84183713 100644 --- a/packages/templates/src/templates/bb-guide-customization.md +++ b/packages/templates/src/templates/bb-guide-customization.md @@ -283,11 +283,12 @@ once on a conflict. `reset` writes the default. The SDK offers Custom (`chronological`) is the default for `sidebar.organizationMode` when no value is saved. Existing server and legacy browser choices are preserved. -`sidebar.threadLifecycles` is a nonempty distinct list of `active`, `draft`, -and `archived`, defaulting to `["active"]`. Drafts appear above the existing -hierarchy; selected archived threads use their preserved placement and a restore -action. Archived pages load only while selected. For example: -`bb settings ui set sidebar.threadLifecycles '["active","draft"]'`. +`sidebar.threadLifecycles` selects `active` and `archived`, defaulting to +`["active"]`. Active includes threads with saved messages; there is no separate +Drafts section or filter. Archived threads use their preserved placement and a +restore action. Archived pages load only while selected. For example: +`bb settings ui set sidebar.threadLifecycles '["active","archived"]'`. +Previously saved `draft` selections display as Active. Plugin sidebar replacements own their filters. Every thread-list header's actions menu offers New project, New section, diff --git a/plugins/bb-guide/skills/bb-cli/references/app-settings.md b/plugins/bb-guide/skills/bb-cli/references/app-settings.md index 2a8d3ec11c4..49bce137d9c 100644 --- a/plugins/bb-guide/skills/bb-cli/references/app-settings.md +++ b/plugins/bb-guide/skills/bb-cli/references/app-settings.md @@ -19,10 +19,11 @@ every window and client sees the same value. orders, the collapsed-id lists, `sidebar.pluginPanelOrder`, `sidebar.visiblePluginPanels`, `sidebar.navigationProvider`, `sidebar.threadListProvider`). -- `sidebar.threadLifecycles` selects a nonempty distinct list of `active`, - `draft`, and `archived` in the built-in sidebar. Default/reset is `["active"]`. - Use `bb settings ui set sidebar.threadLifecycles '["active","draft"]'` to - show saved drafts above the existing active hierarchy. Selected archived rows +- `sidebar.threadLifecycles` selects `active` and `archived` in the built-in + sidebar. Default is `["active"]`, including threads with saved messages. + Previously saved `draft` selections display as Active. + Use `bb settings ui set sidebar.threadLifecycles '["active","archived"]'` + to include archived threads. Selected archived rows retain their hierarchy placement and offer a restore action. Archived pages load only while selected; plugin sidebar replacements keep ownership of their rendering. - `sidebar.organizationMode` defaults to Custom (`chronological`) when unset; From 39ef3acadae128b53e8bf78d2bec6044d27aacdf Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 18:13:53 -0700 Subject: [PATCH 27/57] Show saved messages in ordinary thread search results --- .../commands/CommandPalette.test.tsx | 106 +++++------------- .../commands/ThreadSearchPaletteMode.tsx | 25 ++--- .../hooks/queries/palette-thread-queries.ts | 2 +- .../src/hooks/queries/thread-queries.test.tsx | 45 ++------ .../palette-thread-search.test.ts | 84 ++++---------- .../command-palette/palette-thread-search.ts | 9 +- docs/configuration.md | 15 ++- .../src/templates/bb-guide-customization.md | 11 +- .../skills/bb-cli/references/app-settings.md | 11 +- 9 files changed, 96 insertions(+), 212 deletions(-) diff --git a/apps/app/src/components/commands/CommandPalette.test.tsx b/apps/app/src/components/commands/CommandPalette.test.tsx index 2bc7c028772..52870abdb45 100644 --- a/apps/app/src/components/commands/CommandPalette.test.tsx +++ b/apps/app/src/components/commands/CommandPalette.test.tsx @@ -115,7 +115,6 @@ const testState = vi.hoisted(() => ({ })); const modeState = vi.hoisted(() => ({ activeRecents: [] as ThreadListEntry[], - draftRecents: [] as ThreadListEntry[], archivedRecents: [] as ThreadListEntry[], threadDraftIds: new Set(), searchResponse: undefined as ThreadSearchResponse | undefined, @@ -239,11 +238,8 @@ vi.mock("@/hooks/queries/sidebar-navigation-query", () => ({ })); vi.mock("@/hooks/queries/palette-thread-queries", () => ({ - usePaletteRecentThreads: (lifecycle: "draft" | "archived") => ({ - data: - lifecycle === "draft" - ? modeState.draftRecents - : modeState.archivedRecents, + usePaletteRecentThreads: () => ({ + data: modeState.archivedRecents, isLoading: false, isError: false, }), @@ -414,7 +410,6 @@ afterEach(() => { testState.showKeyboardHints = true; testState.plugins.length = 0; modeState.activeRecents = []; - modeState.draftRecents = []; modeState.archivedRecents = []; modeState.threadDraftIds.clear(); modeState.searchResponse = undefined; @@ -1021,7 +1016,7 @@ describe("CommandPalette", () => { it("shows active recents in update order with project metadata and follow-up status", async () => { modeState.activeRecents = [ - makeThread("saved-draft", { lifecycle: "draft", status: "pending" }), + makeThread("saved-draft", { lifecycle: "draft", status: "pending", updatedAt: 1 }), makeThread("older", { updatedAt: Date.now() - 100 }), makeThread("newer", { updatedAt: Date.now(), lastReadAt: Date.now() }), ]; @@ -1046,6 +1041,7 @@ describe("CommandPalette", () => { expect(rows.map((row) => row.textContent)).toEqual([ expect.stringContaining("Title newer"), expect.stringContaining("Title older"), + expect.stringContaining("Title saved-draft"), ]); expect(screen.queryByRole("button", { name: "Thread scope" })).toBeNull(); expect( @@ -1054,7 +1050,7 @@ describe("CommandPalette", () => { }), ).toBeTruthy(); expect(rows[0].querySelector('[data-icon="Edit"]')).not.toBeNull(); - expect(results.querySelectorAll('[data-icon="Folder"]')).toHaveLength(2); + expect(results.querySelectorAll('[data-icon="Folder"]')).toHaveLength(3); expectClasses(results, "p-1"); expectClasses(within(results).getByText("Active"), "px-2", "py-1"); for (const row of rows) { @@ -1070,9 +1066,6 @@ describe("CommandPalette", () => { makeThread("first"), makeThread("selected", { updatedAt: 1 }), ]; - modeState.draftRecents = [ - makeThread("saved", { lifecycle: "draft", status: "pending" }), - ]; modeState.archivedRecents = [makeThread("archived", { archivedAt: 1 })]; const { store } = renderPalette(); openThreadSearch(); @@ -1088,10 +1081,10 @@ describe("CommandPalette", () => { screen .getAllByRole("group") .map((group) => group.textContent?.split("Title")[0]), - ).toEqual(["Active", "Drafts", "Archived"]); + ).toEqual(["Active", "Archived"]); expectText(selectedOption(), "Title selected"); - act(() => store.set(paletteThreadLifecyclesAtom, ["draft"])); - expectText(selectedOption(), "Title saved"); + act(() => store.set(paletteThreadLifecyclesAtom, ["archived"])); + expectText(selectedOption(), "Title archived"); expect(input.getAttribute("aria-activedescendant")).toBe( selectedOption()?.id, ); @@ -1100,7 +1093,6 @@ describe("CommandPalette", () => { it("operates the lifecycle filter with the keyboard without selecting a result", async () => { modeState.activeRecents = [makeThread("active")]; - modeState.draftRecents = [makeThread("saved", { lifecycle: "draft" })]; const { store } = renderPalette(); openThreadSearch(); await screen.findByRole("combobox", { name: "Search threads" }); @@ -1109,14 +1101,14 @@ describe("CommandPalette", () => { }); act(() => trigger.focus()); fireEvent.keyDown(trigger, { key: "ArrowDown" }); - const draft = await screen.findByRole("menuitemcheckbox", { - name: "Drafts", + const archived = await screen.findByRole("menuitemcheckbox", { + name: "Archived", }); - act(() => draft.focus()); - fireEvent.keyDown(draft, { key: "Enter" }); - expect(store.get(paletteThreadLifecyclesAtom)).toEqual(["active", "draft"]); + act(() => archived.focus()); + fireEvent.keyDown(archived, { key: "Enter" }); + expect(store.get(paletteThreadLifecyclesAtom)).toEqual(["active", "archived"]); expect(routeNavigateMock).not.toHaveBeenCalled(); - fireEvent.keyDown(draft, { key: "Escape" }); + fireEvent.keyDown(archived, { key: "Escape" }); await waitFor(() => expect(screen.queryByRole("menu")).toBeNull()); expect( screen.getByRole("combobox", { name: "Search threads" }), @@ -1124,70 +1116,34 @@ describe("CommandPalette", () => { }); it.each(["", "match"])( - "budgets three nonempty groups and expands only Drafts for query '%s'", + "keeps saved messages with Active and budgets two groups for query '%s'", async (query) => { - const active = Array.from({ length: 4 }, (_, index) => - makeThread(`active-${index}`), - ); - const draft = Array.from({ length: 4 }, (_, index) => - makeThread(`draft-${index}`, { lifecycle: "draft" }), + const active = Array.from({ length: 7 }, (_, index) => + makeThread(`active-${index}`, { lifecycle: index === 1 ? "draft" : "active" }), ); const archived = Array.from({ length: 4 }, (_, index) => makeThread(`archived-${index}`, { archivedAt: 1 }), ); modeState.activeRecents = active; - modeState.draftRecents = draft; modeState.archivedRecents = archived; modeState.searchResponse = { - active: { - total: 4, - results: active.map((thread) => ({ thread, matches: [] })), - }, - draft: { - total: 4, - results: draft.map((thread) => ({ thread, matches: [] })), - }, - archived: { - total: 4, - results: archived.map((thread) => ({ thread, matches: [] })), - }, + active: { total: 7, results: active.map((thread) => ({ thread, matches: [] })) }, + archived: { total: 4, results: archived.map((thread) => ({ thread, matches: [] })) }, }; - const { store } = renderPalette({ - lifecycles: ["active", "draft", "archived"], - }); + const { store } = renderPalette({ lifecycles: ["active", "archived"] }); openThreadSearch(); - const input = await screen.findByRole("combobox", { - name: "Search threads", - }); + const input = await screen.findByRole("combobox", { name: "Search threads" }); fireEvent.change(input, { target: { value: query } }); - for (const name of ["Active", "Drafts", "Archived"]) { - expect( - within(screen.getByRole("group", { name })).getAllByRole("option"), - ).toHaveLength(3); + expect(screen.queryByRole("group", { name: "Drafts" })).toBeNull(); + for (const name of ["Active", "Archived"]) { + expect(within(screen.getByRole("group", { name })).getAllByRole("option")).toHaveLength(4); } - fireEvent.click( - screen.getByRole("option", { name: "Show more draft threads" }), - ); - expect( - within(screen.getByRole("group", { name: "Drafts" })).getAllByRole( - "option", - ), - ).toHaveLength(4); - expect( - within(screen.getByRole("group", { name: "Active" })).getAllByRole( - "option", - ), - ).toHaveLength(3); - expectText(selectedOption(), "Title draft-2"); - act(() => store.set(paletteThreadLifecyclesAtom, ["active", "draft"])); - expect( - within(screen.getByRole("group", { name: "Drafts" })).getAllByRole( - "option", - ), - ).toHaveLength(4); - expect( - screen.getByRole("option", { name: "Show more draft threads" }), - ).toBeTruthy(); + fireEvent.click(screen.getByRole("option", { name: "Show more active threads" })); + expect(within(screen.getByRole("group", { name: "Active" })).getAllByRole("option")).toHaveLength(7); + expect(within(screen.getByRole("group", { name: "Archived" })).getAllByRole("option")).toHaveLength(4); + expectText(selectedOption(), "Title active-3"); + act(() => store.set(paletteThreadLifecyclesAtom, ["active"])); + expect(screen.getByRole("option", { name: "Show more active threads" })).toBeTruthy(); expect(document.querySelector("[data-palette-footer]")).toBeNull(); }, ); @@ -1361,7 +1317,7 @@ describe("CommandPalette", () => { expect(screen.getByText("Show more")).toBeTruthy(); }); - it.each(["active", "draft", "archived"] as const)( + it.each(["active", "archived"] as const)( "shows six %s-only matches and opens a revealed result", async (lifecycle) => { const threads = Array.from({ length: 7 }, (_, index) => diff --git a/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx b/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx index 3f14903b90a..f5a213b79a7 100644 --- a/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx +++ b/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx @@ -28,6 +28,7 @@ import { THREAD_LIFECYCLE_OPTIONS, } from "@/components/thread/ThreadLifecycleFilter"; import { paletteThreadLifecyclesAtom } from "@/lib/command-palette/palette-preferences"; +import { normalizeThreadLifecycleFilter } from "@/lib/thread-lifecycle-filter"; import { useSidebarNavigation } from "@/hooks/queries/sidebar-navigation-query"; import { usePaletteRecentThreads } from "@/hooks/queries/palette-thread-queries"; import { @@ -78,10 +79,7 @@ export function ThreadSearchPaletteMode({ paletteThreadLifecyclesAtom, ); const lifecycles = useMemo( - () => - THREAD_LIFECYCLE_OPTIONS.flatMap(({ value }) => - selectedLifecycles.includes(value) ? [value] : [], - ), + () => normalizeThreadLifecycleFilter(selectedLifecycles), [selectedLifecycles], ); const [query, setQuery] = useState(""); @@ -96,11 +94,8 @@ export function ThreadSearchPaletteMode({ } const [now] = useState(() => Date.now()); const navigation = useSidebarNavigation(); - const threadSearch = useThreadSearch({ active: true, lifecycles, query }); + const threadSearch = useThreadSearch({ active: true, query }); const trimmedQuery = query.trim(); - const drafts = usePaletteRecentThreads("draft", { - enabled: trimmedQuery.length === 0 && lifecycles.includes("draft"), - }); const archived = usePaletteRecentThreads("archived", { enabled: trimmedQuery.length === 0 && lifecycles.includes("archived"), }); @@ -123,11 +118,10 @@ export function ThreadSearchPaletteMode({ ...(navigation.data?.projects.flatMap((project) => project.threads) ?? []), ...(navigation.data?.personalProject.threads ?? []), - ].filter((thread) => thread.lifecycle === "active"), - ...(lifecycles.includes("draft") ? (drafts.data ?? []) : []), + ], ...(lifecycles.includes("archived") ? (archived.data ?? []) : []), ], - [archived.data, drafts.data, lifecycles, navigation.data], + [archived.data, lifecycles, navigation.data], ); const result = useMemo( () => @@ -154,8 +148,7 @@ export function ThreadSearchPaletteMode({ const nonemptyGroups = lifecycles.filter((lifecycle) => result.rows.some((row) => row.lifecycle === lifecycle), ); - const limit = - nonemptyGroups.length === 3 ? 2 : nonemptyGroups.length === 2 ? 3 : 6; + const limit = nonemptyGroups.length === 2 ? 3 : 6; return nonemptyGroups.flatMap((lifecycle) => { const rows = result.rows.filter((row) => row.lifecycle === lifecycle); const visible = expandedGroups.includes(lifecycle) @@ -193,11 +186,7 @@ export function ThreadSearchPaletteMode({ [options], ); const recentQueries = lifecycles.map((lifecycle) => - lifecycle === "active" - ? navigation - : lifecycle === "draft" - ? drafts - : archived, + lifecycle === "active" ? navigation : archived, ); const isRecentLoading = result.isRecent && recentQueries.some((result) => result.isLoading); diff --git a/apps/app/src/hooks/queries/palette-thread-queries.ts b/apps/app/src/hooks/queries/palette-thread-queries.ts index 61272cadbf8..65697bac27c 100644 --- a/apps/app/src/hooks/queries/palette-thread-queries.ts +++ b/apps/app/src/hooks/queries/palette-thread-queries.ts @@ -9,7 +9,7 @@ import { } from "./thread-queries"; export function usePaletteRecentThreads( - lifecycle: "draft" | "archived", + lifecycle: "archived", { enabled }: { enabled: boolean }, ) { useThreadListRealtimeSubscription({ enabled }); diff --git a/apps/app/src/hooks/queries/thread-queries.test.tsx b/apps/app/src/hooks/queries/thread-queries.test.tsx index 33bc5490687..32b64fdd044 100644 --- a/apps/app/src/hooks/queries/thread-queries.test.tsx +++ b/apps/app/src/hooks/queries/thread-queries.test.tsx @@ -860,46 +860,21 @@ describe("useThreadTimeline segment limit", () => { }); describe("palette lifecycle queries", () => { - it("loads bounded recent draft and archived populations only while selected before typing", async () => { + it("loads bounded archived recents only while selected before typing", async () => { const { wrapper } = createQueryClientTestHarness(); - const olderDraft = makeThreadListEntry({ - id: "old-draft", - lifecycle: "draft", - updatedAt: 1, - }); - vi.mocked(sdk.threads.list).mockResolvedValue([olderDraft]); + const archived = makeThreadListEntry({ id: "archived", lifecycle: "archived", archivedAt: 1 }); + vi.mocked(sdk.threads.list).mockResolvedValue([archived]); const { result, rerender } = renderHook( - ({ recent, draft, archived }) => ({ - draft: usePaletteRecentThreads("draft", { enabled: recent && draft }), - archived: usePaletteRecentThreads("archived", { - enabled: recent && archived, - }), - }), - { - wrapper, - initialProps: { recent: true, draft: false, archived: false }, - }, + ({ recent, selected }) => usePaletteRecentThreads("archived", { enabled: recent && selected }), + { wrapper, initialProps: { recent: true, selected: false } }, ); expect(sdk.threads.list).not.toHaveBeenCalled(); - rerender({ recent: true, draft: true, archived: false }); - await waitFor(() => - expect(result.current.draft.data).toEqual([olderDraft]), - ); + rerender({ recent: false, selected: true }); + expect(sdk.threads.list).not.toHaveBeenCalled(); + rerender({ recent: true, selected: true }); + await waitFor(() => expect(result.current.data).toEqual([archived])); expect(sdk.threads.list).toHaveBeenCalledExactlyOnceWith({ - archived: false, - lifecycles: ["draft"], - limit: 20, - signal: expect.any(AbortSignal), - }); - rerender({ recent: false, draft: true, archived: true }); - expect(sdk.threads.list).toHaveBeenCalledTimes(1); - rerender({ recent: true, draft: true, archived: true }); - await waitFor(() => expect(sdk.threads.list).toHaveBeenCalledTimes(2)); - expect(sdk.threads.list).toHaveBeenLastCalledWith({ - archived: true, - lifecycles: ["archived"], - limit: 20, - signal: expect.any(AbortSignal), + archived: true, lifecycles: ["archived"], limit: 20, signal: expect.any(AbortSignal), }); }); diff --git a/apps/app/src/lib/command-palette/palette-thread-search.test.ts b/apps/app/src/lib/command-palette/palette-thread-search.test.ts index 4e6ade5a16f..83032680138 100644 --- a/apps/app/src/lib/command-palette/palette-thread-search.test.ts +++ b/apps/app/src/lib/command-palette/palette-thread-search.test.ts @@ -73,79 +73,33 @@ function build( } describe("buildPaletteThreadSearchRows", () => { - it("filters by server lifecycle before applying the recent cutoff", () => { - const draft = makeThread("older-draft", { - lifecycle: "draft", - updatedAt: 1, - }); - const archived = makeThread("older-archive", { - archivedAt: 1, - updatedAt: 2, - }); - const active = Array.from({ length: 25 }, (_, index) => - makeThread(`active-${index}`), - ); - const recentThreads = [...active, draft, archived]; - expect(build({ query: "", recentThreads }).rows).toHaveLength(20); - expect( - build({ query: "", recentThreads }).rows.every( - (row) => row.lifecycle === "active", - ), - ).toBe(true); - expect( - build({ - query: "", - recentThreads, - lifecycles: ["draft", "archived"], - }).rows.map((row) => row.threadId), - ).toEqual(["older-draft", "older-archive"]); + it("keeps saved messages in Active recents and maps older draft selections to Active", () => { + const saved = makeThread("saved", { lifecycle: "draft", updatedAt: NOW }); + const archived = makeThread("archived", { archivedAt: 1, updatedAt: 2 }); + const active = Array.from({ length: 25 }, (_, index) => makeThread(`active-${index}`, { updatedAt: 1 })); + const recentThreads = [...active, saved, archived]; + const result = build({ query: "", recentThreads, lifecycles: ["draft", "archived"] }); + expect(result.rows).toHaveLength(21); + expect(result.rows[0]).toMatchObject({ threadId: "saved", lifecycle: "active" }); + expect(result.rows[20]).toMatchObject({ threadId: "archived", lifecycle: "archived" }); }); - it("uses only selected server groups and keeps draft ranking and message anchors", () => { + it("keeps saved-message snippets in the owning thread result without inventing an event anchor", () => { const result = build({ - lifecycles: ["draft"], + lifecycles: ["active"], searchResponse: { active: { - total: 30, - results: [{ thread: makeThread("active"), matches: [] }], - }, - draft: { - total: 2, - results: [ - { - thread: makeThread("draft-message", { lifecycle: "draft" }), - matches: [ - { - sourceKind: "user_message", - text: "matching draft", - highlightRanges: [{ start: 0, end: 5 }], - sourceSeq: 7, - }, - ], - }, - { - thread: makeThread("draft-title", { lifecycle: "draft" }), - matches: [], - }, - ], - }, - archived: { - total: 10, - results: [ - { thread: makeThread("archived", { archivedAt: 1 }), matches: [] }, - ], + total: 1, + results: [{ + thread: makeThread("saved", { lifecycle: "draft" }), + matches: [{ sourceKind: "user_message", text: "matching saved message", highlightRanges: [{ start: 0, end: 5 }], sourceSeq: null }], + }], }, + archived: { total: 0, results: [] }, }, }); - expect(result.rows.map((row) => row.threadId)).toEqual([ - "draft-message", - "draft-title", - ]); - expect(result.rows[0]).toMatchObject({ - messageSeq: 7, - highlightRanges: [{ start: 0, end: 5 }], - lifecycle: "draft", - }); + expect(result.rows).toHaveLength(1); + expect(result.rows[0]).toMatchObject({ threadId: "saved", lifecycle: "active", primaryText: "matching saved message", messageSeq: null }); }); it("preserves active and archived server matches in their ranked order", () => { diff --git a/apps/app/src/lib/command-palette/palette-thread-search.ts b/apps/app/src/lib/command-palette/palette-thread-search.ts index a3ad8a646d2..68b0a751317 100644 --- a/apps/app/src/lib/command-palette/palette-thread-search.ts +++ b/apps/app/src/lib/command-palette/palette-thread-search.ts @@ -9,6 +9,7 @@ import type { } from "@bb/server-contract"; import { formatRelativeTime } from "@/lib/relative-time"; import { getThreadDisplayTitle } from "@/lib/thread-title"; +import { normalizeThreadLifecycleFilter } from "@/lib/thread-lifecycle-filter"; export interface PaletteThreadSearchRow { id: string; @@ -96,10 +97,14 @@ export function buildPaletteThreadSearchRows({ const isSearchable = trimmedQuery.length >= 2; return { isRecent, - rows: lifecycles.flatMap((lifecycle) => + rows: normalizeThreadLifecycleFilter(lifecycles).flatMap((lifecycle) => isRecent ? recentThreads - .filter((thread) => thread.lifecycle === lifecycle) + .filter((thread) => + lifecycle === "archived" + ? thread.archivedAt !== null + : thread.archivedAt === null, + ) .sort((left, right) => right.updatedAt - left.updatedAt) .slice(0, RECENT_THREAD_LIMIT) .map((thread) => diff --git a/docs/configuration.md b/docs/configuration.md index 029e6d8ae38..59814c43f61 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -718,12 +718,15 @@ Archived loads pages only while selected. For example, Previously saved `draft` selections display as Active. Plugin sidebar replacements own their rendering. -`palette.threadLifecycles` independently selects the palette's Active, Drafts, -and Archived groups before and after typing. Default/reset is `["active"]`. -Drafts and Archived load bounded recent lists only while selected. The palette -initially shows six rows across nonempty groups (six, three each, or two each), -with Show more in each group. For example, `bb settings ui set -palette.threadLifecycles '["draft"]'` finds saved drafts without changing the sidebar. +`palette.threadLifecycles` independently selects Active and Archived before +and after typing. Active includes threads with saved messages. Search threads +matches saved message content, including follow-ups, and opens the owning thread. +Archived loads a bounded recent list only while selected. Search uses the existing +ranked Active/Archived response and displays the selected groups, with six initial +rows in one group or three each when both are nonempty, plus Show more. +`bb settings ui set palette.threadLifecycles '["active","archived"]'` includes +archived results without changing the sidebar. Older `draft` selections display +as Active. `sidebar.threadGrouping.environment` decides whether two or more sibling threads that share one worktree environment collapse into a single worktree row inside diff --git a/packages/templates/src/templates/bb-guide-customization.md b/packages/templates/src/templates/bb-guide-customization.md index eebc9dbc957..8b7eb3364d9 100644 --- a/packages/templates/src/templates/bb-guide-customization.md +++ b/packages/templates/src/templates/bb-guide-customization.md @@ -291,11 +291,12 @@ restore action. Archived pages load only while selected. For example: Previously saved `draft` selections display as Active. Plugin sidebar replacements own their filters. -`palette.threadLifecycles` uses the same values and Active default, independently -of the sidebar. It filters named groups before and after typing; Drafts and -Archived fetch bounded recent rows only when selected. Use -`bb settings ui set palette.threadLifecycles '["draft"]'` for saved drafts, -or reset the key to restore Active. +`palette.threadLifecycles` uses Active and Archived independently of the +sidebar. Active includes threads with saved messages; Search threads also +matches their saved content. Archived fetches bounded recent rows only when +selected. Use `bb settings ui set palette.threadLifecycles +'["active","archived"]'` to include archived results. Older `draft` selections +display as Active. Every thread-list header's actions menu offers New project, New section, Organize, Sort by, and Filter. Organize selects By project, diff --git a/plugins/bb-guide/skills/bb-cli/references/app-settings.md b/plugins/bb-guide/skills/bb-cli/references/app-settings.md index 2859ce7c445..f1711f89146 100644 --- a/plugins/bb-guide/skills/bb-cli/references/app-settings.md +++ b/plugins/bb-guide/skills/bb-cli/references/app-settings.md @@ -26,11 +26,12 @@ every window and client sees the same value. to include archived threads. Selected archived rows retain their hierarchy placement and offer a restore action. Archived pages load only while selected; plugin sidebar replacements keep ownership of their rendering. -- `palette.threadLifecycles` uses the same nonempty lifecycle selection and - Active default, independently of the sidebar. It filters palette results - before and after typing. Use `bb settings ui set palette.threadLifecycles - '["draft"]'` for saved drafts; reset restores Active. Recent Drafts and - Archived lists load only while selected and are bounded at the server. +- `palette.threadLifecycles` selects Active and Archived independently of the + sidebar. Active includes threads with saved messages, whose content is + matched by Search threads. Use `bb settings ui set palette.threadLifecycles + '["active","archived"]'` to include archived results. Archived recents load + only while selected and are bounded at the server. Older `draft` selections + display as Active. - `sidebar.organizationMode` defaults to Custom (`chronological`) when unset; existing server and legacy browser choices are preserved. - `sidebar.threadGrouping.environment` decides whether sibling threads sharing From 421870d88239d5f2aa1ad1741794d9e59ad4c43c Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 18:14:46 -0700 Subject: [PATCH 28/57] Preserve queue corruption diagnostics and migration fixtures --- packages/db/drizzle/0127_queued_message_search.sql | 12 ++++++------ packages/db/test/migrate.test.ts | 10 ++++++++++ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/db/drizzle/0127_queued_message_search.sql b/packages/db/drizzle/0127_queued_message_search.sql index d4d889867e9..1045cdee965 100644 --- a/packages/db/drizzle/0127_queued_message_search.sql +++ b/packages/db/drizzle/0127_queued_message_search.sql @@ -7,7 +7,7 @@ SELECT NULL, trim(COALESCE(( SELECT group_concat(json_extract(part.value, '$.text'), char(10)) - FROM json_each(q.content) AS part + FROM json_each(CASE WHEN json_valid(q.content) THEN q.content ELSE '[]' END) AS part WHERE json_extract(part.value, '$.type') = 'text' AND COALESCE(json_extract(part.value, '$.visibility'), '') <> 'agent-only' ), '')), @@ -16,7 +16,7 @@ SELECT FROM queued_thread_messages AS q WHERE q.system_notice IS NULL AND EXISTS ( - SELECT 1 FROM json_each(q.content) AS part + SELECT 1 FROM json_each(CASE WHEN json_valid(q.content) THEN q.content ELSE '[]' END) AS part WHERE json_extract(part.value, '$.type') = 'text' AND COALESCE(json_extract(part.value, '$.visibility'), '') <> 'agent-only' AND trim(json_extract(part.value, '$.text')) <> '' @@ -35,14 +35,14 @@ BEGIN NULL, trim(COALESCE(( SELECT group_concat(json_extract(part.value, '$.text'), char(10)) - FROM json_each(NEW.content) AS part + FROM json_each(CASE WHEN json_valid(NEW.content) THEN NEW.content ELSE '[]' END) AS part WHERE json_extract(part.value, '$.type') = 'text' AND COALESCE(json_extract(part.value, '$.visibility'), '') <> 'agent-only' ), '')), NEW.created_at, NEW.updated_at WHERE EXISTS ( - SELECT 1 FROM json_each(NEW.content) AS part + SELECT 1 FROM json_each(CASE WHEN json_valid(NEW.content) THEN NEW.content ELSE '[]' END) AS part WHERE json_extract(part.value, '$.type') = 'text' AND COALESCE(json_extract(part.value, '$.visibility'), '') <> 'agent-only' AND trim(json_extract(part.value, '$.text')) <> '' @@ -63,7 +63,7 @@ BEGIN NULL, trim(COALESCE(( SELECT group_concat(json_extract(part.value, '$.text'), char(10)) - FROM json_each(NEW.content) AS part + FROM json_each(CASE WHEN json_valid(NEW.content) THEN NEW.content ELSE '[]' END) AS part WHERE json_extract(part.value, '$.type') = 'text' AND COALESCE(json_extract(part.value, '$.visibility'), '') <> 'agent-only' ), '')), @@ -71,7 +71,7 @@ BEGIN NEW.updated_at WHERE NEW.system_notice IS NULL AND EXISTS ( - SELECT 1 FROM json_each(NEW.content) AS part + SELECT 1 FROM json_each(CASE WHEN json_valid(NEW.content) THEN NEW.content ELSE '[]' END) AS part WHERE json_extract(part.value, '$.type') = 'text' AND COALESCE(json_extract(part.value, '$.visibility'), '') <> 'agent-only' AND trim(json_extract(part.value, '$.text')) <> '' diff --git a/packages/db/test/migrate.test.ts b/packages/db/test/migrate.test.ts index 4da166327e3..ed2ab8f8530 100644 --- a/packages/db/test/migrate.test.ts +++ b/packages/db/test/migrate.test.ts @@ -736,7 +736,16 @@ function dropMarketplaceStatsColumn(db: DbConnection): void { * nothing here. Every rewind that clears 0110's journal row also clears * 0108's, so the replay recreates the table before 0110 drops it again. */ +function dropQueuedMessageSearchTriggers(db: DbConnection): void { + db.$client.exec(` + DROP TRIGGER IF EXISTS queued_thread_messages_search_insert; + DROP TRIGGER IF EXISTS queued_thread_messages_search_update; + DROP TRIGGER IF EXISTS queued_thread_messages_search_delete; + `); +} + function rewindEnvironmentProvisioningMigration(db: DbConnection): void { + dropQueuedMessageSearchTriggers(db); db.$client.exec("DROP TRIGGER IF EXISTS threads_lifecycle_owner_insert"); db.$client.exec("DROP TRIGGER IF EXISTS threads_lifecycle_owner_immutable"); db.$client.exec("DROP INDEX IF EXISTS threads_lifecycle_owner_idx"); @@ -858,6 +867,7 @@ function rewindEnvironmentRowFactsMigration(db: DbConnection): void { } function rewindMachineProvidersMigration(db: DbConnection): void { + dropQueuedMessageSearchTriggers(db); const queuedDispatchOrigin = db.$client .prepare<[], TableInfoRow>("PRAGMA table_info(queued_thread_messages)") .all(); From 1e9c39bbe4a653f74e290e320455fa13c74aa955 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 18:17:10 -0700 Subject: [PATCH 29/57] Rewind queue search rows with the migration fixture --- packages/db/test/migrate.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/db/test/migrate.test.ts b/packages/db/test/migrate.test.ts index ed2ab8f8530..b91110698a5 100644 --- a/packages/db/test/migrate.test.ts +++ b/packages/db/test/migrate.test.ts @@ -741,6 +741,8 @@ function dropQueuedMessageSearchTriggers(db: DbConnection): void { DROP TRIGGER IF EXISTS queued_thread_messages_search_insert; DROP TRIGGER IF EXISTS queued_thread_messages_search_update; DROP TRIGGER IF EXISTS queued_thread_messages_search_delete; + DELETE FROM thread_search_segments + WHERE source_kind = 'user_message' AND source_key LIKE 'queued:%'; `); } From 0d4081763cb9e105d2127a669385e6bfe07445c6 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 18:24:25 -0700 Subject: [PATCH 30/57] Handle pre-search schemas when rewinding migration fixtures --- packages/db/test/migrate.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/db/test/migrate.test.ts b/packages/db/test/migrate.test.ts index b91110698a5..bccf553e041 100644 --- a/packages/db/test/migrate.test.ts +++ b/packages/db/test/migrate.test.ts @@ -741,9 +741,13 @@ function dropQueuedMessageSearchTriggers(db: DbConnection): void { DROP TRIGGER IF EXISTS queued_thread_messages_search_insert; DROP TRIGGER IF EXISTS queued_thread_messages_search_update; DROP TRIGGER IF EXISTS queued_thread_messages_search_delete; - DELETE FROM thread_search_segments - WHERE source_kind = 'user_message' AND source_key LIKE 'queued:%'; `); + if (readTableNames(db).includes("thread_search_segments")) { + db.$client.exec(` + DELETE FROM thread_search_segments + WHERE source_kind = 'user_message' AND source_key LIKE 'queued:%'; + `); + } } function rewindEnvironmentProvisioningMigration(db: DbConnection): void { From dd214942627a77016be0b6932ca6d6859c92c332 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 18:29:58 -0700 Subject: [PATCH 31/57] Remove obsolete draft expansion label and align regressions --- apps/app/src/components/commands/CommandPalette.test.tsx | 4 ++-- apps/app/src/components/commands/ThreadSearchPaletteMode.tsx | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/app/src/components/commands/CommandPalette.test.tsx b/apps/app/src/components/commands/CommandPalette.test.tsx index 52870abdb45..ab620573f6d 100644 --- a/apps/app/src/components/commands/CommandPalette.test.tsx +++ b/apps/app/src/components/commands/CommandPalette.test.tsx @@ -1138,12 +1138,12 @@ describe("CommandPalette", () => { for (const name of ["Active", "Archived"]) { expect(within(screen.getByRole("group", { name })).getAllByRole("option")).toHaveLength(4); } - fireEvent.click(screen.getByRole("option", { name: "Show more active threads" })); + fireEvent.click(screen.getByRole("option", { name: "Show more threads" })); expect(within(screen.getByRole("group", { name: "Active" })).getAllByRole("option")).toHaveLength(7); expect(within(screen.getByRole("group", { name: "Archived" })).getAllByRole("option")).toHaveLength(4); expectText(selectedOption(), "Title active-3"); act(() => store.set(paletteThreadLifecyclesAtom, ["active"])); - expect(screen.getByRole("option", { name: "Show more active threads" })).toBeTruthy(); + expect(screen.getByRole("option", { name: "Show more threads" })).toBeTruthy(); expect(document.querySelector("[data-palette-footer]")).toBeNull(); }, ); diff --git a/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx b/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx index f5a213b79a7..b979a005c64 100644 --- a/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx +++ b/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx @@ -404,9 +404,7 @@ export function ThreadSearchPaletteMode({ ? undefined : lifecycle === "archived" ? "Show more archived threads" - : lifecycle === "draft" - ? "Show more draft threads" - : "Show more threads" + : "Show more threads" } className={cn( "flex min-w-0 flex-1 cursor-pointer items-center rounded-md px-2 py-1.5", From 4b4bc3aaeccbfda7b5d832c6a586b43d60dedaaa Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 19:36:43 -0700 Subject: [PATCH 32/57] Keep sidebar view settings inside the lazy menu --- .../sidebar/SidebarHeaderControls.tsx | 64 +------------------ .../components/sidebar/SidebarViewItems.tsx | 52 +++++++++------ 2 files changed, 35 insertions(+), 81 deletions(-) diff --git a/apps/app/src/components/sidebar/SidebarHeaderControls.tsx b/apps/app/src/components/sidebar/SidebarHeaderControls.tsx index 9aba4f79f57..1d31ef34501 100644 --- a/apps/app/src/components/sidebar/SidebarHeaderControls.tsx +++ b/apps/app/src/components/sidebar/SidebarHeaderControls.tsx @@ -6,7 +6,6 @@ import { useState, type ReactNode, } from "react"; -import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { Button } from "@bb/shared-ui/button"; import { Icon } from "@bb/shared-ui/icon"; import { COARSE_POINTER_ICON_SIZE_CLASS } from "@bb/shared-ui/coarse-pointer-sizing"; @@ -22,14 +21,6 @@ import { DropdownMenuSubContent, DropdownMenuPortal, } from "@bb/shared-ui/dropdown-menu"; -import { - sidebarOrganizationModeAtom, - sidebarChronologicalSortAtom, - sidebarSortDirectionAtom, - sidebarThreadLifecyclesAtom, - sidebarGroupThreadsByEnvironmentAtom, - sidebarEnvironmentGroupingAtom, -} from "./sidebarCollapsedAtoms"; import { SidebarControlButton, SidebarRowControls } from "./SidebarRowControls"; import { SIDEBAR_CONTROL_BUTTON_CLASS } from "./sidebarRowClasses"; @@ -43,52 +34,12 @@ interface HeaderCreationActions { const HeaderCreationContext = createContext({}); export const SidebarHeaderActionsProvider = HeaderCreationContext.Provider; -const SIDEBAR_ORGANIZE_OPTIONS = [ - { label: "By project", mode: "project" }, - { label: "By machine", mode: "machine" }, - { label: "Custom", mode: "chronological" }, -] as const; - -const SIDEBAR_SORT_OPTIONS = [ - { label: "Updated at", sort: "updated", direction: "descending" }, - { label: "Created at", sort: "created", direction: "descending" }, - { label: "Alphabetical", sort: "alpha", direction: "ascending" }, -] as const; - const LazySidebarViewItems = lazy(() => import("./SidebarViewItems").then(({ SidebarViewItems }) => ({ default: SidebarViewItems, })), ); -export interface SidebarViewItemsProps { - page: "organize" | "sort" | "filter"; - settings: ReturnType; - organizeOptions: typeof SIDEBAR_ORGANIZE_OPTIONS; - sortOptions: typeof SIDEBAR_SORT_OPTIONS; -} - -function useSidebarViewSettings() { - const [lifecycles, setLifecycles] = useAtom(sidebarThreadLifecyclesAtom); - const [organization, setOrganization] = useAtom(sidebarOrganizationModeAtom); - const [sort, setSort] = useAtom(sidebarChronologicalSortAtom); - const [savedDirection, setDirection] = useAtom(sidebarSortDirectionAtom); - const setEnvironmentGrouping = useSetAtom(sidebarEnvironmentGroupingAtom); - const groupByEnvironment = useAtomValue(sidebarGroupThreadsByEnvironmentAtom); - const selectedSort = sort === "none" ? "updated" : sort; - return { - lifecycles, - setLifecycles, - organization, - setOrganization, - groupByEnvironment, - setEnvironmentGrouping, - setSort, - savedDirection, - setDirection, - selectedSort, - }; -} export function SidebarHeaderControls({ label, onNewThread, @@ -105,7 +56,6 @@ export function SidebarHeaderControls({ onOpenChange?: (open: boolean) => void; }) { const creation = useContext(HeaderCreationContext); - const settings = useSidebarViewSettings(); const compact = useIsCompactViewport(); const [page, setPage] = useState<"organize" | "sort" | "filter" | null>(null); const changeOpen = (next: boolean) => { @@ -167,12 +117,7 @@ export function SidebarHeaderControls({ Loading…} > - + ) : ( @@ -234,12 +179,7 @@ export function SidebarHeaderControls({ Loading… } > - +
diff --git a/apps/app/src/components/sidebar/SidebarViewItems.tsx b/apps/app/src/components/sidebar/SidebarViewItems.tsx index dd3bab622d4..6c5419316c5 100644 --- a/apps/app/src/components/sidebar/SidebarViewItems.tsx +++ b/apps/app/src/components/sidebar/SidebarViewItems.tsx @@ -1,3 +1,4 @@ +import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { Icon } from "@bb/shared-ui/icon"; import { DropdownMenuGroup, @@ -6,26 +7,39 @@ import { DropdownMenuSeparator, } from "@bb/shared-ui/dropdown-menu"; import { ThreadLifecycleFilterItems } from "@/components/thread/ThreadLifecycleFilter"; -import type { SidebarViewItemsProps } from "./SidebarHeaderControls"; +import { + sidebarOrganizationModeAtom, + sidebarChronologicalSortAtom, + sidebarSortDirectionAtom, + sidebarThreadLifecyclesAtom, + sidebarGroupThreadsByEnvironmentAtom, + sidebarEnvironmentGroupingAtom, +} from "./sidebarCollapsedAtoms"; + +const SIDEBAR_ORGANIZE_OPTIONS = [ + { label: "By project", mode: "project" }, + { label: "By machine", mode: "machine" }, + { label: "Custom", mode: "chronological" }, +] as const; + +const SIDEBAR_SORT_OPTIONS = [ + { label: "Updated at", sort: "updated", direction: "descending" }, + { label: "Created at", sort: "created", direction: "descending" }, + { label: "Alphabetical", sort: "alpha", direction: "ascending" }, +] as const; export function SidebarViewItems({ page, - settings, - organizeOptions, - sortOptions, -}: SidebarViewItemsProps) { - const { - lifecycles, - setLifecycles, - organization, - setOrganization, - groupByEnvironment, - setEnvironmentGrouping, - setSort, - savedDirection, - setDirection, - selectedSort, - } = settings; +}: { + page: "organize" | "sort" | "filter"; +}) { + const [lifecycles, setLifecycles] = useAtom(sidebarThreadLifecyclesAtom); + const [organization, setOrganization] = useAtom(sidebarOrganizationModeAtom); + const [sort, setSort] = useAtom(sidebarChronologicalSortAtom); + const [savedDirection, setDirection] = useAtom(sidebarSortDirectionAtom); + const setEnvironmentGrouping = useSetAtom(sidebarEnvironmentGroupingAtom); + const groupByEnvironment = useAtomValue(sidebarGroupThreadsByEnvironmentAtom); + const selectedSort = sort === "none" ? "updated" : sort; if (page === "filter") { return ( @@ -41,7 +55,7 @@ export function SidebarViewItems({ <> Sections - {organizeOptions.map((option) => ( + {SIDEBAR_ORGANIZE_OPTIONS.map((option) => ( - {sortOptions.map((option) => { + {SIDEBAR_SORT_OPTIONS.map((option) => { const selected = selectedSort === option.sort; const direction = savedDirection === "default" ? option.direction : savedDirection; From 55c762560a4b3588c566477df397493b12abc65f Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 20:26:57 -0700 Subject: [PATCH 33/57] Remove draft thread lifecycle API and classification --- .../commands/CommandPalette.test.tsx | 1 - .../app/src/hooks/cache-owners/query-cache.ts | 5 - .../thread-runtime-cache-owner.test.ts | 35 +-- .../thread-runtime-cache-owner.ts | 14 +- .../palette-thread-search.test.ts | 1 - .../command-output/thread-list.test.ts | 25 -- .../thread-organization.test.ts | 27 -- apps/cli/src/commands/thread/helpers.ts | 21 -- apps/cli/src/commands/thread/list.ts | 8 - apps/cli/src/commands/thread/organization.ts | 13 +- apps/cli/src/json-shapes.ts | 5 +- apps/demo-server/src/fixtures/world.ts | 1 - apps/server/src/routes/threads/base.ts | 21 -- .../threads/thread-runtime-display.ts | 27 +- .../test/public/public-thread-search.test.ts | 140 +++-------- .../threads/thread-runtime-display.test.ts | 62 ----- .../test/threads/dispatch-hooks.test.ts | 18 +- .../db/src/data/queued-thread-messages.ts | 2 - packages/db/src/data/threads.ts | 70 +----- .../data/thread-discovery-lifecycle.test.ts | 235 ------------------ packages/domain/src/thread.ts | 5 - packages/sdk/src/areas/threads.ts | 14 +- packages/sdk/test/sdk.test.ts | 34 --- packages/server-contract/src/api/threads.ts | 14 -- .../server-contract/test/contract.test.ts | 2 - .../templates/src/templates/bb-guide-json.md | 4 +- .../src/templates/bb-guide-threads.md | 14 +- packages/test-helpers/src/domain-fixtures.ts | 1 - .../bb-cli/references/thread-operation.md | 8 - 29 files changed, 69 insertions(+), 758 deletions(-) delete mode 100644 packages/db/test/data/thread-discovery-lifecycle.test.ts diff --git a/apps/app/src/components/commands/CommandPalette.test.tsx b/apps/app/src/components/commands/CommandPalette.test.tsx index 31ff5a720f0..acf02e3764d 100644 --- a/apps/app/src/components/commands/CommandPalette.test.tsx +++ b/apps/app/src/components/commands/CommandPalette.test.tsx @@ -308,7 +308,6 @@ function makeThread( environmentWorkspaceDisplayKind: "other", runtime: { displayStatus: "idle", hostReconnectGraceExpiresAt: null }, queuedWork: "none", - lifecycle: overrides.archivedAt != null ? "archived" : "active", ...overrides, }; } diff --git a/apps/app/src/hooks/cache-owners/query-cache.ts b/apps/app/src/hooks/cache-owners/query-cache.ts index 7b3cb508289..7e3a40b8460 100644 --- a/apps/app/src/hooks/cache-owners/query-cache.ts +++ b/apps/app/src/hooks/cache-owners/query-cache.ts @@ -2,7 +2,6 @@ import type { QueryClient, QueryKey } from "@tanstack/react-query"; import type { Thread, ThreadListEntry, - ThreadLifecycle, ThreadStatusChangeMetadata, } from "@bb/domain"; import { @@ -573,14 +572,10 @@ function threadMatchesListFilters( export function optimisticallyInsertThread( queryClient: QueryClient, thread: ThreadResponse, - lifecycle: ThreadLifecycle = thread.archivedAt !== null - ? "archived" - : "active", ): void { const queuedWork = thread.queuedMessageCount > 0 ? "waiting" : "none"; const insertedThread: ThreadListEntry = { ...thread, - lifecycle, activity: { activeWorkflowCount: 0, activeBackgroundAgentCount: 0, diff --git a/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.test.ts b/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.test.ts index 77edad530f8..61a1584aa9b 100644 --- a/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.test.ts +++ b/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.test.ts @@ -15,10 +15,7 @@ import { makeProjectWithThreadsResponse, makeSidebarBootstrapResponse, } from "@/test/fixtures/projects"; -import { - makeThreadResponse, - makeThreadTimelineResponse as makeTimelineResponse, -} from "@/test/fixtures/thread-responses"; +import { makeThreadTimelineResponse as makeTimelineResponse } from "@/test/fixtures/thread-responses"; import { sidebarNavigationQueryKey, threadListQueryKey, @@ -30,7 +27,6 @@ import { } from "../queries/query-keys"; import { threadDefaultExecutionOptionsQueryKey } from "../queries/thread-default-execution-options-query"; import { - applyCreateThreadResult, applyQueuedMessageCreateResult, applyQueuedMessageSendResult, applyQueuedMessageUpdateResult, @@ -116,35 +112,6 @@ function makeQueuedMessage( } describe("thread runtime cache owner", () => { - it("keeps a newly saved draft classified while the list refreshes", () => { - const queryClient = createAppQueryClient({ - defaultOptions: { queries: { gcTime: Infinity, retry: false } }, - showMutationErrorToasts: false, - }); - const key = threadListQueryKey({ archived: false, projectId: "project-1" }); - queryClient.setQueryData(key, []); - - applyCreateThreadResult({ - queryClient, - request: { - projectId: "project-1", - input: [{ type: "text", text: "Save for later", mentions: [] }], - environment: { type: "project-default" }, - pluginSubmission: { pluginId: "drafts", data: { kind: "draft" } }, - }, - thread: makeThreadResponse({ - id: "thread-draft", - projectId: "project-1", - status: "pending", - queuedMessageCount: 1, - }), - }); - - expect(queryClient.getQueryData(key)).toEqual([ - expect.objectContaining({ id: "thread-draft", lifecycle: "draft" }), - ]); - }); - it.each([ ["Plan", applyThreadPlanCancellationResult, "activePlanModeCount"], ["Goal", applyThreadGoalClearResult, "activeGoalCount"], diff --git a/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.ts b/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.ts index 9fe2ebb3cb3..695a69c1c41 100644 --- a/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.ts +++ b/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.ts @@ -890,19 +890,7 @@ export function applyCreateThreadResult({ thread, }: CreateThreadSuccessArgs): void { queryClient.setQueryData(threadQueryKey(thread.id), thread); - const submission = request.pluginSubmission; - const savedDraft = - thread.status === "pending" && - submission?.pluginId === "drafts" && - submission.data !== null && - typeof submission.data === "object" && - !Array.isArray(submission.data) && - submission.data.kind === "draft"; - optimisticallyInsertThread( - queryClient, - thread, - savedDraft ? "draft" : "active", - ); + optimisticallyInsertThread(queryClient, thread); prependProjectPromptHistory( queryClient, request.projectId, diff --git a/apps/app/src/lib/command-palette/palette-thread-search.test.ts b/apps/app/src/lib/command-palette/palette-thread-search.test.ts index 8ddaf6be9ae..8ce19f522ec 100644 --- a/apps/app/src/lib/command-palette/palette-thread-search.test.ts +++ b/apps/app/src/lib/command-palette/palette-thread-search.test.ts @@ -49,7 +49,6 @@ function makeThread( environmentWorkspaceDisplayKind: "other", runtime: { displayStatus: "idle", hostReconnectGraceExpiresAt: null }, queuedWork: "none", - lifecycle: overrides.archivedAt != null ? "archived" : "active", ...overrides, }; } diff --git a/apps/cli/src/__tests__/command-output/thread-list.test.ts b/apps/cli/src/__tests__/command-output/thread-list.test.ts index 418b466756f..23557696b35 100644 --- a/apps/cli/src/__tests__/command-output/thread-list.test.ts +++ b/apps/cli/src/__tests__/command-output/thread-list.test.ts @@ -16,31 +16,6 @@ describe("bb thread list command output", () => { const register: CommandRegistrar = (program) => registerThreadCommands(program, () => "http://server"); - it("passes explicit lifecycle filters without imposing the legacy archive filter", async () => { - const list = vi.fn(async () => []); - stubServerApi({ "v1.threads.$get": list }); - - await runCommand( - ["thread", "list", "--lifecycle", "draft,archived"], - register, - ); - - expect(list).toHaveBeenCalledWith({ - query: { lifecycles: "draft,archived" }, - }); - }); - - it("rejects an empty lifecycle instead of broadening the list", async () => { - const list = vi.fn(async () => []); - stubServerApi({ "v1.threads.$get": list }); - - await expect( - runCommand(["thread", "list", "--lifecycle", ""], register), - ).rejects.toThrow("process.exit:1"); - - expect(list).not.toHaveBeenCalled(); - }); - it("bb thread list supports parent-thread filtering", async () => { const list = vi.fn(async () => []); stubServerApi({ "v1.threads.$get": list }); diff --git a/apps/cli/src/__tests__/command-output/thread-organization.test.ts b/apps/cli/src/__tests__/command-output/thread-organization.test.ts index 76db1d88c92..5c2edcab641 100644 --- a/apps/cli/src/__tests__/command-output/thread-organization.test.ts +++ b/apps/cli/src/__tests__/command-output/thread-organization.test.ts @@ -41,33 +41,6 @@ describe("bb thread organization commands", () => { const register: CommandRegistrar = (program) => registerThreadCommands(program, () => "http://server"); - it("forwards lifecycle filtering and the per-group limit", async () => { - const search = vi.fn(async () => ({ - active: { total: 0, results: [] }, - archived: { total: 0, results: [] }, - draft: { total: 0, results: [] }, - })); - stubServerApi({ "v1.threads.search.$get": search }); - - await runCommand( - [ - "thread", - "search", - "release", - "--lifecycle", - "draft", - "--limit", - "3", - "--json", - ], - register, - ); - - expect(search).toHaveBeenCalledWith({ - query: { query: "release", lifecycles: "draft", limitPerGroup: "3" }, - }); - }); - it("creates a named thread section", async () => { const create = vi.fn(async () => ({ id: "section-review", diff --git a/apps/cli/src/commands/thread/helpers.ts b/apps/cli/src/commands/thread/helpers.ts index 9179b0656fd..d7bbee371cc 100644 --- a/apps/cli/src/commands/thread/helpers.ts +++ b/apps/cli/src/commands/thread/helpers.ts @@ -8,8 +8,6 @@ import { type PromptInput, serviceTierSchema, type ServiceTier, - threadLifecycleSchema, - type ThreadLifecycle, } from "@bb/domain"; import { DEFAULT_THREAD_WAIT_POLL_INTERVAL_MS, @@ -31,25 +29,6 @@ export const PERMISSION_MODE_HELP = export const PLAN_HELP = "Send the message as the provider's /plan action so the agent proposes a plan for approval before executing"; -export function parseThreadLifecycles( - value: string | undefined, -): ThreadLifecycle[] | undefined { - if (value === undefined) return undefined; - return [ - ...new Set( - value.split(",").map((entry) => { - const result = threadLifecycleSchema.safeParse(entry.trim()); - if (!result.success) { - throw new Error( - "--lifecycle must contain active, draft, or archived, separated by commas.", - ); - } - return result.data; - }), - ), - ]; -} - export function buildPromptInputs(args: { message: string; files?: readonly string[]; diff --git a/apps/cli/src/commands/thread/list.ts b/apps/cli/src/commands/thread/list.ts index ec71258b411..eb4111bd248 100644 --- a/apps/cli/src/commands/thread/list.ts +++ b/apps/cli/src/commands/thread/list.ts @@ -9,14 +9,12 @@ import { truncateCell, } from "../../table.js"; import { outputJson } from "../helpers.js"; -import { parseThreadLifecycles } from "./helpers.js"; interface ThreadListCommandOptions { environment?: string; project?: string; parentThread?: string; archived?: boolean; - lifecycle?: string; section?: string; unsectioned?: boolean; json?: boolean; @@ -36,10 +34,6 @@ export function registerListCommand( .option("--section ", "Filter by thread section ID") .option("--unsectioned", "Show only threads outside sections") .option("--archived", "Show only archived threads") - .option( - "--lifecycle ", - "Filter by active, draft, or archived (comma-separated)", - ) .option("--include-hidden", "Include hidden threads") .option("--json", "Print machine-readable JSON output") .action( @@ -64,9 +58,7 @@ export function registerListCommand( flagName: "--section", value: opts.section, }); - const lifecycles = parseThreadLifecycles(opts.lifecycle); const threads = await sdk.threads.list({ - ...(lifecycles === undefined ? {} : { lifecycles }), ...(projectId ? { projectId } : {}), ...(environmentId ? { environmentId } : {}), ...(parentThreadId ? { parentThreadId } : {}), diff --git a/apps/cli/src/commands/thread/organization.ts b/apps/cli/src/commands/thread/organization.ts index a1f7955a721..f5e53141b51 100644 --- a/apps/cli/src/commands/thread/organization.ts +++ b/apps/cli/src/commands/thread/organization.ts @@ -22,11 +22,7 @@ import { outputJson, requireThreadIdOrSelf, } from "../helpers.js"; -import { - buildPromptInputs, - parseThreadLifecycles, - uploadClientAttachmentInputs, -} from "./helpers.js"; +import { buildPromptInputs, uploadClientAttachmentInputs } from "./helpers.js"; interface JsonOptions { json?: boolean; @@ -42,7 +38,6 @@ interface SectionDeleteOptions extends JsonOptions { interface SearchOptions extends JsonOptions { limit?: string; - lifecycle?: string; } interface HistoryOptions extends JsonOptions { @@ -235,10 +230,6 @@ export function registerOrganizationCommands( parent .command("search ") .description("Search threads and messages") - .option( - "--lifecycle ", - "Filter by active, draft, or archived (comma-separated)", - ) .option( "--limit ", `Maximum results per group (1-${THREAD_SEARCH_LIMIT_PER_GROUP_MAX})`, @@ -246,10 +237,8 @@ export function registerOrganizationCommands( .option("--json", "Print machine-readable JSON output") .action( action(async (query: string, opts: SearchOptions) => { - const lifecycles = parseThreadLifecycles(opts.lifecycle); const result = await createCliBbSdk(getUrl()).threads.search({ query, - ...(lifecycles === undefined ? {} : { lifecycles }), limitPerGroup: parsePositiveInteger( opts.limit, "--limit", diff --git a/apps/cli/src/json-shapes.ts b/apps/cli/src/json-shapes.ts index 59db40e053a..2cfce1d7beb 100644 --- a/apps/cli/src/json-shapes.ts +++ b/apps/cli/src/json-shapes.ts @@ -2,7 +2,7 @@ export const JSON_SHAPE_BY_COMMAND_PATH: Readonly> = { status: "{project: {id, name} | null, thread: {id, status, title, parentThreadId, environment: {hostId, display} | null} | null, childThreads: [{id, status, title}] | null, pendingTodos, pluginsNeedingAttention: [{id, status}], dataDir}", "thread list": - "[{id, projectId, environmentId, providerId, title, status, lifecycle, parentThreadId, sectionId, visibility, archivedAt, pinnedAt, createdAt, updatedAt, activity}] (bare array; title can be null)", + "[{id, projectId, environmentId, providerId, title, status, parentThreadId, sectionId, visibility, archivedAt, pinnedAt, createdAt, updatedAt, activity}] (bare array; title can be null)", "thread show": "{thread: {id, status, title, projectId, environmentId, parentThreadId, ...}, environment: {id, hostId, path, branchName, ...} | null, pendingTodos} (thread fields are under .thread)", "thread log": @@ -11,8 +11,7 @@ export const JSON_SHAPE_BY_COMMAND_PATH: Readonly> = { "thread spawn": "the created thread: {id, status, title, projectId, environmentId, ...}", "thread wait": "{threadId, matched: true, target}", - "thread search": - "{active: {total, results}, archived: {total, results}, draft?: {total, results}} (draft group present with --lifecycle)", + "thread search": "{active: {total, results}, archived: {total, results}}", "project list": "[{id, kind, name, gitRemoteUrl, sources: [{id, hostId, path, isDefault}]}] (bare array)", "machine list": diff --git a/apps/demo-server/src/fixtures/world.ts b/apps/demo-server/src/fixtures/world.ts index 5014f6b5fdc..b1060de990d 100644 --- a/apps/demo-server/src/fixtures/world.ts +++ b/apps/demo-server/src/fixtures/world.ts @@ -84,7 +84,6 @@ export function threadListEntry( environmentIsWorktree: null, environmentWorkspaceDisplayKind: "other", queuedWork: "none", - lifecycle: "active", }; } diff --git a/apps/server/src/routes/threads/base.ts b/apps/server/src/routes/threads/base.ts index 999e7f404b0..e26770630a7 100644 --- a/apps/server/src/routes/threads/base.ts +++ b/apps/server/src/routes/threads/base.ts @@ -17,7 +17,6 @@ import { type UpdateThreadInput, } from "@bb/db"; import type { Environment, Thread, ThreadListEntry } from "@bb/domain"; -import { threadLifecycleSchema } from "@bb/domain"; import { toEnvironmentResponse } from "../../services/environments/environment-response.js"; import { threadIncludeOptionSchema, @@ -86,7 +85,6 @@ interface BuildThreadSearchGroupResponseArgs { interface BuildThreadSearchResponseArgs { active: DbThreadSearchResultGroup; - draft?: DbThreadSearchResultGroup; archived: DbThreadSearchResultGroup; } @@ -206,11 +204,6 @@ function buildThreadSearchResponse( ): ThreadSearchResponse { return { active: buildThreadSearchGroupResponse(deps, { group: args.active }), - ...(args.draft === undefined - ? {} - : { - draft: buildThreadSearchGroupResponse(deps, { group: args.draft }), - }), archived: buildThreadSearchGroupResponse(deps, { group: args.archived }), }; } @@ -279,13 +272,6 @@ export function registerThreadBaseRoutes(app: Hono, deps: AppDeps): void { requireThreadSection(deps, query.sectionId); } const threads = listThreadsWithPendingInteractionState(deps.db, { - ...(query.lifecycles === undefined - ? {} - : { - lifecycles: query.lifecycles - .split(",") - .map((value) => threadLifecycleSchema.parse(value)), - }), ...(query.projectId ? { projectId: query.projectId } : {}), ...(query.environmentId ? { environmentId: query.environmentId } : {}), ...(query.parentThreadId ? { parentThreadId: query.parentThreadId } : {}), @@ -322,13 +308,6 @@ export function registerThreadBaseRoutes(app: Hono, deps: AppDeps): void { ...searchThreadsWithPendingInteractionState(deps.db, { query: searchQuery, limitPerGroup, - ...(query.lifecycles === undefined - ? {} - : { - lifecycles: query.lifecycles - .split(",") - .map((value) => threadLifecycleSchema.parse(value)), - }), }), }) satisfies ThreadSearchResponse, ); diff --git a/apps/server/src/services/threads/thread-runtime-display.ts b/apps/server/src/services/threads/thread-runtime-display.ts index b51372589f1..4dc73f35993 100644 --- a/apps/server/src/services/threads/thread-runtime-display.ts +++ b/apps/server/src/services/threads/thread-runtime-display.ts @@ -19,7 +19,6 @@ import type { ThreadActivityState, ThreadChangeMetadata, ThreadListEntry, - ThreadLifecycle, ThreadQueuedWork, ThreadRuntimeState, ThreadStatus, @@ -88,7 +87,6 @@ interface ToThreadListEntryResponseFromLatestSessionArgs { latestSession: HostDaemonSessionRow | null; now?: number; queuedWork: ThreadQueuedWork; - lifecycle: ThreadLifecycle; thread: ThreadWithPendingInteractionState; } @@ -524,19 +522,16 @@ function buildThreadActivityStateByThreadId( function buildThreadQueuedWorkByThreadId( deps: ThreadRuntimeDisplayDeps, threads: readonly Thread[], -): Map { - const result = new Map< - string, - { queuedWork: ThreadQueuedWork; hasDraft: boolean } - >(); +): Map { + const result = new Map(); for (const counts of listQueuedThreadMessageCountsByThreadIds(deps.db, { threadIds: threads.map((thread) => thread.id), })) { if (counts.queuedMessageCount === 0) continue; - result.set(counts.threadId, { - queuedWork: counts.failedQueuedMessageCount > 0 ? "failed" : "waiting", - hasDraft: counts.draftQueuedMessageCount > 0, - }); + result.set( + counts.threadId, + counts.failedQueuedMessageCount > 0 ? "failed" : "waiting", + ); } return result; } @@ -573,16 +568,9 @@ export function toThreadListEntryResponses( args.threads, ); return args.threads.map((thread) => { - const queue = queuedWorkByThreadId.get(thread.id); return toThreadListEntryResponseFromLatestSession({ activity: activityByThreadId.get(thread.id) ?? EMPTY_THREAD_ACTIVITY, - queuedWork: queue?.queuedWork ?? "none", - lifecycle: - thread.archivedAt !== null - ? "archived" - : thread.status === "pending" && queue?.hasDraft === true - ? "draft" - : "active", + queuedWork: queuedWorkByThreadId.get(thread.id) ?? "none", hostConnected: thread.environmentHostId !== null && connectedActiveHostIds.has(thread.environmentHostId), @@ -604,7 +592,6 @@ function toThreadListEntryResponseFromLatestSession( ...thread, activity: args.activity, queuedWork: args.queuedWork, - lifecycle: args.lifecycle, pinSortKey: args.thread.pinSortKey, environmentBranchName: args.thread.environmentBranchName, environmentHostId: args.thread.environmentHostId, diff --git a/apps/server/test/public/public-thread-search.test.ts b/apps/server/test/public/public-thread-search.test.ts index 09f9a508dd3..e20f02e608d 100644 --- a/apps/server/test/public/public-thread-search.test.ts +++ b/apps/server/test/public/public-thread-search.test.ts @@ -1,8 +1,5 @@ import { archiveThread, createQueuedThreadMessage } from "@bb/db"; -import { - threadListResponseSchema, - threadSearchResponseSchema, -} from "@bb/server-contract"; +import { threadSearchResponseSchema } from "@bb/server-contract"; import { describe, expect, it } from "vitest"; import { readJson } from "../helpers/json.js"; import { @@ -13,6 +10,44 @@ import { import { withTestHarness } from "../helpers/test-app.js"; describe("public thread search route", () => { + it("finds saved first messages and follow-ups in the existing thread groups", async () => { + await withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps); + const { project } = seedProjectWithSource(harness.deps, { hostId: host.id }); + const first = seedThread(harness.deps, { projectId: project.id, status: "pending" }); + const followup = seedThread(harness.deps, { projectId: project.id }); + const archived = seedThread(harness.deps, { projectId: project.id }); + const hidden = seedThread(harness.deps, { projectId: project.id, visibility: "hidden" }); + for (const thread of [first, followup, archived, hidden]) { + createQueuedThreadMessage(harness.db, harness.deps.hub, { + threadId: thread.id, + content: [{ type: "text", text: "Juniper saved message", mentions: [] }], + model: "gpt-5", + reasoningLevel: "medium", + permissionMode: "full", + serviceTier: "default", + waitingOn: { kind: "plugin", pluginId: "drafts", reason: "Draft" }, + sendAt: null, + payload: { kind: "inline" }, + systemNotice: null, + }); + } + archiveThread(harness.db, harness.deps.hub, archived.id); + const response = await harness.app.request("/api/v1/threads/search?query=Juniper"); + expect(response.status).toBe(200); + const body = threadSearchResponseSchema.parse(await readJson(response)); + expect(Object.keys(body)).toEqual(["active", "archived"]); + expect(new Set(body.active.results.map((result) => result.thread.id))).toEqual(new Set([first.id, followup.id])); + expect(body.archived.results.map((result) => result.thread.id)).toEqual([archived.id]); + for (const result of [...body.active.results, ...body.archived.results]) { + expect(result.thread).not.toHaveProperty("lifecycle"); + expect(result.matches).toEqual(expect.arrayContaining([ + expect.objectContaining({ text: "Juniper saved message", sourceSeq: null }), + ])); + } + }); + }); + it("returns active and archived search result groups", async () => { await withTestHarness(async (harness) => { const { host } = seedHostSession(harness.deps); @@ -87,101 +122,4 @@ describe("public thread search route", () => { expect(badLimitResponse.status).toBe(400); }); }); - - it("opts into lifecycle list filters and a separate draft search group", async () => { - await withTestHarness(async (harness) => { - const { host } = seedHostSession(harness.deps); - const { project } = seedProjectWithSource(harness.deps, { - hostId: host.id, - }); - const draft = seedThread(harness.deps, { - projectId: project.id, - status: "pending", - title: "lifecycleroute draft", - }); - const active = seedThread(harness.deps, { - projectId: project.id, - title: "lifecycleroute active", - }); - const archived = seedThread(harness.deps, { - projectId: project.id, - title: "lifecycleroute archived", - }); - const hidden = seedThread(harness.deps, { - projectId: project.id, - status: "pending", - title: "lifecycleroute hidden", - visibility: "hidden", - }); - for (const thread of [draft, hidden]) { - createQueuedThreadMessage(harness.db, harness.deps.hub, { - threadId: thread.id, - content: [{ type: "text", text: "Saved draft", mentions: [] }], - model: "gpt-5", - reasoningLevel: "medium", - permissionMode: "full", - serviceTier: "default", - waitingOn: { kind: "plugin", pluginId: "drafts", reason: "Draft" }, - sendAt: null, - payload: { kind: "inline" }, - systemNotice: null, - }); - } - archiveThread(harness.db, harness.deps.hub, archived.id); - const legacyResponse = await harness.app.request( - "/api/v1/threads/search?query=lifecycleroute", - ); - const legacy = threadSearchResponseSchema.parse( - await readJson(legacyResponse), - ); - expect(Object.keys(legacy)).toEqual(["active", "archived"]); - expect( - new Set(legacy.active.results.map((result) => result.thread.id)), - ).toEqual(new Set([draft.id, active.id])); - const response = await harness.app.request( - "/api/v1/threads/search?query=lifecycleroute&lifecycles=draft,archived", - ); - const body = threadSearchResponseSchema.parse(await readJson(response)); - expect(response.status).toBe(200); - expect(body.active).toEqual({ total: 0, results: [] }); - expect( - body.draft?.results.map((result) => [ - result.thread.id, - result.thread.lifecycle, - ]), - ).toEqual([[draft.id, "draft"]]); - expect(body.archived.results.map((result) => result.thread.id)).toEqual([ - archived.id, - ]); - const listResponse = await harness.app.request( - `/api/v1/threads?projectId=${project.id}&lifecycles=draft&limit=1`, - ); - expect(listResponse.status).toBe(200); - expect( - threadListResponseSchema - .parse(await readJson(listResponse)) - .map((thread) => thread.id), - ).toEqual([draft.id]); - const intersection = await harness.app.request( - `/api/v1/threads?projectId=${project.id}&lifecycles=draft&archived=true`, - ); - expect(await readJson(intersection)).toEqual([]); - for (const lifecycles of ["", "unknown", "draft,", "draft,unknown"]) { - expect( - ( - await harness.app.request( - `/api/v1/threads?lifecycles=${lifecycles}`, - ) - ).status, - ).toBe(400); - expect( - ( - await harness.app.request( - `/api/v1/threads/search?query=lifecycleroute&lifecycles=${lifecycles}`, - ) - ).status, - ).toBe(400); - } - }); - }); }); diff --git a/apps/server/test/services/threads/thread-runtime-display.test.ts b/apps/server/test/services/threads/thread-runtime-display.test.ts index 5f2137aeed6..2deaaf650c0 100644 --- a/apps/server/test/services/threads/thread-runtime-display.test.ts +++ b/apps/server/test/services/threads/thread-runtime-display.test.ts @@ -3,8 +3,6 @@ import { eq } from "drizzle-orm"; import { describe, expect, it } from "vitest"; import { appendStoredThreadEvent, - archiveThread, - claimQueuedThreadMessage, closeSession, createConnection, createEnvironment, @@ -521,66 +519,6 @@ describe("thread runtime display", () => { ).toHaveLength(32_767); }); - it("projects only unarchived pending threads with live Drafts holds as drafts", () => { - const { db, hostId, hub } = setup(); - const saved = createThreadWithEnvironment({ - db, - hostId, - status: "pending", - }); - const followup = createThreadWithEnvironment({ - db, - hostId, - status: "idle", - }); - const archived = createThreadWithEnvironment({ - db, - hostId, - status: "pending", - }); - const claimed = createThreadWithEnvironment({ - db, - hostId, - status: "pending", - }); - const pending = createThreadWithEnvironment({ - db, - hostId, - status: "pending", - }); - for (const fixture of [saved, followup, archived, claimed]) { - const queued = createQueuedThreadMessage(db, noopNotifier, { - threadId: fixture.thread.id, - content: [{ type: "text", text: "Saved draft", mentions: [] }], - model: "gpt-5", - reasoningLevel: "medium", - permissionMode: "auto", - serviceTier: "default", - waitingOn: { kind: "plugin", pluginId: "drafts", reason: "Draft" }, - sendAt: null, - payload: { kind: "inline" }, - systemNotice: null, - }); - if (fixture === claimed) - claimQueuedThreadMessage(db, noopNotifier, queued.id); - } - archiveThread(db, noopNotifier, archived.thread.id); - const entries = toThreadListEntryResponses( - { db, hub, providerRegistry }, - { - threads: listThreadsWithPendingInteractionState(db, {}), - }, - ); - const byId = new Map(entries.map((entry) => [entry.id, entry])); - expect( - [saved, followup, archived, claimed, pending].map( - (fixture) => byId.get(fixture.thread.id)?.lifecycle, - ), - ).toEqual(["draft", "active", "archived", "active", "active"]); - expect(byId.get(saved.thread.id)?.queuedWork).toBe("waiting"); - expect(byId.get(claimed.thread.id)?.queuedWork).toBe("none"); - }); - it("marks list entries active when the prompt banner would show plan or goal state", () => { const { db, hostId, hub } = setup(); const activePlan = createThreadWithEnvironment({ db, hostId }); diff --git a/apps/server/test/threads/dispatch-hooks.test.ts b/apps/server/test/threads/dispatch-hooks.test.ts index aa984fcfc75..89ddc629324 100644 --- a/apps/server/test/threads/dispatch-hooks.test.ts +++ b/apps/server/test/threads/dispatch-hooks.test.ts @@ -7,7 +7,6 @@ import { listQueuedThreadMessages, listQueuedThreadMessagesForApi, listRunningThreads, - listThreadsWithPendingInteractionState, setQueuedThreadMessageGroupBoundary, } from "@bb/db"; import type { ThreadQueuedMessage } from "@bb/domain"; @@ -492,11 +491,7 @@ describe("message.dispatch hook context", () => { }); expect(seen).toEqual([pluginSubmission]); - expect( - listThreadsWithPendingInteractionState(harness.db, { - lifecycles: ["draft"], - }).map((thread) => thread.id), - ).toEqual([created.id]); + expect(getThread(harness.db, created.id)?.status).toBe("pending"); const saved = onlyQueuedRow(harness, created.id); await sendQueuedMessage(harness.deps, { threadId: created.id, @@ -505,16 +500,7 @@ describe("message.dispatch hook context", () => { claimPolicy: { kind: "explicit-send" }, }); expect(seen).toEqual([pluginSubmission]); - expect( - listThreadsWithPendingInteractionState(harness.db, { - lifecycles: ["draft"], - }), - ).toEqual([]); - expect( - listThreadsWithPendingInteractionState(harness.db, { - lifecycles: ["active"], - }).map((thread) => thread.id), - ).toContain(created.id); + expect(getThread(harness.db, created.id)?.status).not.toBe("pending"); expect(queuedRows(harness, created.id)).toEqual([]); }); }); diff --git a/packages/db/src/data/queued-thread-messages.ts b/packages/db/src/data/queued-thread-messages.ts index c76e004e1d1..79a029c6a60 100644 --- a/packages/db/src/data/queued-thread-messages.ts +++ b/packages/db/src/data/queued-thread-messages.ts @@ -1572,7 +1572,6 @@ export function listQueuedThreadMessagesForApi( export interface QueuedThreadMessageCounts { threadId: string; queuedMessageCount: number; - draftQueuedMessageCount: number; /** * How many of those rows last failed to dispatch. Counted in the same pass * as the total because both answers come from the same rows, and the thread @@ -1606,7 +1605,6 @@ export function listQueuedThreadMessageCountsByThreadIds( threadId: queuedThreadMessages.threadId, queuedMessageCount: count(queuedThreadMessages.id), failedQueuedMessageCount: count(queuedThreadMessages.failureReason), - draftQueuedMessageCount: sql`count(CASE WHEN ${queuedThreadMessages.waitHolder} = 'plugin:drafts' THEN 1 END)`.mapWith(Number), }) .from(queuedThreadMessages) .where( diff --git a/packages/db/src/data/threads.ts b/packages/db/src/data/threads.ts index ea3390124be..9375627e1bb 100644 --- a/packages/db/src/data/threads.ts +++ b/packages/db/src/data/threads.ts @@ -14,14 +14,12 @@ import { or, sql, type SQL, - type SQLWrapper, } from "drizzle-orm"; import type { JsonObject, ReasoningLevel, ThreadChangeKind, ThreadLifecycleEvent, - ThreadLifecycle, ThreadLifecycleNoopReason, ThreadOriginKind, ThreadSearchSourceKind, @@ -108,14 +106,12 @@ export interface ThreadSearchResultGroup { export interface ThreadSearchResults { active: ThreadSearchResultGroup; - draft?: ThreadSearchResultGroup; archived: ThreadSearchResultGroup; } export interface SearchThreadsWithPendingInteractionStateArgs { query: string; limitPerGroup: number; - lifecycles?: readonly ThreadLifecycle[]; } export interface UpsertThreadTitleSearchSegmentsArgs { @@ -147,11 +143,10 @@ interface ListThreadSearchMatchRowsArgs { anyTokenMatchQuery: string; limitPerGroup: number; tokenMatchQueries: readonly string[]; - lifecycles?: readonly ThreadLifecycle[]; } interface ThreadSearchMatchRow { - lifecycle: ThreadLifecycle; + archived: number; segmentOrder: number; sourceKind: string; sourceSeq: number | null; @@ -418,7 +413,6 @@ export interface ListThreadsOptions { projectId?: string; environmentId?: string; archived?: boolean; - lifecycles?: readonly ThreadLifecycle[]; sectionId?: string; unsectioned?: boolean; parentThreadId?: string; @@ -682,27 +676,8 @@ function statusTransitionNeedsAttention(args: StatusTransition): boolean { return args.currentStatus === "active" || args.currentStatus === "starting"; } -function threadLifecycleSql(thread: { - id: SQLWrapper; - archivedAt: SQLWrapper; - status: SQLWrapper; -} = threads): SQL { - return sql`CASE - WHEN ${thread.archivedAt} IS NOT NULL THEN 'archived' - WHEN ${thread.status} = 'pending' AND ${thread.id} IN ( - SELECT thread_id FROM queued_thread_messages - WHERE wait_holder = 'plugin:drafts' - AND claimed_at IS NULL AND claim_token IS NULL - ) THEN 'draft' - ELSE 'active' - END`; -} - function buildListThreadsFilters(options: ListThreadsOptions) { return [ - options.lifecycles === undefined - ? undefined - : inArray(threadLifecycleSql(), [...options.lifecycles]), options.projectId ? eq(threads.projectId, options.projectId) : undefined, options.environmentId ? eq(threads.environmentId, options.environmentId) @@ -769,9 +744,6 @@ function buildPinnedThreadOrderBy() { } function buildListThreadsOrderBy(options: ListThreadsOptions) { - if (options.lifecycles !== undefined) { - return [desc(threads.updatedAt), desc(threads.id)]; - } if (options.archived === true) { return [desc(threads.archivedAt), desc(threads.id)]; } @@ -1021,17 +993,6 @@ function listThreadSearchMatchRows( ); const isTitleSegment = sql`thread_search_segments.source_kind IN ('title', 'title_fallback')`; - const lifecycle = args.lifecycles === undefined - ? sql`CASE WHEN t.archived_at IS NOT NULL THEN 'archived' ELSE 'active' END` - : threadLifecycleSql({ - id: sql`t.id`, - archivedAt: sql`t.archived_at`, - status: sql`t.status`, - }); - const lifecycleFilter = args.lifecycles === undefined - ? sql`1 = 1` - : inArray(sql`lifecycle`, [...args.lifecycles]); - return db.all(sql` WITH token_matches AS ( ${sql.join(tokenMatchSelects, sql` UNION ALL `)} @@ -1041,7 +1002,7 @@ function listThreadSearchMatchRows( token_matches.threadId AS threadId, MIN(token_matches.tokenRank) AS bestRank, MAX(t.updated_at) AS threadUpdatedAt, - ${lifecycle} AS lifecycle + MAX(t.archived_at IS NOT NULL) AS archived FROM token_matches JOIN threads AS t ON t.id = token_matches.threadId WHERE t.deleted_at IS NULL @@ -1052,23 +1013,22 @@ function listThreadSearchMatchRows( ordered_threads AS ( SELECT threadId, - lifecycle, + archived, ROW_NUMBER() OVER ( - PARTITION BY lifecycle + PARTITION BY archived ORDER BY bestRank ASC, threadUpdatedAt DESC, threadId DESC ) AS threadOrder, - COUNT(*) OVER (PARTITION BY lifecycle) AS total + COUNT(*) OVER (PARTITION BY archived) AS total FROM ranked_threads - WHERE ${lifecycleFilter} ), limited_threads AS ( - SELECT threadId, lifecycle, threadOrder, total + SELECT threadId, archived, threadOrder, total FROM ordered_threads WHERE threadOrder <= ${args.limitPerGroup} ), ranked_segments AS ( SELECT - limited_threads.lifecycle AS lifecycle, + limited_threads.archived AS archived, limited_threads.threadOrder AS threadOrder, limited_threads.total AS total, ROW_NUMBER() OVER ( @@ -1091,7 +1051,7 @@ function listThreadSearchMatchRows( WHERE thread_search_segments_fts MATCH ${args.anyTokenMatchQuery} ) SELECT - lifecycle, + archived, threadOrder, total, segmentOrder, @@ -1103,7 +1063,7 @@ function listThreadSearchMatchRows( FROM ranked_segments WHERE isTitle = 1 OR segmentOrder <= ${THREAD_SEARCH_MESSAGE_MATCHES_PER_THREAD} - ORDER BY lifecycle ASC, threadOrder ASC, isTitle DESC, segmentOrder ASC + ORDER BY archived ASC, threadOrder ASC, isTitle DESC, segmentOrder ASC `); } @@ -1176,7 +1136,6 @@ export function searchThreadsWithPendingInteractionState( if (anyTokenMatchQuery === null) { return { active: { total: 0, results: [] }, - ...(args.lifecycles === undefined ? {} : { draft: { total: 0, results: [] } }), archived: { total: 0, results: [] }, }; } @@ -1189,23 +1148,16 @@ export function searchThreadsWithPendingInteractionState( anyTokenMatchQuery, limitPerGroup, tokenMatchQueries, - ...(args.lifecycles === undefined ? {} : { lifecycles: args.lifecycles }), }); return { active: hydrateThreadSearchGroup(db, { tokens, - rows: rows.filter((row) => row.lifecycle === "active"), - }), - ...(args.lifecycles === undefined ? {} : { - draft: hydrateThreadSearchGroup(db, { - tokens, - rows: rows.filter((row) => row.lifecycle === "draft"), - }), + rows: rows.filter((row) => row.archived === 0), }), archived: hydrateThreadSearchGroup(db, { tokens, - rows: rows.filter((row) => row.lifecycle === "archived"), + rows: rows.filter((row) => row.archived === 1), }), }; } diff --git a/packages/db/test/data/thread-discovery-lifecycle.test.ts b/packages/db/test/data/thread-discovery-lifecycle.test.ts deleted file mode 100644 index 903728bbfca..00000000000 --- a/packages/db/test/data/thread-discovery-lifecycle.test.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { eq } from "drizzle-orm"; -import { describe, expect, it, vi } from "vitest"; -import { threadScope, type ThreadLifecycle } from "@bb/domain"; -import { createConnection } from "../../src/connection.js"; -import { migrate } from "../../src/migrate.js"; -import { noopNotifier } from "../../src/notifier.js"; -import { threads } from "../../src/schema.js"; -import { insertEvents, listEvents } from "../../src/data/events.js"; -import { upsertHost } from "../../src/data/hosts.js"; -import { createProject } from "../../src/data/projects.js"; -import { - claimQueuedThreadMessage, - createQueuedThreadMessage, - deleteQueuedThreadMessage, - getQueuedThreadMessage, - listQueuedThreadMessageCountsByThreadIds, - setQueuedThreadMessageFailureReason, -} from "../../src/data/queued-thread-messages.js"; -import { - archiveThread, - createThread, - getThread, - listThreadsWithPendingInteractionState, - searchThreadsWithPendingInteractionState, - unarchiveThread, -} from "../../src/data/threads.js"; - -function setup() { - const db = createConnection(":memory:"); - migrate(db); - const host = upsertHost(db, noopNotifier, { name: "lifecycle-host" }); - const { project } = createProject(db, noopNotifier, { - name: "lifecycle-project", - source: { type: "local_path", hostId: host.id, path: "/tmp/lifecycle" }, - }); - function thread(status: "pending" | "idle" = "pending") { - return createThread(db, noopNotifier, { - projectId: project.id, - providerId: "codex", - status, - title: "discovery lifecycle", - }); - } - function draft(threadId: string, pluginId = "drafts") { - return createQueuedThreadMessage(db, noopNotifier, { - threadId, - content: [{ type: "text", text: "saved message", mentions: [] }], - model: "gpt-5", - reasoningLevel: "medium", - permissionMode: "full", - serviceTier: "default", - waitingOn: { kind: "plugin", pluginId, reason: "Draft" }, - sendAt: null, - payload: { kind: "inline" }, - systemNotice: null, - }); - } - function list(lifecycles: readonly ThreadLifecycle[]) { - return listThreadsWithPendingInteractionState(db, { lifecycles }).map((row) => row.id); - } - return { db, host, project, thread, draft, list }; -} - -describe("derived thread lifecycle discovery", () => { - it("recognizes live Drafts waits, preserves archive precedence and counts in one grouped pass", () => { - const { db, thread, draft, list } = setup(); - try { - const saved = thread(); - const savedRow = draft(saved.id); - const followup = thread("idle"); - draft(followup.id); - const other = thread(); - draft(other.id, "scheduler"); - const claimed = thread(); - const claimedRow = draft(claimed.id); - claimQueuedThreadMessage(db, noopNotifier, claimedRow.id); - setQueuedThreadMessageFailureReason(db, noopNotifier, { - threadId: saved.id, - id: savedRow.id, - failureReason: "Host unavailable", - }); - const prepare = vi.spyOn(db.$client, "prepare"); - try { - expect(listQueuedThreadMessageCountsByThreadIds(db, { - threadIds: [saved.id, followup.id, other.id, claimed.id], - })).toEqual(expect.arrayContaining([ - { threadId: saved.id, queuedMessageCount: 1, failedQueuedMessageCount: 1, draftQueuedMessageCount: 1 }, - { threadId: followup.id, queuedMessageCount: 1, failedQueuedMessageCount: 0, draftQueuedMessageCount: 1 }, - { threadId: other.id, queuedMessageCount: 1, failedQueuedMessageCount: 0, draftQueuedMessageCount: 0 }, - ])); - expect(prepare).toHaveBeenCalledTimes(1); - } finally { - prepare.mockRestore(); - } - expect(list(["draft"])).toEqual([saved.id]); - expect(new Set(list(["active"]))).toEqual(new Set([followup.id, other.id, claimed.id])); - archiveThread(db, noopNotifier, saved.id); - expect(list(["draft"])).toEqual([]); - expect(list(["archived"])).toEqual([saved.id]); - unarchiveThread(db, noopNotifier, saved.id); - expect(list(["draft"])).toEqual([saved.id]); - expect(getQueuedThreadMessage(db, savedRow.id)?.waitingOn).toContain('"drafts"'); - } finally { - db.$client.close(); - } - }); - - it("filters before list offsets and search limits while legacy search keeps drafts active", () => { - const { db, thread, draft } = setup(); - try { - const first = thread(); - const second = thread(); - draft(first.id); - draft(second.id); - db.update(threads).set({ createdAt: 1, updatedAt: 1 }).where(eq(threads.id, first.id)).run(); - db.update(threads).set({ createdAt: 2, updatedAt: 2 }).where(eq(threads.id, second.id)).run(); - for (let index = 0; index < 24; index += 1) thread("idle"); - const archived = thread(); - draft(archived.id); - archiveThread(db, noopNotifier, archived.id); - const hidden = thread(); - draft(hidden.id); - db.update(threads).set({ visibility: "hidden" }).where(eq(threads.id, hidden.id)).run(); - const deleted = thread(); - draft(deleted.id); - db.update(threads).set({ deletedAt: Date.now() }).where(eq(threads.id, deleted.id)).run(); - - expect(listThreadsWithPendingInteractionState(db, { - lifecycles: ["draft"], limit: 1, offset: 1, - }).map((row) => row.id)).toEqual([first.id]); - expect(listThreadsWithPendingInteractionState(db, { - lifecycles: ["draft"], archived: true, - })).toEqual([]); - const legacy = searchThreadsWithPendingInteractionState(db, { - query: "discovery", limitPerGroup: 50, - }); - expect(Object.keys(legacy)).toEqual(["active", "archived"]); - expect(legacy.active.total).toBe(26); - expect(legacy.active.results.map((result) => result.thread.id)).toContain(first.id); - const filtered = searchThreadsWithPendingInteractionState(db, { - query: "discovery", limitPerGroup: 1, lifecycles: ["draft"], - }); - expect(filtered.active).toEqual({ total: 0, results: [] }); - expect(filtered.archived).toEqual({ total: 0, results: [] }); - expect(filtered.draft?.total).toBe(2); - expect(filtered.draft?.results.map((result) => result.thread.id)).toEqual([second.id]); - const all = searchThreadsWithPendingInteractionState(db, { - query: "discovery", limitPerGroup: 50, lifecycles: ["active", "draft", "archived"], - }); - expect([all.active.total, all.draft?.total, all.archived.total]).toEqual([24, 2, 1]); - expect(all.archived.results.map((result) => result.thread.id)).toEqual([archived.id]); - } finally { - db.$client.close(); - } - }); - - it("bounds lifecycle lists by global recency without changing legacy project or archive ordering", () => { - const { db, host, project, draft } = setup(); - try { - const { project: otherProject } = createProject(db, noopNotifier, { - name: "other-lifecycle-project", - source: { type: "local_path", hostId: host.id, path: "/tmp/other-lifecycle" }, - }); - const older = createThread(db, noopNotifier, { - projectId: project.id < otherProject.id ? project.id : otherProject.id, - providerId: "codex", - status: "pending", - }); - const recent = createThread(db, noopNotifier, { - projectId: project.id < otherProject.id ? otherProject.id : project.id, - providerId: "codex", - status: "pending", - }); - draft(older.id); - draft(recent.id); - db.update(threads).set({ createdAt: 20, updatedAt: 30, pinnedAt: 10 }).where(eq(threads.id, older.id)).run(); - db.update(threads).set({ createdAt: 10, updatedAt: 40 }).where(eq(threads.id, recent.id)).run(); - - expect(listThreadsWithPendingInteractionState(db, { - lifecycles: ["draft"], limit: 1, - }).map((row) => row.id)).toEqual([recent.id]); - expect(listThreadsWithPendingInteractionState(db, { - lifecycles: ["draft"], limit: 1, offset: 1, - }).map((row) => row.id)).toEqual([older.id]); - expect(listThreadsWithPendingInteractionState(db, { - archived: false, limit: 1, - }).map((row) => row.id)).toEqual([older.id]); - - db.update(threads).set({ archivedAt: 60 }).where(eq(threads.id, older.id)).run(); - db.update(threads).set({ archivedAt: 50 }).where(eq(threads.id, recent.id)).run(); - expect(listThreadsWithPendingInteractionState(db, { - lifecycles: ["archived"], archived: true, limit: 1, - }).map((row) => row.id)).toEqual([recent.id]); - expect(listThreadsWithPendingInteractionState(db, { - archived: true, limit: 1, - }).map((row) => row.id)).toEqual([older.id]); - } finally { - db.$client.close(); - } - }); - - it("deletes only the held row and preserves its owning fork and inherited history", () => { - const { db, project, thread, draft, list } = setup(); - try { - const source = thread("idle"); - const fork = createThread(db, noopNotifier, { - projectId: project.id, - providerId: "codex", - status: "pending", - sourceThreadId: source.id, - originKind: "fork", - }); - insertEvents(db, noopNotifier, [{ - threadId: fork.id, - sequence: 1, - type: "item/completed", - scope: threadScope(), - itemId: "inherited-message", - itemKind: "agentMessage", - parentToolCallId: null, - data: JSON.stringify({ item: { id: "inherited-message", type: "agentMessage", text: "Inherited response" } }), - }]); - const saved = draft(fork.id); - const before = listEvents(db, { threadId: fork.id }); - expect(list(["draft"])).toEqual([fork.id]); - expect(deleteQueuedThreadMessage(db, noopNotifier, saved.id)).toBe(true); - expect(getThread(db, fork.id)).toMatchObject({ sourceThreadId: source.id, deletedAt: null, status: "pending" }); - expect(listEvents(db, { threadId: fork.id })).toEqual(before); - expect(list(["draft"])).toEqual([]); - expect(list(["active"])).toContain(fork.id); - } finally { - db.$client.close(); - } - }); -}); diff --git a/packages/domain/src/thread.ts b/packages/domain/src/thread.ts index e13e8ff1a20..405edfee51a 100644 --- a/packages/domain/src/thread.ts +++ b/packages/domain/src/thread.ts @@ -437,12 +437,7 @@ export const threadQueuedWorkValues = ["none", "waiting", "failed"] as const; export const threadQueuedWorkSchema = z.enum(threadQueuedWorkValues); export type ThreadQueuedWork = z.infer; -export const threadLifecycleValues = ["active", "draft", "archived"] as const; -export const threadLifecycleSchema = z.enum(threadLifecycleValues); -export type ThreadLifecycle = z.infer; - export const threadListEntrySchema = threadWithRuntimeSchema.extend({ - lifecycle: threadLifecycleSchema, activity: threadActivityStateSchema, queuedWork: threadQueuedWorkSchema, pinSortKey: z.string().nullable(), diff --git a/packages/sdk/src/areas/threads.ts b/packages/sdk/src/areas/threads.ts index 04d105225c9..8dd6bd7b42f 100644 --- a/packages/sdk/src/areas/threads.ts +++ b/packages/sdk/src/areas/threads.ts @@ -11,7 +11,6 @@ import { type QueuedMessageWaitHolder, type ThreadQueuedMessage, type ThreadStatus, - type ThreadLifecycle, validatePluginMetadata, } from "@bb/domain"; import { @@ -86,7 +85,6 @@ export const DEFAULT_THREAD_WAIT_POLL_INTERVAL_MS = 250; export interface ThreadListArgs { archived?: boolean; - lifecycles?: readonly ThreadLifecycle[]; environmentId?: string; sectionId?: string; hasParent?: boolean; @@ -102,11 +100,7 @@ export interface ThreadListArgs { unsectioned?: boolean; } -export interface ThreadSearchArgs extends Omit< - ThreadSearchQuery, - "lifecycles" -> { - lifecycles?: readonly ThreadLifecycle[]; +export interface ThreadSearchArgs extends ThreadSearchQuery { signal?: AbortSignal; } @@ -643,9 +637,6 @@ function listQuery(args: ThreadListArgs | undefined): ThreadListQuery { ...(args?.archived === undefined ? {} : { archived: args.archived ? "true" : "false" }), - ...(args?.lifecycles === undefined - ? {} - : { lifecycles: args.lifecycles.join(",") }), ...(args?.unsectioned === undefined ? {} : { unsectioned: args.unsectioned ? "true" : "false" }), @@ -782,9 +773,6 @@ function searchQuery(args: ThreadSearchArgs): ThreadSearchQuery { return { limitPerGroup: args.limitPerGroup, query: args.query, - ...(args.lifecycles === undefined - ? {} - : { lifecycles: args.lifecycles.join(",") }), }; } diff --git a/packages/sdk/test/sdk.test.ts b/packages/sdk/test/sdk.test.ts index 36b689d23f6..e5fe470a80d 100644 --- a/packages/sdk/test/sdk.test.ts +++ b/packages/sdk/test/sdk.test.ts @@ -1312,40 +1312,6 @@ describe("@bb/sdk", () => { }); }); - it("opts into lifecycle filtering without changing legacy list and search requests", async () => { - const queue = createFetchQueue( - Array.from({ length: 4 }, () => ({ body: [] })), - ); - const sdk = createBbSdk({ - transport: createHttpTransport({ - baseUrl: "http://bb.test", - fetch: queue.fetch, - runtime: "node", - }), - }); - - await sdk.threads.list(); - await sdk.threads.search({ query: "release" }); - await sdk.threads.list({ lifecycles: ["draft", "archived"], limit: 5 }); - await sdk.threads.search({ - query: "release", - lifecycles: ["draft"], - limitPerGroup: "3", - }); - - expect( - queue.requests.map(({ url }) => { - const parsed = new URL(url); - return Object.fromEntries(parsed.searchParams); - }), - ).toEqual([ - {}, - { query: "release" }, - { lifecycles: "draft,archived", limit: "5" }, - { query: "release", lifecycles: "draft", limitPerGroup: "3" }, - ]); - }); - it("forwards every public permission mode through thread surfaces", async () => { const queue = createFetchQueue([ { body: { id: "thr_auto" }, status: 201 }, diff --git a/packages/server-contract/src/api/threads.ts b/packages/server-contract/src/api/threads.ts index dc8078de732..6dedb4a04dc 100644 --- a/packages/server-contract/src/api/threads.ts +++ b/packages/server-contract/src/api/threads.ts @@ -23,7 +23,6 @@ import { threadCreateOriginSchema, threadOriginKindSchema, threadListEntrySchema, - threadLifecycleSchema, threadQueuedMessageSchema, threadSearchSourceKindSchema, threadStatusSchema, @@ -477,7 +476,6 @@ export const threadSearchResultGroupSchema = z export const threadSearchResponseSchema = z .object({ active: threadSearchResultGroupSchema, - draft: threadSearchResultGroupSchema.optional(), archived: threadSearchResultGroupSchema, }) .strict(); @@ -753,18 +751,7 @@ export type ThreadArchiveAllResponse = z.infer< typeof threadArchiveAllResponseSchema >; -const threadLifecyclesQuerySchema = z - .string() - .refine( - (value) => - value - .split(",") - .every((entry) => threadLifecycleSchema.safeParse(entry).success), - { message: "Invalid lifecycles" }, - ); - export const threadListQuerySchema = z.object({ - lifecycles: threadLifecyclesQuerySchema.optional(), projectId: z.string().min(1).optional(), environmentId: z.string().min(1).optional(), parentThreadId: z.string().min(1).optional(), @@ -866,7 +853,6 @@ export const threadRunningResponseSchema = z.array(threadRunningEntrySchema); export type ThreadRunningResponse = z.infer; export const threadSearchQuerySchema = z.object({ - lifecycles: threadLifecyclesQuerySchema.optional(), query: z.string().trim().min(2), limitPerGroup: z.string().regex(/^\d+$/).optional(), }); diff --git a/packages/server-contract/test/contract.test.ts b/packages/server-contract/test/contract.test.ts index 95f98331a71..9f42226af4d 100644 --- a/packages/server-contract/test/contract.test.ts +++ b/packages/server-contract/test/contract.test.ts @@ -507,7 +507,6 @@ const OPTIONAL_SERVER_FIELD_GROUPS: readonly OptionalServerFieldGroup[] = [ "threadListQuerySchema.limit", "threadListQuerySchema.hasParent", "threadListQuerySchema.includeHidden", - "threadListQuerySchema.lifecycles", "threadListQuerySchema.offset", "threadListQuerySchema.originKind", "threadListQuerySchema.originPluginId", @@ -1226,7 +1225,6 @@ describe("server-contract canonical schemas", () => { environmentIsWorktree: true, environmentWorkspaceDisplayKind: "managed-worktree", queuedWork: "none", - lifecycle: "active", }, ]), ).toMatchObject([ diff --git a/packages/templates/src/templates/bb-guide-json.md b/packages/templates/src/templates/bb-guide-json.md index 7dedda70403..02162dd6e2e 100644 --- a/packages/templates/src/templates/bb-guide-json.md +++ b/packages/templates/src/templates/bb-guide-json.md @@ -38,7 +38,7 @@ Fields beyond those shown exist; these are the ones scripts use. {project: {id, name} | null, thread: {id, status, title, parentThreadId, environment: {hostId, display} | null} | null, childThreads: [{id, status, title}] | null, pendingTodos, pluginsNeedingAttention: [{id, status}], dataDir} bb thread list --json - [{id, projectId, environmentId, providerId, title, status, lifecycle, parentThreadId, sectionId, visibility, archivedAt, pinnedAt, createdAt, updatedAt, activity}] (bare array; title can be null) + [{id, projectId, environmentId, providerId, title, status, parentThreadId, sectionId, visibility, archivedAt, pinnedAt, createdAt, updatedAt, activity}] (bare array; title can be null) bb thread show --json {thread: {id, status, title, projectId, environmentId, parentThreadId, ...}, environment: {id, hostId, path, branchName, ...} | null, pendingTodos} (thread fields are under .thread) @@ -62,7 +62,7 @@ Fields beyond those shown exist; these are the ones scripts use. {total} bb thread search --json - {active: {total, results}, archived: {total, results}, draft?: {total, results}} (draft group present with --lifecycle) + {active: {total, results}, archived: {total, results}} bb thread section list --json [{id, name, createdAt, updatedAt}] diff --git a/packages/templates/src/templates/bb-guide-threads.md b/packages/templates/src/templates/bb-guide-threads.md index 152b027e233..d079cdf2cfc 100644 --- a/packages/templates/src/templates/bb-guide-threads.md +++ b/packages/templates/src/templates/bb-guide-threads.md @@ -160,7 +160,6 @@ Listing: --environment Filter by environment --parent-thread Filter by parent thread --archived Show only archived threads - --lifecycle Filter by active, draft, or archived (comma-separated) --section Filter by section --unsectioned Show only threads outside sections --include-hidden Include hidden threads @@ -172,15 +171,6 @@ Listing: bb thread search [--limit <1-50>] Search threads and messages - --lifecycle Filter by active, draft, or archived (comma-separated) - - Lifecycle draft means an unarchived pending thread held by Drafts. Saved - follow-ups on an established thread do not change its lifecycle. Archived - takes precedence. Omit --lifecycle to retain the existing list/search groups; - with it, search also returns a draft group. --archived intersects the lifecycle - filter when both are given. Lifecycle-filtered lists sort by last updated, - newest first, before pagination; omitted filters keep existing list ordering. - SDK list/search accept lifecycles as an array. bb thread history List prompt history bb thread count Count threads without listing them @@ -298,8 +288,8 @@ Messaging: combined with --mode steer or auto. SDK callers pass pluginSubmission: { pluginId: "drafts", data: { kind: "draft" } } to threads.spawn or threads.send; follow-up saves use mode: "queue-if-active". - Save-only requests require the Drafts plugin to be available. A saved - follow-up keeps its established thread active; it does not become a draft thread. + Save-only requests require the Drafts plugin to be available. Saved messages + are searchable through their owning threads with bb thread search. --plan sends the same structured /plan command the composer's plan action sends, so the agent proposes a plan for approval before executing (Claude diff --git a/packages/test-helpers/src/domain-fixtures.ts b/packages/test-helpers/src/domain-fixtures.ts index 44a55bc1737..2e17d4e52ab 100644 --- a/packages/test-helpers/src/domain-fixtures.ts +++ b/packages/test-helpers/src/domain-fixtures.ts @@ -186,7 +186,6 @@ export function makeThreadListEntry( environmentIsWorktree: null, environmentWorkspaceDisplayKind: "other", queuedWork: "none", - lifecycle: overrides.archivedAt != null ? "archived" : "active", }; return { ...entry, diff --git a/plugins/bb-guide/skills/bb-cli/references/thread-operation.md b/plugins/bb-guide/skills/bb-cli/references/thread-operation.md index 1f19264b8b5..af7b852c319 100644 --- a/plugins/bb-guide/skills/bb-cli/references/thread-operation.md +++ b/plugins/bb-guide/skills/bb-cli/references/thread-operation.md @@ -102,14 +102,6 @@ hostId, providerId, projectId, parentThreadId, groupBy })`. ## Inspecting Results -- Add `--lifecycle active,draft,archived` to thread list/search to select any - nonempty subset. Draft means pending with a Drafts-held first message; - established threads with saved follow-ups stay active. Archived takes - precedence. Omission retains legacy groups; opt-in search adds a draft group. - List `--archived` intersects this filter. SDK list/search accept `lifecycles` - as an array. Filtering precedes result limits and counts. Lifecycle-filtered - lists sort by last updated, newest first, before pagination; omission keeps - existing list ordering. - Use `bb thread search [--limit <1-50>]` for sidebar search. Use `history`, `read|unread`, and `section` for organization and recall. The `bb thread queue` group contains the queued-message operations. Queue updates From 6af934a289c3dae76206bc8e33a67d93e88bc5b9 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 20:38:25 -0700 Subject: [PATCH 34/57] Remove remaining lifecycle fields from sidebar fixtures --- apps/app/src/components/sidebar/ThreadRow.test.tsx | 4 ++-- apps/app/src/hooks/realtime-cache-effects.test.ts | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/app/src/components/sidebar/ThreadRow.test.tsx b/apps/app/src/components/sidebar/ThreadRow.test.tsx index 58c2b69e6d4..5030efa98c2 100644 --- a/apps/app/src/components/sidebar/ThreadRow.test.tsx +++ b/apps/app/src/components/sidebar/ThreadRow.test.tsx @@ -234,7 +234,7 @@ afterEach(() => { describe("ThreadRow", () => { it("keeps one restore action visible and blocks row pointer, keyboard, and click propagation", () => { - const thread = createThread({ archivedAt: 1, lifecycle: "archived" }); + const thread = createThread({ archivedAt: 1 }); const rowEvent = vi.fn(); const client = new QueryClient(); render( @@ -260,7 +260,7 @@ describe("ThreadRow", () => { it("disables only the restoring thread and recovers when its mutation fails", async () => { const client = new QueryClient(); - const thread = createThread({ archivedAt: 1, lifecycle: "archived" }); + const thread = createThread({ archivedAt: 1 }); let rejectRestore!: (error: Error) => void; const mutation = client.getMutationCache().build(client, { mutationKey: ["unarchive-thread"], diff --git a/apps/app/src/hooks/realtime-cache-effects.test.ts b/apps/app/src/hooks/realtime-cache-effects.test.ts index 69da42835df..8160354a1c8 100644 --- a/apps/app/src/hooks/realtime-cache-effects.test.ts +++ b/apps/app/src/hooks/realtime-cache-effects.test.ts @@ -2307,7 +2307,6 @@ describe("createRealtimeCacheEffects", () => { const idleRow = { activity: NO_THREAD_ACTIVITY, archivedAt: null, - lifecycle: "active", id: "thr_1", latestAttentionAt: 100, runtime: { displayStatus: "idle", hostReconnectGraceExpiresAt: null }, From 744fd8ac5b0e59c6c0b497ea48e60be42a852ae1 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 20:42:00 -0700 Subject: [PATCH 35/57] Align palette regressions with Active and Archived filters --- apps/app/src/hooks/queries/thread-queries.test.tsx | 2 -- apps/app/src/lib/ui-preferences/ui-preferences-sync.test.ts | 6 +++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/app/src/hooks/queries/thread-queries.test.tsx b/apps/app/src/hooks/queries/thread-queries.test.tsx index e5eeb532223..380a461f671 100644 --- a/apps/app/src/hooks/queries/thread-queries.test.tsx +++ b/apps/app/src/hooks/queries/thread-queries.test.tsx @@ -21,7 +21,6 @@ import { threadPendingInteractionsQueryKey, threadQueuedMessagesQueryKey, threadQueryKey, - threadSearchQueryKey, threadTimelineQueryKey, } from "./query-keys"; import { usePaletteRecentArchivedThreads } from "./palette-thread-queries"; @@ -31,7 +30,6 @@ import { isPendingInteractionStateUnknown, useArchivedThreads, useChildThreads, - useThreadSearch, useThread, useThreadDetailBootstrap, useThreadHostFilePreview, diff --git a/apps/app/src/lib/ui-preferences/ui-preferences-sync.test.ts b/apps/app/src/lib/ui-preferences/ui-preferences-sync.test.ts index 61a857b2ec7..f6a7788f6f8 100644 --- a/apps/app/src/lib/ui-preferences/ui-preferences-sync.test.ts +++ b/apps/app/src/lib/ui-preferences/ui-preferences-sync.test.ts @@ -96,18 +96,18 @@ describe("ui preferences sync", () => { ); const stop = startUiPreferencesSync({ queryClient, store }); expect(store.get(palette)).toEqual(["active"]); - store.set(palette, ["draft"]); + store.set(palette, ["active", "archived"]); await waitForUiPreferenceWrites(); expect(mocks.set).toHaveBeenCalledExactlyOnceWith({ key: "palette.threadLifecycles", - value: ["draft"], + value: ["active", "archived"], expectedRevision: 0, }); expect(store.get(sidebar)).toEqual(["archived"]); stop(); const reloadedStore = createStore(); startUiPreferencesSync({ queryClient, store: reloadedStore }); - expect(reloadedStore.get(palette)).toEqual(["draft"]); + expect(reloadedStore.get(palette)).toEqual(["active", "archived"]); expect(reloadedStore.get(sidebar)).toEqual(["archived"]); reconcileUiPreferences( serverResponse({ From 741fd214d8badb3e23a13f68340e65c9641e501e Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 21:16:55 -0700 Subject: [PATCH 36/57] Use Filter for thread filter labels --- .../sidebar/SidebarThreadLifecycles.test.tsx | 2 +- apps/app/src/components/sidebar/SidebarViewItems.tsx | 2 +- .../components/thread/ThreadLifecycleFilter.test.tsx | 4 +++- .../src/components/thread/ThreadLifecycleFilter.tsx | 10 +++++----- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx b/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx index 8492c9b26c9..e09fe937c1f 100644 --- a/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx +++ b/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx @@ -259,7 +259,7 @@ describe("sidebar lifecycle placement", () => { const store = setup([lifecycle], true); expect(screen.getByText("No threads")).toBeTruthy(); expect( - screen.queryByRole("button", { name: /Thread lifecycle:/ }), + screen.queryByRole("button", { name: /Filter:/ }), ).toBeNull(); const label = "Threads"; fireEvent.keyDown( diff --git a/apps/app/src/components/sidebar/SidebarViewItems.tsx b/apps/app/src/components/sidebar/SidebarViewItems.tsx index 6c5419316c5..dd52301d5a0 100644 --- a/apps/app/src/components/sidebar/SidebarViewItems.tsx +++ b/apps/app/src/components/sidebar/SidebarViewItems.tsx @@ -42,7 +42,7 @@ export function SidebarViewItems({ const selectedSort = sort === "none" ? "updated" : sort; if (page === "filter") { return ( - + { viewport.compact = compact; const { container } = render(); const trigger = screen.getByRole("button", { - name: "Thread lifecycle: Active", + name: "Filter: Active", }); if (compact) { fireEvent.click(trigger); @@ -45,6 +45,8 @@ describe("ThreadLifecycleFilter", () => { const active = await screen.findByRole("menuitemcheckbox", { name: "Active", }); + expect(screen.getByRole("group", { name: "Filter" })).toBeTruthy(); + expect(active.getAttribute("title")).toBe("Keep at least one filter selected"); expect(active.getAttribute("aria-disabled")).not.toBe("true"); expect(active.hasAttribute("data-disabled")).toBe(false); fireEvent.click(active); diff --git a/apps/app/src/components/thread/ThreadLifecycleFilter.tsx b/apps/app/src/components/thread/ThreadLifecycleFilter.tsx index 9df3403f79a..6bbf5a1a4fd 100644 --- a/apps/app/src/components/thread/ThreadLifecycleFilter.tsx +++ b/apps/app/src/components/thread/ThreadLifecycleFilter.tsx @@ -37,7 +37,7 @@ export function ThreadLifecycleFilterItems({ role="menuitemcheckbox" aria-checked={checked} title={ - required ? "Keep at least one lifecycle selected" : undefined + required ? "Keep at least one filter selected" : undefined } onSelect={(event) => { event.preventDefault(); @@ -84,15 +84,15 @@ export function ThreadLifecycleFilter({ variant="ghost" size="sm" className="min-w-0 max-w-full justify-start" - aria-label={`Thread lifecycle: ${label}`} + aria-label={`Filter: ${label}`} > {label} - - - Thread lifecycle + + + Filter From 693824d4cb80f2c3100d8fbdcb3e5d5e541dfab1 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 21:17:09 -0700 Subject: [PATCH 37/57] Align palette keyboard coverage with Filter label --- apps/app/src/components/commands/CommandPalette.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/app/src/components/commands/CommandPalette.test.tsx b/apps/app/src/components/commands/CommandPalette.test.tsx index 0b20c29d01d..4cba053737d 100644 --- a/apps/app/src/components/commands/CommandPalette.test.tsx +++ b/apps/app/src/components/commands/CommandPalette.test.tsx @@ -1096,7 +1096,7 @@ describe("CommandPalette", () => { openThreadSearch(); await screen.findByRole("combobox", { name: "Search threads" }); const trigger = screen.getByRole("button", { - name: "Thread lifecycle: Active", + name: "Filter: Active", }); act(() => trigger.focus()); fireEvent.keyDown(trigger, { key: "ArrowDown" }); From c717d3498520d1ab489a9ac284c082be1be2d60d Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 21:19:58 -0700 Subject: [PATCH 38/57] Assert native hover hint only in desktop filter menu --- .../src/components/thread/ThreadLifecycleFilter.test.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/app/src/components/thread/ThreadLifecycleFilter.test.tsx b/apps/app/src/components/thread/ThreadLifecycleFilter.test.tsx index 42901107a6f..cfd0b732938 100644 --- a/apps/app/src/components/thread/ThreadLifecycleFilter.test.tsx +++ b/apps/app/src/components/thread/ThreadLifecycleFilter.test.tsx @@ -46,7 +46,11 @@ describe("ThreadLifecycleFilter", () => { name: "Active", }); expect(screen.getByRole("group", { name: "Filter" })).toBeTruthy(); - expect(active.getAttribute("title")).toBe("Keep at least one filter selected"); + if (!compact) { + expect(active.getAttribute("title")).toBe( + "Keep at least one filter selected", + ); + } expect(active.getAttribute("aria-disabled")).not.toBe("true"); expect(active.hasAttribute("data-disabled")).toBe(false); fireEvent.click(active); From 165defedc126efdc0f79a8f02d68d5793ee152a4 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 21:27:45 -0700 Subject: [PATCH 39/57] Match the thread filter icon to existing controls --- apps/app/src/components/thread/ThreadLifecycleFilter.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/app/src/components/thread/ThreadLifecycleFilter.tsx b/apps/app/src/components/thread/ThreadLifecycleFilter.tsx index 6bbf5a1a4fd..535b4d5944d 100644 --- a/apps/app/src/components/thread/ThreadLifecycleFilter.tsx +++ b/apps/app/src/components/thread/ThreadLifecycleFilter.tsx @@ -86,6 +86,11 @@ export function ThreadLifecycleFilter({ className="min-w-0 max-w-full justify-start" aria-label={`Filter: ${label}`} > + {label} From fa8c241c7eb1d3f9eec5b632a43786a70582b149 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 21:40:08 -0700 Subject: [PATCH 40/57] Show All when every thread filter is selected --- .../src/components/thread/ThreadLifecycleFilter.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/app/src/components/thread/ThreadLifecycleFilter.tsx b/apps/app/src/components/thread/ThreadLifecycleFilter.tsx index 535b4d5944d..2dbf5f55e88 100644 --- a/apps/app/src/components/thread/ThreadLifecycleFilter.tsx +++ b/apps/app/src/components/thread/ThreadLifecycleFilter.tsx @@ -71,11 +71,14 @@ export function ThreadLifecycleFilter({ onChange, }: ThreadLifecycleFilterProps) { const value = normalizeThreadLifecycleFilter(savedValue); - const label = THREAD_LIFECYCLE_OPTIONS.filter((option) => - value.includes(option.value), - ) - .map((option) => option.label) - .join(", "); + const label = + value.length === THREAD_LIFECYCLE_OPTIONS.length + ? "All" + : THREAD_LIFECYCLE_OPTIONS.filter((option) => + value.includes(option.value), + ) + .map((option) => option.label) + .join(", "); return ( From b8d60aded25ec1ad1e15fd00b310e51f49c755bc Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 21:41:05 -0700 Subject: [PATCH 41/57] Cover the combined thread filter label --- apps/app/src/components/commands/CommandPalette.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/app/src/components/commands/CommandPalette.test.tsx b/apps/app/src/components/commands/CommandPalette.test.tsx index 4cba053737d..2c0a7e0f4df 100644 --- a/apps/app/src/components/commands/CommandPalette.test.tsx +++ b/apps/app/src/components/commands/CommandPalette.test.tsx @@ -1106,6 +1106,8 @@ describe("CommandPalette", () => { act(() => archived.focus()); fireEvent.keyDown(archived, { key: "Enter" }); expect(store.get(paletteThreadLifecyclesAtom)).toEqual(["active", "archived"]); + expectText(trigger, "All"); + expect(trigger.getAttribute("aria-label")).toBe("Filter: All"); expect(routeNavigateMock).not.toHaveBeenCalled(); fireEvent.keyDown(archived, { key: "Escape" }); await waitFor(() => expect(screen.queryByRole("menu")).toBeNull()); From 46a3b3cbba6489e7970dbc59b335e7dd092b225a Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 22:03:32 -0700 Subject: [PATCH 42/57] Adapt sidebar filter fixture to current section visibility --- .../src/components/sidebar/SidebarThreadLifecycles.test.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx b/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx index e09fe937c1f..93d458ee6f0 100644 --- a/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx +++ b/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx @@ -133,6 +133,10 @@ function LifecycleContents({ empty }: { empty: boolean }) { "threads", buildSidebarEntitySectionId("section", "archive-section"), ]} + fullSectionOrder={[ + "threads", + buildSidebarEntitySectionId("section", "archive-section"), + ]} onTopLevelSectionOrderChange={vi.fn()} pinnedReorderPending={false} pinnedThreads={[]} From e47acfc97981858886c46386b95b5e253b7fe121 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 22:12:25 -0700 Subject: [PATCH 43/57] Defer sidebar menu contents until first open --- .../sidebar/SidebarHeaderControls.test.tsx | 5 +- .../sidebar/SidebarHeaderControls.tsx | 118 +++--------------- .../components/sidebar/SidebarViewItems.tsx | 111 +++++++++++++++- 3 files changed, 129 insertions(+), 105 deletions(-) diff --git a/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx b/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx index 64e243febaf..ba833dbd9e3 100644 --- a/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx +++ b/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx @@ -87,9 +87,12 @@ async function openSubmenu(label: string) { } describe("sidebar header controls", () => { - it("supports keyboard selection when a submenu first loads", async () => { + it("supports keyboard selection when the menu first loads", async () => { const { store } = setup("Pinned", false, "chronological"); await openMenu(); + const newProject = screen.getByRole("menuitem", { name: "New project" }); + fireEvent.keyDown(newProject.closest('[role="menu"]')!, { key: "Home" }); + await waitFor(() => expect(document.activeElement).toBe(newProject)); await openSubmenu("Organize"); const project = await screen.findByRole("menuitemradio", { name: "By project", diff --git a/apps/app/src/components/sidebar/SidebarHeaderControls.tsx b/apps/app/src/components/sidebar/SidebarHeaderControls.tsx index 6b0b14b899d..e276d174f64 100644 --- a/apps/app/src/components/sidebar/SidebarHeaderControls.tsx +++ b/apps/app/src/components/sidebar/SidebarHeaderControls.tsx @@ -16,16 +16,12 @@ import { DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, - DropdownMenuSub, - DropdownMenuSubTrigger, - DropdownMenuSubContent, - DropdownMenuPortal, } from "@bb/shared-ui/dropdown-menu"; import { SidebarControlButton, SidebarRowControls } from "./SidebarRowControls"; import { SIDEBAR_CONTROL_BUTTON_CLASS } from "./sidebarRowClasses"; import { ThreadListVisibilityMenuItems } from "./ThreadListVisibility"; -interface HeaderCreationActions { +export interface HeaderCreationActions { onNewProject?: () => void; onNewSection?: () => void; isCreatingProject?: boolean; @@ -35,9 +31,9 @@ interface HeaderCreationActions { const HeaderCreationContext = createContext({}); export const SidebarHeaderActionsProvider = HeaderCreationContext.Provider; -const LazySidebarViewItems = lazy(() => - import("./SidebarViewItems").then(({ SidebarViewItems }) => ({ - default: SidebarViewItems, +const LazySidebarHeaderMenuContents = lazy(() => + import("./SidebarViewItems").then(({ SidebarHeaderMenuContents }) => ({ + default: SidebarHeaderMenuContents, })), ); @@ -103,100 +99,18 @@ export function SidebarHeaderControls({ : `${label} actions` } > - {compact && page ? ( - <> - { - event.preventDefault(); - setPage(null); - }} - > - - Back - - - Loading…} - > - - - - ) : ( - <> - - - New project - - - - New section - - - {( - [ - { page: "organize", label: "Organize", icon: "Layers" }, - { page: "sort", label: "Sort by", icon: "ArrowUpDown" }, - { - page: "filter", - label: "Filter", - icon: "SlidersHorizontal", - }, - ] as const - ).map((item) => - compact ? ( - { - event.preventDefault(); - setPage(item.page); - }} - > - - {item.label} - - - ) : ( - - - - {item.label} - - - - Loading… - } - > - - - - - - ), - )} - {children ? ( - <> - - {children} - - ) : ( - - )} - - )} + Loading…} + > + + {children} + +
diff --git a/apps/app/src/components/sidebar/SidebarViewItems.tsx b/apps/app/src/components/sidebar/SidebarViewItems.tsx index dd52301d5a0..7429e4a7253 100644 --- a/apps/app/src/components/sidebar/SidebarViewItems.tsx +++ b/apps/app/src/components/sidebar/SidebarViewItems.tsx @@ -5,7 +5,13 @@ import { DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, + DropdownMenuPortal, } from "@bb/shared-ui/dropdown-menu"; +import type { HeaderCreationActions } from "./SidebarHeaderControls"; +import { ThreadListVisibilityMenuItems } from "./ThreadListVisibility"; import { ThreadLifecycleFilterItems } from "@/components/thread/ThreadLifecycleFilter"; import { sidebarOrganizationModeAtom, @@ -28,10 +34,110 @@ const SIDEBAR_SORT_OPTIONS = [ { label: "Alphabetical", sort: "alpha", direction: "ascending" }, ] as const; -export function SidebarViewItems({ +type SidebarViewPage = "organize" | "sort" | "filter"; + +export function SidebarHeaderMenuContents({ + creation, + compact, + page, + onPageChange, + children, +}: { + creation: HeaderCreationActions; + compact: boolean; + page: SidebarViewPage | null; + onPageChange: (page: SidebarViewPage | null) => void; + children?: ReactNode; +}) { + if (compact && page) { + return ( + <> + { + event.preventDefault(); + onPageChange(null); + }} + > + + Back + + + + + ); + } + return ( + <> + + + New project + + + + New section + + + {( + [ + { page: "organize", label: "Organize", icon: "Layers" }, + { page: "sort", label: "Sort by", icon: "ArrowUpDown" }, + { page: "filter", label: "Filter", icon: "SlidersHorizontal" }, + ] as const + ).map((item) => + compact ? ( + { + event.preventDefault(); + onPageChange(item.page); + }} + > + + {item.label} + + + ) : ( + + + + {item.label} + + + + + + + + ), + )} + {children ? ( + <> + + {children} + + ) : ( + + )} + + ); +} + +function SidebarViewItems({ page, }: { - page: "organize" | "sort" | "filter"; + page: SidebarViewPage; }) { const [lifecycles, setLifecycles] = useAtom(sidebarThreadLifecyclesAtom); const [organization, setOrganization] = useAtom(sidebarOrganizationModeAtom); @@ -141,3 +247,4 @@ export function SidebarViewItems({ ); } +import type { ReactNode } from "react"; From 1582c7d02efbd193a75b803870d1128a180ac25a Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 22:12:41 -0700 Subject: [PATCH 44/57] Keep menu type imports with module imports --- apps/app/src/components/sidebar/SidebarViewItems.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/app/src/components/sidebar/SidebarViewItems.tsx b/apps/app/src/components/sidebar/SidebarViewItems.tsx index 7429e4a7253..26626caa809 100644 --- a/apps/app/src/components/sidebar/SidebarViewItems.tsx +++ b/apps/app/src/components/sidebar/SidebarViewItems.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from "react"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { Icon } from "@bb/shared-ui/icon"; import { @@ -247,4 +248,3 @@ function SidebarViewItems({ ); } -import type { ReactNode } from "react"; From 8bfbf64f85e6b7b2fcbd36237c1798a671e9288a Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 22:18:30 -0700 Subject: [PATCH 45/57] Load sidebar customization only when requested --- .../plugin/PluginNavSidebarItems.test.tsx | 2 +- .../SidebarVisibilityControls.test.tsx | 4 +- .../sidebar/SidebarVisibilityControls.tsx | 284 +----------------- .../sidebar/SidebarVisibilityCustomize.tsx | 281 +++++++++++++++++ 4 files changed, 298 insertions(+), 273 deletions(-) create mode 100644 apps/app/src/components/sidebar/SidebarVisibilityCustomize.tsx diff --git a/apps/app/src/components/plugin/PluginNavSidebarItems.test.tsx b/apps/app/src/components/plugin/PluginNavSidebarItems.test.tsx index 34d4eb90eee..9a54cb38f48 100644 --- a/apps/app/src/components/plugin/PluginNavSidebarItems.test.tsx +++ b/apps/app/src/components/plugin/PluginNavSidebarItems.test.tsx @@ -885,7 +885,7 @@ describe("PluginNavSidebarItems", () => { expect(onCompactCustomizeModeChange).toHaveBeenCalledWith(true); expect( - screen.getByTestId("sidebar-navigation-customize-inline"), + await screen.findByTestId("sidebar-navigation-customize-inline"), ).not.toBeNull(); expect( screen diff --git a/apps/app/src/components/sidebar/SidebarVisibilityControls.test.tsx b/apps/app/src/components/sidebar/SidebarVisibilityControls.test.tsx index c0988a0b53d..94626316bd8 100644 --- a/apps/app/src/components/sidebar/SidebarVisibilityControls.test.tsx +++ b/apps/app/src/components/sidebar/SidebarVisibilityControls.test.tsx @@ -7,7 +7,7 @@ import { SidebarVisibilityCustomize } from "./SidebarVisibilityControls"; afterEach(cleanup); describe("shared sidebar visibility controls", () => { - it("lets group customization toggle visibility without navigating away", () => { + it("loads group customization and toggles visibility without navigating away", async () => { const onVisibleChange = vi.fn(); const onDone = vi.fn(); render( @@ -26,7 +26,7 @@ describe("shared sidebar visibility controls", () => { , ); - fireEvent.click(screen.getByRole("button", { name: "Review" })); + fireEvent.click(await screen.findByRole("button", { name: "Review" })); expect(onVisibleChange).toHaveBeenCalledWith("section:review", true); expect(onDone).not.toHaveBeenCalled(); fireEvent.keyDown(screen.getByRole("button", { name: "Review" }), { diff --git a/apps/app/src/components/sidebar/SidebarVisibilityControls.tsx b/apps/app/src/components/sidebar/SidebarVisibilityControls.tsx index 9cb27802dc7..364ab6a5090 100644 --- a/apps/app/src/components/sidebar/SidebarVisibilityControls.tsx +++ b/apps/app/src/components/sidebar/SidebarVisibilityControls.tsx @@ -1,20 +1,13 @@ import { useCallback, - useEffect, - useId, - useMemo, - useRef, + lazy, + Suspense, useState, type PointerEventHandler, type ReactNode, + type ComponentProps, } from "react"; -import { DndContext, type DragEndEvent } from "@dnd-kit/core"; -import { - SortableContext, - verticalListSortingStrategy, -} from "@dnd-kit/sortable"; import { Button } from "@bb/shared-ui/button"; -import { Checkbox } from "@bb/shared-ui/checkbox"; import { Icon } from "@bb/shared-ui/icon"; import { Popover, PopoverContent, PopoverTrigger } from "@bb/shared-ui/popover"; import { @@ -34,7 +27,6 @@ import { COARSE_POINTER_ICON_SIZE_CLASS, COARSE_POINTER_ROW_ACTION_SIZE_CLASS, } from "@bb/shared-ui/coarse-pointer-sizing"; -import { CHROME_SECTION_LABEL_CLASS } from "@bb/shared-ui/chrome-style-tokens"; import { cn } from "@bb/shared-ui/lib/utils"; import { SIDEBAR_HOVER_ACTIONS_CLASS, @@ -46,8 +38,6 @@ import { SIDEBAR_CONTROL_BUTTON_CLASS, } from "./sidebarRowClasses"; import { TopLevelSidebarSection } from "./TopLevelSidebarSection"; -import { useSidebarSortable } from "./sortableMotion"; -import { useSidebarReorderDnd } from "./useSidebarReorderDnd"; const OVERFLOW_ROW_BUTTON_CLASS = "w-full justify-start gap-2 rounded-sm px-2 text-xs font-normal hover:bg-state-hover focus-visible:bg-state-hover"; @@ -330,264 +320,18 @@ export function SidebarOverflowItem({ ); } -export function SidebarVisibilityCustomize({ - items, - listLabel, - onActivate, - onDone, - onExit, - onReorder, - onVisibleChange, - testIdPrefix = "sidebar-navigation", - title, - variant, - visibleIds, -}: { - items: readonly SidebarVisibilityItem[]; - listLabel: string; - onActivate?: ( - item: SidebarVisibilityItem, - event: SidebarActivationModifiers, - ) => void; - onDone: () => void; - onExit?: () => void; - onReorder: (activeId: string, overId: string) => void; - onVisibleChange: (id: string, visible: boolean) => void; - testIdPrefix?: string; - title: string; - variant: "compact" | "card"; - visibleIds: readonly string[]; -}) { - const containerRef = useRef(null); - const doneButtonRef = useRef(null); - const orderedIds = useMemo(() => items.map((item) => item.id), [items]); - const visibleIdSet = useMemo(() => new Set(visibleIds), [visibleIds]); - const handleDragEnd = useCallback( - (event: DragEndEvent) => { - if ( - typeof event.active.id !== "string" || - typeof event.over?.id !== "string" - ) - return; - onReorder(event.active.id, event.over.id); - }, - [onReorder], - ); - const { dndContextProps, onClickCapture } = useSidebarReorderDnd({ - onDragEnd: handleDragEnd, - }); - - useEffect(() => { - if (variant === "compact") { - doneButtonRef.current?.focus(); - return; - } - containerRef.current - ?.querySelector("[data-sidebar-customize-launch]") - ?.focus(); - }, [variant]); - - const list = ( -
- - - {items.map((item) => ( - { - onActivate(item, event); - onExit?.(); - } - : undefined - } - onCheckedChange={(checked) => onVisibleChange(item.id, checked)} - testIdPrefix={testIdPrefix} - /> - ))} - - -
- ); - - if (variant === "compact") { - return ( -
-
- -
- {title} -
-
-
{list}
-
- ); - } - - return ( -
{ - if (event.key !== "Escape") return; - event.preventDefault(); - onDone(); - }} - > -
-
- {title} -
- -
- {list} -
- ); -} - -function SidebarCustomizeItem({ - checked, - item, - onActivate, - onCheckedChange, - reorderDisabled, - testIdPrefix, -}: { - checked: boolean; - item: SidebarVisibilityItem; - onActivate?: ((event: SidebarActivationModifiers) => void) | undefined; - onCheckedChange: (checked: boolean) => void; - reorderDisabled: boolean; - testIdPrefix: string; -}) { - const checkboxId = useId(); - const { dragBindings, setNodeRef, style } = useSidebarSortable({ - id: item.id, - disabled: reorderDisabled, - }); - const isNavigation = testIdPrefix === "sidebar-navigation"; +const LazySidebarVisibilityCustomize = lazy(() => + import("./SidebarVisibilityCustomize").then(({ SidebarVisibilityCustomize }) => ({ + default: SidebarVisibilityCustomize, + })), +); +export function SidebarVisibilityCustomize( + props: ComponentProps, +) { return ( -
- - - -
+ Loading…}> + + ); } diff --git a/apps/app/src/components/sidebar/SidebarVisibilityCustomize.tsx b/apps/app/src/components/sidebar/SidebarVisibilityCustomize.tsx new file mode 100644 index 00000000000..fc914faf879 --- /dev/null +++ b/apps/app/src/components/sidebar/SidebarVisibilityCustomize.tsx @@ -0,0 +1,281 @@ +import { useCallback, useEffect, useId, useMemo, useRef } from "react"; +import { DndContext, type DragEndEvent } from "@dnd-kit/core"; +import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import { Button } from "@bb/shared-ui/button"; +import { Checkbox } from "@bb/shared-ui/checkbox"; +import { Icon } from "@bb/shared-ui/icon"; +import { + COARSE_POINTER_COMPACT_ROW_HEIGHT_CLASS, + COARSE_POINTER_ICON_SIZE_CLASS, + COARSE_POINTER_ROW_ACTION_SIZE_CLASS, +} from "@bb/shared-ui/coarse-pointer-sizing"; +import { CHROME_SECTION_LABEL_CLASS } from "@bb/shared-ui/chrome-style-tokens"; +import { cn } from "@bb/shared-ui/lib/utils"; +import type { + SidebarVisibilityItem, + SidebarActivationModifiers, +} from "./SidebarVisibilityControls"; +import { useSidebarSortable } from "./sortableMotion"; +import { useSidebarReorderDnd } from "./useSidebarReorderDnd"; + +export function SidebarVisibilityCustomize({ + items, + listLabel, + onActivate, + onDone, + onExit, + onReorder, + onVisibleChange, + testIdPrefix = "sidebar-navigation", + title, + variant, + visibleIds, +}: { + items: readonly SidebarVisibilityItem[]; + listLabel: string; + onActivate?: ( + item: SidebarVisibilityItem, + event: SidebarActivationModifiers, + ) => void; + onDone: () => void; + onExit?: () => void; + onReorder: (activeId: string, overId: string) => void; + onVisibleChange: (id: string, visible: boolean) => void; + testIdPrefix?: string; + title: string; + variant: "compact" | "card"; + visibleIds: readonly string[]; +}) { + const containerRef = useRef(null); + const doneButtonRef = useRef(null); + const orderedIds = useMemo(() => items.map((item) => item.id), [items]); + const visibleIdSet = useMemo(() => new Set(visibleIds), [visibleIds]); + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + if ( + typeof event.active.id !== "string" || + typeof event.over?.id !== "string" + ) + return; + onReorder(event.active.id, event.over.id); + }, + [onReorder], + ); + const { dndContextProps, onClickCapture } = useSidebarReorderDnd({ + onDragEnd: handleDragEnd, + }); + + useEffect(() => { + if (variant === "compact") { + doneButtonRef.current?.focus(); + return; + } + containerRef.current + ?.querySelector("[data-sidebar-customize-launch]") + ?.focus(); + }, [variant]); + + const list = ( +
+ + + {items.map((item) => ( + { + onActivate(item, event); + onExit?.(); + } + : undefined + } + onCheckedChange={(checked) => onVisibleChange(item.id, checked)} + testIdPrefix={testIdPrefix} + /> + ))} + + +
+ ); + + if (variant === "compact") { + return ( +
+
+ +
+ {title} +
+
+
{list}
+
+ ); + } + + return ( +
{ + if (event.key !== "Escape") return; + event.preventDefault(); + onDone(); + }} + > +
+
+ {title} +
+ +
+ {list} +
+ ); +} + +function SidebarCustomizeItem({ + checked, + item, + onActivate, + onCheckedChange, + reorderDisabled, + testIdPrefix, +}: { + checked: boolean; + item: SidebarVisibilityItem; + onActivate?: ((event: SidebarActivationModifiers) => void) | undefined; + onCheckedChange: (checked: boolean) => void; + reorderDisabled: boolean; + testIdPrefix: string; +}) { + const checkboxId = useId(); + const { dragBindings, setNodeRef, style } = useSidebarSortable({ + id: item.id, + disabled: reorderDisabled, + }); + const isNavigation = testIdPrefix === "sidebar-navigation"; + + return ( +
+ + + +
+ ); +} From fca05702aaa516af5a27b022a17fa0800bf4e1a6 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 22:54:05 -0700 Subject: [PATCH 46/57] Hide archived sidebar restore icon on mobile --- apps/app/src/components/sidebar/ThreadRow.test.tsx | 3 ++- apps/app/src/components/sidebar/ThreadRow.tsx | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/app/src/components/sidebar/ThreadRow.test.tsx b/apps/app/src/components/sidebar/ThreadRow.test.tsx index 5030efa98c2..bdce01f169f 100644 --- a/apps/app/src/components/sidebar/ThreadRow.test.tsx +++ b/apps/app/src/components/sidebar/ThreadRow.test.tsx @@ -233,7 +233,7 @@ afterEach(() => { }); describe("ThreadRow", () => { - it("keeps one restore action visible and blocks row pointer, keyboard, and click propagation", () => { + it("keeps desktop restore available, hides it on mobile, and blocks row event propagation", () => { const thread = createThread({ archivedAt: 1 }); const rowEvent = vi.fn(); const client = new QueryClient(); @@ -249,6 +249,7 @@ describe("ThreadRow", () => { expect(restore.classList.contains("bg-state-hover")).toBe(false); expect(restore.classList.contains("bg-state-active")).toBe(false); expect(restore.closest("[data-sidebar-hover-actions-open]")).toBeNull(); + expect(restore.closest(".max-md\\:pointer-coarse\\:hidden")).not.toBeNull(); expect(screen.queryByRole("button", { name: "Archive thread" })).toBeNull(); fireEvent.pointerDown(restore, { pointerType: "touch", button: 0 }); fireEvent.keyDown(restore, { key: "Enter" }); diff --git a/apps/app/src/components/sidebar/ThreadRow.tsx b/apps/app/src/components/sidebar/ThreadRow.tsx index cbca4fdaf53..5304ad5c3cc 100644 --- a/apps/app/src/components/sidebar/ThreadRow.tsx +++ b/apps/app/src/components/sidebar/ThreadRow.tsx @@ -565,7 +565,7 @@ function ThreadRowComponent({ {thread.archivedAt !== null ? ( - +
Date: Sun, 20 Sep 2026 23:23:27 -0700 Subject: [PATCH 47/57] Use the unarchive icon for archived sidebar row actions --- apps/app/src/components/sidebar/ThreadRow.test.tsx | 2 +- apps/app/src/components/sidebar/ThreadRow.tsx | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/app/src/components/sidebar/ThreadRow.test.tsx b/apps/app/src/components/sidebar/ThreadRow.test.tsx index bdce01f169f..516fabf5f2e 100644 --- a/apps/app/src/components/sidebar/ThreadRow.test.tsx +++ b/apps/app/src/components/sidebar/ThreadRow.test.tsx @@ -245,7 +245,7 @@ describe("ThreadRow", () => { , ); const restore = screen.getByRole("button", { name: "Unarchive thread" }); - expect(restore.querySelector('[data-icon="Archive"]')).toBeTruthy(); + expect(restore.querySelector('[data-icon="ArchiveRestore"]')).toBeTruthy(); expect(restore.classList.contains("bg-state-hover")).toBe(false); expect(restore.classList.contains("bg-state-active")).toBe(false); expect(restore.closest("[data-sidebar-hover-actions-open]")).toBeNull(); diff --git a/apps/app/src/components/sidebar/ThreadRow.tsx b/apps/app/src/components/sidebar/ThreadRow.tsx index 5304ad5c3cc..871b2056df0 100644 --- a/apps/app/src/components/sidebar/ThreadRow.tsx +++ b/apps/app/src/components/sidebar/ThreadRow.tsx @@ -286,7 +286,6 @@ function ThreadRestoreStatusAction({ thread }: { thread: ThreadListEntry }) { > 0} className={SIDEBAR_CONTROL_BUTTON_CLASS} /> From 73692f4884d6fb8741b765c514c286b9b2bfb089 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 23:46:44 -0700 Subject: [PATCH 48/57] Use background focus treatment for sidebar action buttons --- apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx | 3 +++ apps/app/src/components/sidebar/sidebarRowClasses.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx b/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx index ba833dbd9e3..6a340beeaff 100644 --- a/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx +++ b/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx @@ -145,6 +145,9 @@ describe("sidebar header controls", () => { false, ); expect(control?.classList.contains("hover:text-foreground")).toBe(false); + expect(control?.classList.contains("focus-visible:ring-0")).toBe(true); + expect(control?.classList.contains("focus-visible:ring-1")).toBe(false); + expect(control?.classList.contains("focus-visible:ring-2")).toBe(false); } expect(primary.classList.contains("max-md:pointer-coarse:w-8")).toBe(true); expect( diff --git a/apps/app/src/components/sidebar/sidebarRowClasses.ts b/apps/app/src/components/sidebar/sidebarRowClasses.ts index 68bf4dd0b06..1f609875d91 100644 --- a/apps/app/src/components/sidebar/sidebarRowClasses.ts +++ b/apps/app/src/components/sidebar/sidebarRowClasses.ts @@ -43,7 +43,7 @@ export const SIDEBAR_CONTROL_TONE_CLASS = export const SIDEBAR_CONTROL_STATE_CLASS = `${SIDEBAR_CONTROL_TONE_CLASS} hover:bg-state-hover focus-visible:bg-state-hover active:bg-state-active data-[state=open]:bg-state-active data-[state=open]:hover:bg-state-active data-[state=open]:focus-visible:bg-state-active`; -const SIDEBAR_CONTROL_BUTTON_BASE_CLASS = `${SIDEBAR_CONTROL_STATE_CLASS} relative m-0 shrink-0 cursor-pointer rounded-md p-0 outline-none ring-sidebar-ring focus-visible:ring-2`; +const SIDEBAR_CONTROL_BUTTON_BASE_CLASS = `${SIDEBAR_CONTROL_STATE_CLASS} relative m-0 shrink-0 cursor-pointer rounded-md p-0 outline-none focus-visible:ring-0`; export const SIDEBAR_CONTROL_BUTTON_CLASS = `${COARSE_POINTER_ROW_ACTION_SIZE_CLASS} ${SIDEBAR_CONTROL_BUTTON_BASE_CLASS}`; From 706650ed60db0de8710a512fbd13cd3d31095907 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Mon, 21 Sep 2026 08:41:34 -0700 Subject: [PATCH 49/57] Restore conventional sidebar keyboard focus --- .../sidebar/SidebarHeaderControls.test.tsx | 11 +++++++++-- .../components/sidebar/sidebarRowClasses.ts | 6 +++--- apps/app/src/components/ui/theme.css | 2 +- apps/app/src/components/ui/theme.test.ts | 19 +++++++++++++++++++ 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx b/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx index 6a340beeaff..b0c6879d25f 100644 --- a/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx +++ b/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx @@ -145,9 +145,16 @@ describe("sidebar header controls", () => { false, ); expect(control?.classList.contains("hover:text-foreground")).toBe(false); - expect(control?.classList.contains("focus-visible:ring-0")).toBe(true); - expect(control?.classList.contains("focus-visible:ring-1")).toBe(false); + expect(control?.classList.contains("focus-visible:ring-0")).toBe(false); + expect(control?.classList.contains("focus-visible:ring-1")).toBe(true); + expect(control?.classList.contains("focus-visible:ring-ring")).toBe(true); expect(control?.classList.contains("focus-visible:ring-2")).toBe(false); + expect(control?.classList.contains("focus-visible:bg-primary")).toBe( + false, + ); + expect(control?.classList.contains("focus-visible:bg-state-hover")).toBe( + false, + ); } expect(primary.classList.contains("max-md:pointer-coarse:w-8")).toBe(true); expect( diff --git a/apps/app/src/components/sidebar/sidebarRowClasses.ts b/apps/app/src/components/sidebar/sidebarRowClasses.ts index 1f609875d91..bb14af9eb6f 100644 --- a/apps/app/src/components/sidebar/sidebarRowClasses.ts +++ b/apps/app/src/components/sidebar/sidebarRowClasses.ts @@ -39,11 +39,11 @@ export const SIDEBAR_ROW_TEXT_CLASS = "text-sidebar-foreground"; export const SIDEBAR_GROUP_TEXT_CLASS = "text-muted-foreground"; export const SIDEBAR_CONTROL_TONE_CLASS = - "text-subtle-foreground hover:text-muted-foreground focus-visible:text-muted-foreground data-[state=open]:text-muted-foreground"; + "text-subtle-foreground hover:text-muted-foreground data-[state=open]:text-muted-foreground"; -export const SIDEBAR_CONTROL_STATE_CLASS = `${SIDEBAR_CONTROL_TONE_CLASS} hover:bg-state-hover focus-visible:bg-state-hover active:bg-state-active data-[state=open]:bg-state-active data-[state=open]:hover:bg-state-active data-[state=open]:focus-visible:bg-state-active`; +export const SIDEBAR_CONTROL_STATE_CLASS = `${SIDEBAR_CONTROL_TONE_CLASS} hover:bg-state-hover active:bg-state-active data-[state=open]:bg-state-active data-[state=open]:hover:bg-state-active`; -const SIDEBAR_CONTROL_BUTTON_BASE_CLASS = `${SIDEBAR_CONTROL_STATE_CLASS} relative m-0 shrink-0 cursor-pointer rounded-md p-0 outline-none focus-visible:ring-0`; +const SIDEBAR_CONTROL_BUTTON_BASE_CLASS = `${SIDEBAR_CONTROL_STATE_CLASS} relative m-0 shrink-0 cursor-pointer rounded-md p-0 outline-none`; export const SIDEBAR_CONTROL_BUTTON_CLASS = `${COARSE_POINTER_ROW_ACTION_SIZE_CLASS} ${SIDEBAR_CONTROL_BUTTON_BASE_CLASS}`; diff --git a/apps/app/src/components/ui/theme.css b/apps/app/src/components/ui/theme.css index 2426b2a9436..7d7f2879321 100644 --- a/apps/app/src/components/ui/theme.css +++ b/apps/app/src/components/ui/theme.css @@ -189,7 +189,7 @@ content: ""; position: sticky; top: 0; - z-index: 70; + z-index: 55; display: block; height: var(--bb-sidebar-sticky-stack-padding-top); margin-top: calc(-1 * var(--bb-sidebar-sticky-stack-padding-top)); diff --git a/apps/app/src/components/ui/theme.test.ts b/apps/app/src/components/ui/theme.test.ts index ef4a245c7b0..74a62127fbf 100644 --- a/apps/app/src/components/ui/theme.test.ts +++ b/apps/app/src/components/ui/theme.test.ts @@ -142,6 +142,25 @@ describe("theme.css neutral ramp", () => { ); }); + it("keeps the scrollport cap below label controls but above project rows", () => { + const cap = Number( + css.match( + /\[data-sidebar-sticky-stack\]::before\s*\{[^}]*z-index:\s*(\d+)/, + )?.[1], + ); + const tier = (name: string) => + Number( + css.match( + new RegExp( + `\\[data-sidebar-sticky-tier="${name}"\\]\\s*\\{[^}]*--bb-sidebar-sticky-tier-z-index:\\s*(\\d+)`, + ), + )?.[1], + ); + + expect(cap).toBeLessThan(tier("label")); + expect(cap).toBeGreaterThan(tier("project")); + }); + it("collapses the label slot when a section header is not sticky", () => { const compact = css.replace(/\s+/g, " "); const declarations = (selector: string): string | undefined => From 4ad63bbcd5063bb9288302c600f2d55fcca8080e Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Mon, 21 Sep 2026 11:20:51 -0700 Subject: [PATCH 50/57] Test restored sidebar rows remain visible during unarchive --- .../thread-lifecycle-cache.test.ts | 58 ++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/apps/app/src/hooks/cache-owners/thread-lifecycle-cache.test.ts b/apps/app/src/hooks/cache-owners/thread-lifecycle-cache.test.ts index 52c32e5866a..b4bd04151f0 100644 --- a/apps/app/src/hooks/cache-owners/thread-lifecycle-cache.test.ts +++ b/apps/app/src/hooks/cache-owners/thread-lifecycle-cache.test.ts @@ -1,13 +1,69 @@ import { QueryClient } from "@tanstack/react-query"; import { describe, expect, it } from "vitest"; import { makeThreadListEntry } from "@bb/test-helpers/domain-fixtures"; -import { archivedThreadsListQueryKey } from "../queries/query-keys"; +import { + makeProjectWithThreadsResponse, + makeSidebarBootstrapResponse, +} from "@/test/fixtures/projects"; +import { archivedThreadsListQueryKey, sidebarNavigationQueryKey } from "../queries/query-keys"; import { beginUnarchiveThreadTransaction, rollbackThreadListMutationTransaction, } from "./thread-state-cache-owner"; describe("sidebar archive cache", () => { + it.each(["project-1", "proj_personal"])( + "keeps a restored row in its sidebar hierarchy before the server responds (%s)", + async (projectId) => { + const queryClient = new QueryClient(); + const archivedKey = archivedThreadsListQueryKey({}); + const archived = makeThreadListEntry({ + id: "archived", + projectId, + archivedAt: 100, + parentThreadId: "parent", + sectionId: "section-1", + pinnedAt: 10, + pinSortKey: "a0", + environmentId: "environment-1", + environmentHostId: "host-1", + latestAttentionAt: 20, + createdAt: 5, + }); + const neighbor = makeThreadListEntry({ id: "parent", projectId }); + const navigation = makeSidebarBootstrapResponse({ + projects: [makeProjectWithThreadsResponse({ + id: "project-1", + threads: projectId === "project-1" ? [neighbor] : [], + })], + personalProject: makeProjectWithThreadsResponse({ + id: "proj_personal", + kind: "personal", + threads: projectId === "proj_personal" ? [neighbor] : [], + }), + }); + const pages = { pages: [[archived]], pageParams: [0] }; + queryClient.setQueryData(archivedKey, pages); + queryClient.setQueryData(sidebarNavigationQueryKey(), navigation); + + const transaction = await beginUnarchiveThreadTransaction({ + queryClient, + threadId: archived.id, + }); + + const next = queryClient.getQueryData(sidebarNavigationQueryKey())!; + const destination = projectId === "proj_personal" ? next.personalProject : next.projects[0]; + const other = projectId === "proj_personal" ? next.projects[0] : next.personalProject; + expect(destination?.threads).toEqual([neighbor, { ...archived, archivedAt: null }]); + expect(other?.threads).toEqual([]); + expect(queryClient.getQueryData(archivedKey)).toMatchObject({ pages: [[]] }); + + rollbackThreadListMutationTransaction({ queryClient, threadId: archived.id, transaction }); + expect(queryClient.getQueryData(sidebarNavigationQueryKey())).toEqual(navigation); + expect(queryClient.getQueryData(archivedKey)).toEqual(pages); + }, + ); + it("restores archived hierarchy metadata after an unsuccessful optimistic restore", async () => { const queryClient = new QueryClient(); const archivedKey = archivedThreadsListQueryKey({}); From 3fc3fdbc4ff767e0a0da1ac001a2d414980dc0a0 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Mon, 21 Sep 2026 11:23:57 -0700 Subject: [PATCH 51/57] Keep restored sidebar threads visible throughout unarchive --- apps/app/src/hooks/cache-owners/query-cache.ts | 8 +++++--- .../cache-owners/thread-state-cache-owner.ts | 17 ++++++++++++++++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/apps/app/src/hooks/cache-owners/query-cache.ts b/apps/app/src/hooks/cache-owners/query-cache.ts index 7e3a40b8460..c5cd825a7ba 100644 --- a/apps/app/src/hooks/cache-owners/query-cache.ts +++ b/apps/app/src/hooks/cache-owners/query-cache.ts @@ -79,8 +79,10 @@ type SidebarNavigationProject = SidebarBootstrapResponse["projects"][number]; export type CachedThreadListsAndSidebarNavigationMapper = ( threads: ThreadListEntry[], ) => ThreadListEntry[]; -type SidebarNavigationThreadMapper = - CachedThreadListsAndSidebarNavigationMapper; +type SidebarNavigationThreadMapper = ( + threads: ThreadListEntry[], + projectId: string, +) => ThreadListEntry[]; interface ApplyToCachedSidebarNavigationThreadsArgs { mapper: SidebarNavigationThreadMapper; @@ -302,7 +304,7 @@ function mapSidebarNavigationProjectThreads( ): SidebarNavigationProject { return { ...project, - threads: mapper(project.threads), + threads: mapper(project.threads, project.id), }; } diff --git a/apps/app/src/hooks/cache-owners/thread-state-cache-owner.ts b/apps/app/src/hooks/cache-owners/thread-state-cache-owner.ts index fcbe95b79ca..7565c9209dd 100644 --- a/apps/app/src/hooks/cache-owners/thread-state-cache-owner.ts +++ b/apps/app/src/hooks/cache-owners/thread-state-cache-owner.ts @@ -600,7 +600,22 @@ export function beginUnarchiveThreadTransaction({ threadId, }: ThreadIdCacheArgs): Promise { return runOptimisticThreadFieldTransaction({ - applyToLists: removeThreadFromLists, + applyToLists: (queryClient, threadId) => { + const thread = getCachedThreadLists(queryClient, { + queryKey: threadsQueryKey(), + }) + .flatMap(({ data }) => [...iterateThreadListCacheEntries(data)]) + .find((candidate) => candidate.id === threadId); + removeThreadFromLists(queryClient, threadId); + if (!thread) return; + applyToCachedSidebarNavigationThreads({ + queryClient, + mapper: (list, projectId) => + projectId === thread.projectId + ? [...list, { ...thread, archivedAt: null }] + : list, + }); + }, patch: { archivedAt: null }, queryClient, threadId, From c6edbdee39172bcd7453416f1708fa9e4a6ea476 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:39:27 -0700 Subject: [PATCH 52/57] Reuse existing archive ordering in palette recents --- .../palette-archive-cache.test.ts | 2 +- .../hooks/queries/palette-thread-queries.ts | 1 - apps/app/src/hooks/queries/query-keys.ts | 1 - .../src/hooks/queries/thread-queries.test.tsx | 2 +- .../palette-thread-search.test.ts | 15 +++++++ .../command-palette/palette-thread-search.ts | 6 ++- .../command-output/thread-list.test.ts | 9 ----- apps/cli/src/commands/thread/list.ts | 5 +-- apps/server/src/routes/threads/base.ts | 1 - docs/configuration.md | 3 +- packages/db/src/data/threads.ts | 4 -- packages/db/test/data/threads.test.ts | 39 ------------------- packages/sdk/src/areas/threads.ts | 2 - packages/sdk/test/sdk.test.ts | 19 --------- packages/server-contract/src/api/threads.ts | 1 - .../server-contract/test/contract.test.ts | 1 - .../src/templates/bb-guide-threads.md | 1 - .../bb-cli/references/thread-operation.md | 4 -- 18 files changed, 25 insertions(+), 91 deletions(-) diff --git a/apps/app/src/hooks/cache-owners/palette-archive-cache.test.ts b/apps/app/src/hooks/cache-owners/palette-archive-cache.test.ts index e1347824c82..cc2b98a9218 100644 --- a/apps/app/src/hooks/cache-owners/palette-archive-cache.test.ts +++ b/apps/app/src/hooks/cache-owners/palette-archive-cache.test.ts @@ -11,7 +11,7 @@ import { describe("palette archive cache", () => { it("keeps bounded recents server-owned and included in list invalidation", () => { const queryClient = new QueryClient(); - const key = threadListQueryKey({ archived: true, sort: "updated", limit: 20 }); + const key = threadListQueryKey({ archived: true, limit: 20 }); const archived = Array.from({ length: 20 }, (_, index) => makeThreadListEntry({ id: `archived-${index}`, archivedAt: 1 }), ); diff --git a/apps/app/src/hooks/queries/palette-thread-queries.ts b/apps/app/src/hooks/queries/palette-thread-queries.ts index bf4dac48453..e8b146605e5 100644 --- a/apps/app/src/hooks/queries/palette-thread-queries.ts +++ b/apps/app/src/hooks/queries/palette-thread-queries.ts @@ -12,7 +12,6 @@ export function usePaletteRecentArchivedThreads({ enabled }: { enabled: boolean useThreadListRealtimeSubscription({ enabled }); const filters = { archived: true, - sort: "updated" as const, limit: THREAD_SEARCH_LIMIT_PER_GROUP, }; return useQuery({ diff --git a/apps/app/src/hooks/queries/query-keys.ts b/apps/app/src/hooks/queries/query-keys.ts index 8dd27b2ebe3..30c686a8c61 100644 --- a/apps/app/src/hooks/queries/query-keys.ts +++ b/apps/app/src/hooks/queries/query-keys.ts @@ -81,7 +81,6 @@ const PLUGIN_CATALOG_SEARCH_QUERY_KEY = "plugin-catalog-search"; const PLUGIN_CATALOG_INSTALL_PLAN_QUERY_KEY = "plugin-catalog-install-plan"; const PLUGIN_MARKETPLACES_QUERY_KEY = "plugin-marketplaces"; export interface ThreadListQueryFilters { - sort?: "updated"; projectId?: string; hasParent?: ThreadListFilters["hasParent"]; parentThreadId?: string; diff --git a/apps/app/src/hooks/queries/thread-queries.test.tsx b/apps/app/src/hooks/queries/thread-queries.test.tsx index 380a461f671..4aadae79481 100644 --- a/apps/app/src/hooks/queries/thread-queries.test.tsx +++ b/apps/app/src/hooks/queries/thread-queries.test.tsx @@ -871,7 +871,7 @@ describe("palette lifecycle queries", () => { rerender({ recent: true, selected: true }); await waitFor(() => expect(result.current.data).toEqual([archived])); expect(sdk.threads.list).toHaveBeenCalledExactlyOnceWith({ - archived: true, sort: "updated", limit: 20, signal: expect.any(AbortSignal), + archived: true, limit: 20, signal: expect.any(AbortSignal), }); }); diff --git a/apps/app/src/lib/command-palette/palette-thread-search.test.ts b/apps/app/src/lib/command-palette/palette-thread-search.test.ts index acc96047070..f5d1408e8fa 100644 --- a/apps/app/src/lib/command-palette/palette-thread-search.test.ts +++ b/apps/app/src/lib/command-palette/palette-thread-search.test.ts @@ -72,6 +72,21 @@ function build( } describe("buildPaletteThreadSearchRows", () => { + it("orders archived recents by archive time instead of last update", () => { + const result = build({ + query: "", + lifecycles: ["archived"], + recentThreads: [ + makeThread("updated-latest", { archivedAt: 1, updatedAt: NOW }), + makeThread("archived-latest", { archivedAt: 2, updatedAt: 1 }), + ], + }); + expect(result.rows.map((row) => row.threadId)).toEqual([ + "archived-latest", + "updated-latest", + ]); + }); + it("keeps saved-message threads in Active recents", () => { const saved = makeThread("saved", { status: "pending", updatedAt: NOW }); const archived = makeThread("archived", { archivedAt: 1, updatedAt: 2 }); diff --git a/apps/app/src/lib/command-palette/palette-thread-search.ts b/apps/app/src/lib/command-palette/palette-thread-search.ts index 0db4bd122ed..e0cbcbe4ea6 100644 --- a/apps/app/src/lib/command-palette/palette-thread-search.ts +++ b/apps/app/src/lib/command-palette/palette-thread-search.ts @@ -105,7 +105,11 @@ export function buildPaletteThreadSearchRows({ ? thread.archivedAt !== null : thread.archivedAt === null, ) - .sort((left, right) => right.updatedAt - left.updatedAt) + .sort((left, right) => + lifecycle === "archived" + ? (right.archivedAt ?? 0) - (left.archivedAt ?? 0) + : right.updatedAt - left.updatedAt, + ) .slice(0, RECENT_THREAD_LIMIT) .map((thread) => serverRow(thread, [], lifecycle, projectNamesById, now), diff --git a/apps/cli/src/__tests__/command-output/thread-list.test.ts b/apps/cli/src/__tests__/command-output/thread-list.test.ts index 5b6693b31c0..23557696b35 100644 --- a/apps/cli/src/__tests__/command-output/thread-list.test.ts +++ b/apps/cli/src/__tests__/command-output/thread-list.test.ts @@ -40,15 +40,6 @@ describe("bb thread list command output", () => { }); }); - it("bb thread list forwards explicit updated ordering", async () => { - const list = vi.fn(async () => []); - stubServerApi({ "v1.threads.$get": list }); - await runCommand(["thread", "list", "--archived", "--sort", "updated"], register); - expect(list).toHaveBeenCalledWith({ - query: { archived: "true", sort: "updated" }, - }); - }); - it("bb thread list opts into hidden threads explicitly", async () => { const list = vi.fn(async () => []); stubServerApi({ "v1.threads.$get": list }); diff --git a/apps/cli/src/commands/thread/list.ts b/apps/cli/src/commands/thread/list.ts index ec5643e336c..eb4111bd248 100644 --- a/apps/cli/src/commands/thread/list.ts +++ b/apps/cli/src/commands/thread/list.ts @@ -1,4 +1,4 @@ -import { Command, Option } from "commander"; +import { Command } from "commander"; import { PERSONAL_PROJECT_ID, type Thread } from "@bb/domain"; import { action } from "../../action.js"; import { createCliBbSdk } from "../../client.js"; @@ -11,7 +11,6 @@ import { import { outputJson } from "../helpers.js"; interface ThreadListCommandOptions { - sort?: "updated"; environment?: string; project?: string; parentThread?: string; @@ -35,7 +34,6 @@ export function registerListCommand( .option("--section ", "Filter by thread section ID") .option("--unsectioned", "Show only threads outside sections") .option("--archived", "Show only archived threads") - .addOption(new Option("--sort ", "Order by last update").choices(["updated"])) .option("--include-hidden", "Include hidden threads") .option("--json", "Print machine-readable JSON output") .action( @@ -61,7 +59,6 @@ export function registerListCommand( value: opts.section, }); const threads = await sdk.threads.list({ - ...(opts.sort ? { sort: opts.sort } : {}), ...(projectId ? { projectId } : {}), ...(environmentId ? { environmentId } : {}), ...(parentThreadId ? { parentThreadId } : {}), diff --git a/apps/server/src/routes/threads/base.ts b/apps/server/src/routes/threads/base.ts index f9a95b22d24..e26770630a7 100644 --- a/apps/server/src/routes/threads/base.ts +++ b/apps/server/src/routes/threads/base.ts @@ -272,7 +272,6 @@ export function registerThreadBaseRoutes(app: Hono, deps: AppDeps): void { requireThreadSection(deps, query.sectionId); } const threads = listThreadsWithPendingInteractionState(deps.db, { - ...(query.sort ? { sort: query.sort } : {}), ...(query.projectId ? { projectId: query.projectId } : {}), ...(query.environmentId ? { environmentId: query.environmentId } : {}), ...(query.parentThreadId ? { parentThreadId: query.parentThreadId } : {}), diff --git a/docs/configuration.md b/docs/configuration.md index 96b2168a287..b4503ad4c56 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -741,7 +741,8 @@ Plugin sidebar replacements own their rendering. `palette.threadLifecycles` independently selects Active and Archived before and after typing. Active includes threads with saved messages. Search threads matches saved message content, including follow-ups, and opens the owning thread. -Archived loads a bounded recent list only while selected. Search uses the existing +Archived loads a bounded list in most-recently-archived order only while selected. +Search uses the existing ranked Active/Archived response and displays the selected groups, with six initial rows in one group or three each when both are nonempty, plus Show more. `bb settings ui set palette.threadLifecycles '["active","archived"]'` includes diff --git a/packages/db/src/data/threads.ts b/packages/db/src/data/threads.ts index a99657f7c04..9375627e1bb 100644 --- a/packages/db/src/data/threads.ts +++ b/packages/db/src/data/threads.ts @@ -410,7 +410,6 @@ export function listThreadMentionRowsByIds( } export interface ListThreadsOptions { - sort?: "updated"; projectId?: string; environmentId?: string; archived?: boolean; @@ -745,9 +744,6 @@ function buildPinnedThreadOrderBy() { } function buildListThreadsOrderBy(options: ListThreadsOptions) { - if (options.sort === "updated") { - return [desc(threads.updatedAt), desc(threads.id)]; - } if (options.archived === true) { return [desc(threads.archivedAt), desc(threads.id)]; } diff --git a/packages/db/test/data/threads.test.ts b/packages/db/test/data/threads.test.ts index 8065477d98f..da3193bc21f 100644 --- a/packages/db/test/data/threads.test.ts +++ b/packages/db/test/data/threads.test.ts @@ -66,45 +66,6 @@ function mustCreateThreadSection( } describe("threads", () => { - it("orders recents before pagination while preserving default archive order", () => { - const { db, host, project } = setup(); - const { project: otherProject } = createProject(db, noopNotifier, { - name: "other-project", - source: { type: "local_path", hostId: host.id, path: "/tmp/other" }, - }); - const older = createThread(db, noopNotifier, { - projectId: project.id, - providerId: "codex", - }); - const newer = createThread(db, noopNotifier, { - projectId: otherProject.id, - providerId: "codex", - }); - db.update(threads) - .set({ updatedAt: 100, archivedAt: 400 }) - .where(eq(threads.id, older.id)) - .run(); - db.update(threads) - .set({ updatedAt: 200, archivedAt: 300 }) - .where(eq(threads.id, newer.id)) - .run(); - expect( - listThreadsWithPendingInteractionState(db, { archived: true, limit: 1 }) - .map((thread) => thread.id), - ).toEqual([older.id]); - expect( - listThreadsWithPendingInteractionState(db, { - archived: true, sort: "updated", limit: 1, - }).map((thread) => thread.id), - ).toEqual([newer.id]); - expect( - listThreadsWithPendingInteractionState(db, { - archived: true, sort: "updated", limit: 1, offset: 1, - }).map((thread) => thread.id), - ).toEqual([older.id]); - db.$client.close(); - }); - it("summarizes favicon attention for active sidebar threads", () => { vi.useFakeTimers(); try { diff --git a/packages/sdk/src/areas/threads.ts b/packages/sdk/src/areas/threads.ts index b5a452adc54..8dd6bd7b42f 100644 --- a/packages/sdk/src/areas/threads.ts +++ b/packages/sdk/src/areas/threads.ts @@ -84,7 +84,6 @@ export const DEFAULT_THREAD_WAIT_TIMEOUT_MS = 20 * 60 * 1000; export const DEFAULT_THREAD_WAIT_POLL_INTERVAL_MS = 250; export interface ThreadListArgs { - sort?: ThreadListQuery["sort"]; archived?: boolean; environmentId?: string; sectionId?: string; @@ -628,7 +627,6 @@ export interface ThreadsArea { function listQuery(args: ThreadListArgs | undefined): ThreadListQuery { return { - ...(args?.sort ? { sort: args.sort } : {}), ...(args?.projectId ? { projectId: args.projectId } : {}), ...(args?.environmentId ? { environmentId: args.environmentId } : {}), ...(args?.parentThreadId ? { parentThreadId: args.parentThreadId } : {}), diff --git a/packages/sdk/test/sdk.test.ts b/packages/sdk/test/sdk.test.ts index d7f6b5231d5..e5fe470a80d 100644 --- a/packages/sdk/test/sdk.test.ts +++ b/packages/sdk/test/sdk.test.ts @@ -1285,25 +1285,6 @@ describe("@bb/sdk", () => { }); }); - it("opts into updated ordering without changing the default list request", async () => { - const queue = createFetchQueue([{ body: [] }, { body: [] }]); - const sdk = createBbSdk({ - transport: createHttpTransport({ - baseUrl: "http://bb.test", - fetch: queue.fetch, - runtime: "node", - }), - }); - await sdk.threads.list({ archived: true, limit: 20 }); - await sdk.threads.list({ archived: true, sort: "updated", limit: 20 }); - expect(queue.requests[0]?.url).toBe( - "http://bb.test/api/v1/threads?archived=true&limit=20", - ); - expect(queue.requests[1]?.url).toBe( - "http://bb.test/api/v1/threads?sort=updated&archived=true&limit=20", - ); - }); - it("forwards includeHidden list filtering and visibility updates", async () => { const queue = createFetchQueue([ { body: [] }, diff --git a/packages/server-contract/src/api/threads.ts b/packages/server-contract/src/api/threads.ts index dcfae825a9f..6dedb4a04dc 100644 --- a/packages/server-contract/src/api/threads.ts +++ b/packages/server-contract/src/api/threads.ts @@ -752,7 +752,6 @@ export type ThreadArchiveAllResponse = z.infer< >; export const threadListQuerySchema = z.object({ - sort: z.literal("updated").optional(), projectId: z.string().min(1).optional(), environmentId: z.string().min(1).optional(), parentThreadId: z.string().min(1).optional(), diff --git a/packages/server-contract/test/contract.test.ts b/packages/server-contract/test/contract.test.ts index a1847ae6cbb..9f42226af4d 100644 --- a/packages/server-contract/test/contract.test.ts +++ b/packages/server-contract/test/contract.test.ts @@ -513,7 +513,6 @@ const OPTIONAL_SERVER_FIELD_GROUPS: readonly OptionalServerFieldGroup[] = [ "threadListQuerySchema.parentThreadId", "threadListQuerySchema.projectId", "threadListQuerySchema.sourceThreadId", - "threadListQuerySchema.sort", "threadListQuerySchema.unsectioned", ], }, diff --git a/packages/templates/src/templates/bb-guide-threads.md b/packages/templates/src/templates/bb-guide-threads.md index 55a8d4887c1..e4853f55c1e 100644 --- a/packages/templates/src/templates/bb-guide-threads.md +++ b/packages/templates/src/templates/bb-guide-threads.md @@ -154,7 +154,6 @@ Listing: --environment Filter by environment --parent-thread Filter by parent thread --archived Show only archived threads - --sort updated Order by last update before pagination --section Filter by section --unsectioned Show only threads outside sections --include-hidden Include hidden threads diff --git a/plugins/bb-guide/skills/bb-cli/references/thread-operation.md b/plugins/bb-guide/skills/bb-cli/references/thread-operation.md index 3b175b92699..88984cd9adf 100644 --- a/plugins/bb-guide/skills/bb-cli/references/thread-operation.md +++ b/plugins/bb-guide/skills/bb-cli/references/thread-operation.md @@ -1,9 +1,5 @@ # Thread coordination and inspection -`bb thread list --archived --sort updated` lists recent archived threads by -last update. The SDK equivalent is `threads.list({ archived: true, sort: "updated" })`. -Without `sort`, existing project/archive ordering is preserved. - ## Coordinating Work - Use one clear owner per task. From f6f00541f8527e298da4a5551a5ebfa136442f00 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:43:24 -0700 Subject: [PATCH 53/57] Remove obsolete palette sort cache guard --- apps/app/src/hooks/cache-owners/query-cache.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/app/src/hooks/cache-owners/query-cache.ts b/apps/app/src/hooks/cache-owners/query-cache.ts index 32c57a12fa6..c5cd825a7ba 100644 --- a/apps/app/src/hooks/cache-owners/query-cache.ts +++ b/apps/app/src/hooks/cache-owners/query-cache.ts @@ -625,7 +625,6 @@ export function optimisticallyInsertThread( } const filters = getThreadListFiltersFromQueryKey(queryKey); - if (filters?.sort === "updated") continue; if (!threadMatchesListFilters(thread, filters)) { continue; } From 3a4b1fee89bce1681e97b8948bb88c7c0b80a882 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:06:53 -0700 Subject: [PATCH 54/57] Consolidate sidebar filter regression coverage --- .../sidebar/SidebarHeaderControls.test.tsx | 30 ++----------------- .../sidebar/SidebarThreadLifecycles.test.tsx | 19 ++++-------- 2 files changed, 9 insertions(+), 40 deletions(-) diff --git a/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx b/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx index b0c6879d25f..0269af53226 100644 --- a/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx +++ b/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx @@ -101,7 +101,6 @@ describe("sidebar header controls", () => { await waitFor(() => expect(document.activeElement).toBe(project)); fireEvent.keyDown(project, { key: "Enter" }); expect(store.get(sidebarOrganizationModeAtom)).toBe("project"); - expect(screen.queryByRole("menuitem", { name: /^Reset/ })).toBeNull(); }); it("dismisses on the first outside click after toggling environment grouping", async () => { @@ -196,7 +195,7 @@ describe("sidebar header controls", () => { }); it.each([false, true])( - "keeps lifecycle selection nonempty in the combined menu (compact=%s)", + "updates the filter preference through the combined menu (compact=%s)", async (compact) => { viewport.compact = compact; const { store } = setup(); @@ -210,24 +209,10 @@ describe("sidebar header controls", () => { }); if (compact) fireEvent.click(filter); else await openSubmenu("Filter"); - const active = await screen.findByRole("menuitemcheckbox", { - name: "Active", + const archived = await screen.findByRole("menuitemcheckbox", { + name: "Archived", }); - expect(screen.queryByRole("menuitem", { name: "Reset" })).toBeNull(); - expect(active.getAttribute("aria-disabled")).not.toBe("true"); - fireEvent.click(active); - expect(store.get(sidebarThreadLifecyclesAtom)).toEqual(["active"]); - fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Archived" })); - fireEvent.click(active); - expect(store.get(sidebarThreadLifecyclesAtom)).toEqual(["archived"]); - const archived = screen.getByRole("menuitemcheckbox", { name: "Archived" }); - expect(archived.getAttribute("aria-checked")).toBe("true"); - expect(archived.getAttribute("aria-disabled")).not.toBe("true"); fireEvent.click(archived); - expect(store.get(sidebarThreadLifecyclesAtom)).toEqual(["archived"]); - fireEvent.click( - screen.getByRole("menuitemcheckbox", { name: "Active" }), - ); expect(store.get(sidebarThreadLifecyclesAtom)).toEqual([ "active", "archived", @@ -297,7 +282,6 @@ describe("sidebar header controls", () => { await screen.findByRole("menuitemradio", { name: "Updated at, descending. Sort ascending", }); - expect(screen.queryByRole("menuitem", { name: "Reset" })).toBeNull(); expect( screen .getByRole("menuitemradio", { @@ -349,13 +333,7 @@ describe("sidebar header controls", () => { it("preserves the existing Organize groups without a Reset action", async () => { const { store } = setup("Pinned", false, "chronological"); act(() => store.set(sidebarEnvironmentGroupingAtom, true)); - const trigger = screen.getByRole("button", { name: "Pinned actions" }); - expect(trigger.classList.contains("bg-state-active")).toBe(false); - expect(trigger.hasAttribute("aria-describedby")).toBe(false); await openMenu(); - expect(screen.getByRole("menuitem", { name: "Organize" })).toBeTruthy(); - expect(screen.getByRole("menuitem", { name: "Sort by" })).toBeTruthy(); - expect(screen.getByRole("menuitem", { name: "Filter" })).toBeTruthy(); await openSubmenu("Organize"); const grouping = await screen.findByRole("menuitemcheckbox", { name: "By environment" }); expect(screen.getByRole("group", { name: "Groups" })).toBeTruthy(); @@ -366,7 +344,6 @@ describe("sidebar header controls", () => { expect(store.get(sidebarOrganizationModeAtom)).toBe("project"); expect(store.get(sidebarEnvironmentGroupingAtom)).toBe(false); expect(screen.queryByRole("menuitem", { name: /^Reset/ })).toBeNull(); - expect(screen.queryByRole("tooltip")).toBeNull(); }); it.each([false, true])( @@ -384,7 +361,6 @@ describe("sidebar header controls", () => { const updated = await screen.findByRole("menuitemradio", { name: "Updated at, descending. Sort ascending", }); - expect(screen.queryByRole("menuitem", { name: /^Reset/ })).toBeNull(); fireEvent.click(updated); expect(store.get(sidebarSortDirectionAtom)).toBe("ascending"); fireEvent.click( diff --git a/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx b/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx index 93d458ee6f0..9f1bb750a1f 100644 --- a/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx +++ b/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx @@ -257,18 +257,17 @@ describe("sidebar lifecycle placement", () => { }, ); - it.each(["archived"] as const)( - "keeps the combined menu reachable in an empty %s-only group", - async (lifecycle) => { - const store = setup([lifecycle], true); + it( + "keeps the combined menu reachable in an empty archived-only group", + async () => { + const store = setup(["archived"], true); expect(screen.getByText("No threads")).toBeTruthy(); expect( screen.queryByRole("button", { name: /Filter:/ }), ).toBeNull(); - const label = "Threads"; fireEvent.keyDown( screen.getByRole("button", { - name: new RegExp(`^${label} actions(?:;|$)`), + name: "Threads actions", }), { key: "Enter", @@ -285,7 +284,7 @@ describe("sidebar lifecycle placement", () => { ); expect(store.get(sidebarThreadLifecyclesAtom)).toEqual([ "active", - lifecycle, + "archived", ]); fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Archived" })); expect(store.get(sidebarThreadLifecyclesAtom)).toEqual(["active"]); @@ -316,10 +315,4 @@ describe("sidebar lifecycle placement", () => { expect(archiveQuery.enabled).toBe(false); expect(screen.queryByText("Archived work")).toBeNull(); }); - - it("reuses the no-threads state for an empty selected group", () => { - setup(["active"], true); - expect(screen.getByText("No threads")).toBeDefined(); - expect(screen.queryByRole("heading", { name: "Drafts" })).toBeNull(); - }); }); From c2f3cbd1bc0227dc04a58b80190d7212a9b2d34b Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:00:49 -0700 Subject: [PATCH 55/57] Keep sidebar archive filtering browser-local --- .../sidebar/SidebarThreadLifecycles.test.tsx | 2 +- .../sidebar/sidebarCollapsedAtoms.ts | 5 +- .../thread/ThreadLifecycleFilter.test.tsx | 2 +- .../thread/ThreadLifecycleFilter.tsx | 6 ++- .../src/lib/thread-lifecycle-filter.test.ts | 50 +++++++++++++++++++ apps/app/src/lib/thread-lifecycle-filter.ts | 24 ++++++++- docs/configuration.md | 10 ++-- packages/domain/src/ui-preferences.ts | 21 -------- packages/domain/test/ui-preferences.test.ts | 29 ----------- .../src/templates/bb-guide-customization.md | 9 ++-- .../skills/bb-cli/references/app-settings.md | 8 ++- 11 files changed, 93 insertions(+), 73 deletions(-) create mode 100644 apps/app/src/lib/thread-lifecycle-filter.test.ts delete mode 100644 packages/domain/test/ui-preferences.test.ts diff --git a/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx b/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx index 9f1bb750a1f..25f8cdc83d7 100644 --- a/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx +++ b/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx @@ -12,7 +12,7 @@ import { createStore, Provider } from "jotai"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MemoryRouter } from "react-router-dom"; import { afterEach, describe, expect, it, vi } from "vitest"; -import type { ThreadArchiveFilter } from "@bb/domain"; +import type { ThreadArchiveFilter } from "@/lib/thread-lifecycle-filter"; import { buildMachineThreadGroups, buildPinnedSidebarState, diff --git a/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts b/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts index 3e206c9fb27..365e5d7a1f0 100644 --- a/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts +++ b/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts @@ -4,6 +4,7 @@ import type { SidebarOrganizationMode, } from "@bb/domain"; import { createSyncedPreferenceAtom } from "@/lib/ui-preferences/synced-preference-atom"; +import { createThreadArchiveFilterAtom } from "@/lib/thread-lifecycle-filter"; export type { CollapsibleSidebarSectionId, @@ -12,8 +13,8 @@ export type { export type { SidebarChronologicalSort, SidebarOrganizationMode }; -export const sidebarThreadLifecyclesAtom = createSyncedPreferenceAtom( - "sidebar.threadLifecycles", +export const sidebarThreadLifecyclesAtom = createThreadArchiveFilterAtom( + "bb.sidebar.threadArchiveFilter", ); export const collapsedProjectIdsAtom = createSyncedPreferenceAtom( diff --git a/apps/app/src/components/thread/ThreadLifecycleFilter.test.tsx b/apps/app/src/components/thread/ThreadLifecycleFilter.test.tsx index cfd0b732938..b6afbc1bf47 100644 --- a/apps/app/src/components/thread/ThreadLifecycleFilter.test.tsx +++ b/apps/app/src/components/thread/ThreadLifecycleFilter.test.tsx @@ -9,7 +9,7 @@ import { waitFor, } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; -import type { ThreadArchiveFilter } from "@bb/domain"; +import type { ThreadArchiveFilter } from "@/lib/thread-lifecycle-filter"; import { ThreadLifecycleFilter } from "./ThreadLifecycleFilter"; const viewport = vi.hoisted(() => ({ compact: false })); diff --git a/apps/app/src/components/thread/ThreadLifecycleFilter.tsx b/apps/app/src/components/thread/ThreadLifecycleFilter.tsx index 2dbf5f55e88..7d27eb6e052 100644 --- a/apps/app/src/components/thread/ThreadLifecycleFilter.tsx +++ b/apps/app/src/components/thread/ThreadLifecycleFilter.tsx @@ -1,7 +1,9 @@ -import type { ThreadArchiveFilter } from "@bb/domain"; import { Button } from "@bb/shared-ui/button"; import { Icon } from "@bb/shared-ui/icon"; -import { normalizeThreadLifecycleFilter } from "@/lib/thread-lifecycle-filter"; +import { + normalizeThreadLifecycleFilter, + type ThreadArchiveFilter, +} from "@/lib/thread-lifecycle-filter"; import { DropdownMenu, DropdownMenuContent, diff --git a/apps/app/src/lib/thread-lifecycle-filter.test.ts b/apps/app/src/lib/thread-lifecycle-filter.test.ts new file mode 100644 index 00000000000..6ca1e5eb3d7 --- /dev/null +++ b/apps/app/src/lib/thread-lifecycle-filter.test.ts @@ -0,0 +1,50 @@ +// @vitest-environment jsdom + +import { createStore } from "jotai"; +import { afterEach, describe, expect, it } from "vitest"; +import { createThreadArchiveFilterAtom } from "./thread-lifecycle-filter"; + +const sidebarKey = "test.sidebar.archiveFilter"; +const paletteKey = "test.palette.archiveFilter"; + +afterEach(() => { + window.localStorage.removeItem(sidebarKey); + window.localStorage.removeItem(paletteKey); +}); + +describe("browser-local thread filters", () => { + it("restores independent selections without server preferences", () => { + const store = createStore(); + const sidebar = createThreadArchiveFilterAtom(sidebarKey); + const palette = createThreadArchiveFilterAtom(paletteKey); + expect(store.get(sidebar)).toEqual(["active"]); + store.set(sidebar, ["active", "archived"]); + store.set(palette, ["archived"]); + + const reloaded = createStore(); + expect(reloaded.get(createThreadArchiveFilterAtom(sidebarKey))).toEqual([ + "active", + "archived", + ]); + expect(reloaded.get(createThreadArchiveFilterAtom(paletteKey))).toEqual([ + "archived", + ]); + }); + + it("falls back to Active for malformed, empty, or unsupported stored values", () => { + for (const value of [ + "invalid JSON", + "null", + '"active"', + "[]", + '["draft"]', + '["active","active"]', + '["active","archived","unknown"]', + ]) { + window.localStorage.setItem(sidebarKey, value); + expect(createStore().get(createThreadArchiveFilterAtom(sidebarKey))).toEqual([ + "active", + ]); + } + }); +}); diff --git a/apps/app/src/lib/thread-lifecycle-filter.ts b/apps/app/src/lib/thread-lifecycle-filter.ts index be240bfbe82..eac341d88da 100644 --- a/apps/app/src/lib/thread-lifecycle-filter.ts +++ b/apps/app/src/lib/thread-lifecycle-filter.ts @@ -1,4 +1,26 @@ -import type { ThreadArchiveFilter } from "@bb/domain"; +import { atomWithStorage } from "jotai/utils"; +import { createJsonLocalStorage } from "@/lib/browser-storage"; + +export type ThreadArchiveFilter = "active" | "archived"; + +function isThreadArchiveFilter(value: unknown): value is ThreadArchiveFilter[] { + return ( + Array.isArray(value) && + value.length >= 1 && + value.length <= 2 && + new Set(value).size === value.length && + value.every((item) => item === "active" || item === "archived") + ); +} + +export function createThreadArchiveFilterAtom(storageKey: string) { + return atomWithStorage( + storageKey, + ["active"], + createJsonLocalStorage(isThreadArchiveFilter), + { getOnInit: true }, + ); +} export function normalizeThreadLifecycleFilter( value: readonly ThreadArchiveFilter[], diff --git a/docs/configuration.md b/docs/configuration.md index 563bfe671d2..b95b6a0a07f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -703,7 +703,6 @@ client wrote first, so a stale window cannot silently clobber a newer value. | Key | Value | | --------------------------------- | --------------------------------------------------- | | `sidebar.organizationMode` | `project`, `chronological`, or `machine` | -| `sidebar.threadLifecycles` | Nonempty selection of `active`, `archived` | | `sidebar.threadGrouping.environment` | `auto`, `true`, or `false` | | `sidebar.chronologicalSort` | `updated`, `created`, `alpha`, or `none` | | `sidebar.sectionOrder` | Section id list for **By project** | @@ -730,17 +729,16 @@ browser choices, which take precedence over this installation fallback. Reset saves the installation fallback as an explicit choice. The built-in sidebar defaults to Active, including threads with saved messages. -`sidebar.threadLifecycles` selects Active and Archived. There is no separate +Filter selects Active and Archived and remembers the selection in this browser, +not in the server-backed preferences or SDK/CLI. There is no separate Drafts section or filter; saved messages remain in their owning thread. The selected archived threads retain their section, project, machine, and pin placement. Choose Filter in a sidebar header's combined actions menu to change the selection. The combined menu offers Organize, Sort by, and Filter. Organize retains its Sections choices and Groups → By environment toggle. -Archived rows have a persistent Archive icon +Desktop archived rows have a persistent Unarchive icon that restores the thread without navigating away. -Archived loads pages only while selected. For example, -`bb settings ui set sidebar.threadLifecycles '["active","archived"]'` shows both. -Previously saved `draft` selections display as Active. +Archived loads pages only while selected. Plugin sidebar replacements own their rendering. `sidebar.threadGrouping.environment` decides whether two or more sibling threads diff --git a/packages/domain/src/ui-preferences.ts b/packages/domain/src/ui-preferences.ts index 53206e82195..443e9e9f800 100644 --- a/packages/domain/src/ui-preferences.ts +++ b/packages/domain/src/ui-preferences.ts @@ -39,10 +39,7 @@ const sidebarHiddenGroupsSchema = z .max(UI_PREFERENCE_LIST_MAX_LENGTH) .transform((value) => [...new Set(value)]); -export type ThreadArchiveFilter = "active" | "archived"; - export const UI_PREFERENCE_KEYS = [ - "sidebar.threadLifecycles", "sidebar.organizationMode", "sidebar.threadGrouping.environment", "sidebar.chronologicalSort", @@ -86,24 +83,6 @@ function defineUiPreference( } export const uiPreferenceDefinitions = { - "sidebar.threadLifecycles": defineUiPreference( - z - .array(z.enum(["active", "draft", "archived"])) - .min(1) - .max(3) - .refine( - (values) => new Set(values).size === values.length, - "Thread filters must be unique.", - ) - .transform((values): ThreadArchiveFilter[] => [ - ...(values.includes("active") || values.includes("draft") - ? ["active" as const] - : []), - ...(values.includes("archived") ? ["archived" as const] : []), - ]), - ["active"], - "Threads shown in the built-in sidebar: active and archived. Select at least one; defaults to active.", - ), "sidebar.organizationMode": defineUiPreference( sidebarOrganizationModeSchema, "chronological", diff --git a/packages/domain/test/ui-preferences.test.ts b/packages/domain/test/ui-preferences.test.ts deleted file mode 100644 index 2d1e44a2a71..00000000000 --- a/packages/domain/test/ui-preferences.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - getUiPreferenceDefault, - parseUiPreferenceValue, -} from "../src/ui-preferences.js"; - -describe("sidebar lifecycle preference", () => { - it("defaults to Active and accepts a nonempty distinct lifecycle selection", () => { - expect(getUiPreferenceDefault("sidebar.threadLifecycles")).toEqual([ - "active", - ]); - expect( - parseUiPreferenceValue("sidebar.threadLifecycles", ["draft", "archived"]), - ).toEqual({ - success: true, - value: ["active", "archived"], - }); - }); - - it.each( - [[], ["draft", "draft"], ["unknown"], "active", null].map((value) => ({ - value, - })), - )("rejects invalid selection $value", ({ value }) => { - expect( - parseUiPreferenceValue("sidebar.threadLifecycles", value).success, - ).toBe(false); - }); -}); diff --git a/packages/templates/src/templates/bb-guide-customization.md b/packages/templates/src/templates/bb-guide-customization.md index 8be1e23fbaa..ea95d8dc0a2 100644 --- a/packages/templates/src/templates/bb-guide-customization.md +++ b/packages/templates/src/templates/bb-guide-customization.md @@ -305,12 +305,11 @@ to By project (`project`). Explicit server choices take precedence over legacy browser choices, which take precedence over this installation fallback. Reset saves the installation fallback as an explicit choice. -`sidebar.threadLifecycles` selects `active` and `archived`, defaulting to -`["active"]`. Active includes threads with saved messages; there is no separate +The built-in sidebar's Filter selects Active and Archived, defaulting to Active. +The selection is browser-local, not a server-backed preference or SDK/CLI setting. +Active includes threads with saved messages; there is no separate Drafts section or filter. Archived threads use their preserved placement and a -restore action. Archived pages load only while selected. For example: -`bb settings ui set sidebar.threadLifecycles '["active","archived"]'`. -Previously saved `draft` selections display as Active. +restore action. Archived pages load only while selected. Plugin sidebar replacements own their filters. Every thread-list header's actions menu offers New project, New section, diff --git a/plugins/bb-guide/skills/bb-cli/references/app-settings.md b/plugins/bb-guide/skills/bb-cli/references/app-settings.md index 87de3b009c3..0be967a793e 100644 --- a/plugins/bb-guide/skills/bb-cli/references/app-settings.md +++ b/plugins/bb-guide/skills/bb-cli/references/app-settings.md @@ -19,11 +19,9 @@ every window and client sees the same value. orders, the collapsed-id lists, `sidebar.hiddenGroups`, `sidebar.pluginPanelOrder`, `sidebar.visiblePluginPanels`, `sidebar.navigationProvider`, `sidebar.threadListProvider`). -- `sidebar.threadLifecycles` selects `active` and `archived` in the built-in - sidebar. Default is `["active"]`, including threads with saved messages. - Previously saved `draft` selections display as Active. - Use `bb settings ui set sidebar.threadLifecycles '["active","archived"]'` - to include archived threads. Selected archived rows +- The built-in sidebar's Filter selects Active and Archived, defaulting to Active, + including threads with saved messages. This selection is browser-local, not + a server-backed preference or SDK/CLI setting. Selected archived rows retain their hierarchy placement and offer a restore action. Archived pages load only while selected; plugin sidebar replacements keep ownership of their rendering. - `sidebar.organizationMode` defaults to Custom (`chronological`) on new installs. From 9255e4cce220e1814ba5f41f4ff650f7eaa2aebb Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:15:54 -0700 Subject: [PATCH 56/57] Remove unused archive action icon override --- apps/app/src/components/thread/ThreadActionsMenu.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/app/src/components/thread/ThreadActionsMenu.tsx b/apps/app/src/components/thread/ThreadActionsMenu.tsx index fe100dd4586..8a2fb95cbd2 100644 --- a/apps/app/src/components/thread/ThreadActionsMenu.tsx +++ b/apps/app/src/components/thread/ThreadActionsMenu.tsx @@ -348,12 +348,10 @@ export function ThreadArchiveQuickAction({ thread, className, disabled, - icon, }: { thread: Thread; className?: string; disabled?: boolean; - icon?: IconName; }) { const { archiveThreadAndChildren, unarchiveThread } = useThreadActions(); const isArchived = thread.archivedAt != null; @@ -379,7 +377,7 @@ export function ThreadArchiveQuickAction({ }} > From 73b10b3b38ab769ccc505daa110f22f0d76772d0 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:40:31 -0700 Subject: [PATCH 57/57] test(app): consolidate sidebar archive regression coverage --- .../sidebar/SidebarHeaderControls.test.tsx | 156 ++++++------------ .../sidebar/SidebarThreadLifecycles.test.tsx | 106 ++++-------- .../thread-lifecycle-cache.test.ts | 28 ---- 3 files changed, 80 insertions(+), 210 deletions(-) diff --git a/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx b/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx index 0269af53226..29ec32ececf 100644 --- a/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx +++ b/apps/app/src/components/sidebar/SidebarHeaderControls.test.tsx @@ -144,16 +144,9 @@ describe("sidebar header controls", () => { false, ); expect(control?.classList.contains("hover:text-foreground")).toBe(false); - expect(control?.classList.contains("focus-visible:ring-0")).toBe(false); expect(control?.classList.contains("focus-visible:ring-1")).toBe(true); expect(control?.classList.contains("focus-visible:ring-ring")).toBe(true); - expect(control?.classList.contains("focus-visible:ring-2")).toBe(false); - expect(control?.classList.contains("focus-visible:bg-primary")).toBe( - false, - ); - expect(control?.classList.contains("focus-visible:bg-state-hover")).toBe( - false, - ); + expect(control?.className).not.toMatch(/focus-visible:(bg-|ring-[02]\b)/); } expect(primary.classList.contains("max-md:pointer-coarse:w-8")).toBe(true); expect( @@ -195,18 +188,28 @@ describe("sidebar header controls", () => { }); it.each([false, true])( - "updates the filter preference through the combined menu (compact=%s)", + "changes filtering through plain combined-menu controls (compact=%s)", async (compact) => { viewport.compact = compact; const { store } = setup(); - const trigger = screen.getByRole("button", { - name: /^Pinned actions(?:;|$)/, + const trigger = screen.getByRole("button", { name: "Pinned actions" }); + act(() => { + store.set(sidebarOrganizationModeAtom, "machine"); + store.set(sidebarChronologicalSortAtom, "created"); + store.set(sidebarSortDirectionAtom, "ascending"); }); + expect(trigger.querySelector('[data-icon="MoreHorizontal"]')).toBeTruthy(); + expect(trigger.classList.contains("bg-state-active")).toBe(false); + expect(trigger.hasAttribute("aria-pressed")).toBe(false); + expect(trigger.hasAttribute("aria-describedby")).toBe(false); if (compact) fireEvent.click(trigger); else await openMenu(); const filter = await screen.findByRole("menuitem", { name: "Filter", }); + expect( + screen.getAllByRole("menuitem").map((item) => item.textContent), + ).toEqual(["New project", "New section", "Organize", "Sort by", "Filter"]); if (compact) fireEvent.click(filter); else await openSubmenu("Filter"); const archived = await screen.findByRole("menuitemcheckbox", { @@ -218,8 +221,8 @@ describe("sidebar header controls", () => { "archived", ]); expect(screen.queryByRole("menuitem", { name: /^Reset/ })).toBeNull(); - expect(store.get(sidebarOrganizationModeAtom)).toBe("project"); - expect(store.get(sidebarChronologicalSortAtom)).toBe("updated"); + expect(store.get(sidebarOrganizationModeAtom)).toBe("machine"); + expect(store.get(sidebarChronologicalSortAtom)).toBe("created"); if (compact) { expect( screen.getByRole("dialog", { name: "Filter" }), @@ -229,68 +232,10 @@ describe("sidebar header controls", () => { expect( screen.getByRole("menuitem", { name: "New project" }), ).toBeTruthy(); - expect(screen.getByRole("menuitem", { name: "Organize" })).toBeTruthy(); - } - }, - ); - - it.each([false, true])( - "keeps ordinary controls and plain menu labels after synced changes (compact=%s)", - async (compact) => { - viewport.compact = compact; - const { store } = setup("Pinned", false, "chronological"); - const trigger = screen.getByRole("button", { name: "Pinned actions" }); - expect( - trigger.querySelector('[data-icon="MoreHorizontal"]'), - ).toBeTruthy(); - act(() => { - store.set(sidebarOrganizationModeAtom, "machine"); - store.set(sidebarChronologicalSortAtom, "created"); - store.set(sidebarSortDirectionAtom, "ascending"); - store.set(sidebarThreadLifecyclesAtom, ["active", "archived"]); - }); - expect( - trigger.querySelector('[data-icon="MoreHorizontal"]'), - ).toBeTruthy(); - expect(trigger.classList.contains("bg-state-active")).toBe(false); - expect(trigger.getAttribute("aria-label")).toBe("Pinned actions"); - expect(trigger.getAttribute("aria-haspopup")).toBe("menu"); - expect(trigger.getAttribute("aria-expanded")).toBe("false"); - expect(trigger.hasAttribute("aria-pressed")).toBe(false); - expect(trigger.hasAttribute("aria-describedby")).toBe(false); - if (compact) fireEvent.click(trigger); - else await openMenu(); - await screen.findByRole("menuitem", { name: "Filter" }); - expect(screen.getAllByRole("menuitem").map((item) => item.textContent)).toEqual([ - "New project", "New section", "Organize", "Sort by", "Filter", - ]); - for (const item of screen.getAllByRole("menuitem")) { - expect(item.querySelector('[data-icon="ArrowUp"], [data-icon="ArrowDown"]')).toBeNull(); } - expect(screen.queryByRole("tooltip")).toBeNull(); }, ); - it("keeps legacy none equivalent to Updated at", async () => { - const { store } = setup("Pinned", false, "chronological"); - act(() => { - store.set(sidebarChronologicalSortAtom, "none"); - store.set(sidebarSortDirectionAtom, "descending"); - }); - await openMenu(); - await openSubmenu("Sort by"); - await screen.findByRole("menuitemradio", { - name: "Updated at, descending. Sort ascending", - }); - expect( - screen - .getByRole("menuitemradio", { - name: "Updated at, descending. Sort ascending", - }) - .getAttribute("aria-checked"), - ).toBe("true"); - }); - it("keeps Organize open and exclusive across selections", async () => { const { store } = setup(); await openMenu(); @@ -346,44 +291,37 @@ describe("sidebar header controls", () => { expect(screen.queryByRole("menuitem", { name: /^Reset/ })).toBeNull(); }); - it.each([false, true])( - "changes sort without adding a Reset action (compact=%s)", - async (compact) => { - viewport.compact = compact; - const { store } = setup(); - if (compact) { - fireEvent.click(screen.getByRole("button", { name: "Pinned actions" })); - fireEvent.click(await screen.findByRole("menuitem", { name: "Sort by" })); - } else { - await openMenu(); - await openSubmenu("Sort by"); - } - const updated = await screen.findByRole("menuitemradio", { - name: "Updated at, descending. Sort ascending", - }); - fireEvent.click(updated); - expect(store.get(sidebarSortDirectionAtom)).toBe("ascending"); - fireEvent.click( - screen.getByRole("menuitemradio", { - name: "Updated at, ascending. Sort descending", - }), - ); - expect(store.get(sidebarSortDirectionAtom)).toBe("descending"); - fireEvent.click( - screen.getByRole("menuitemradio", { name: "Alphabetical" }), - ); - expect(store.get(sidebarChronologicalSortAtom)).toBe("alpha"); - expect(store.get(sidebarSortDirectionAtom)).toBe("ascending"); - expect(screen.queryByRole("menuitem", { name: /^Reset/ })).toBeNull(); - expect( - screen - .getByRole("menuitemradio", { - name: "Alphabetical, ascending. Sort descending", - }) - .getAttribute("aria-checked"), - ).toBe("true"); - }, - ); + it("resolves legacy sort, toggles direction, and resets it for another field", async () => { + const { store } = setup(); + act(() => store.set(sidebarChronologicalSortAtom, "none")); + await openMenu(); + await openSubmenu("Sort by"); + const updated = await screen.findByRole("menuitemradio", { + name: "Updated at, descending. Sort ascending", + }); + expect(updated.getAttribute("aria-checked")).toBe("true"); + fireEvent.click(updated); + expect(store.get(sidebarSortDirectionAtom)).toBe("ascending"); + fireEvent.click( + screen.getByRole("menuitemradio", { + name: "Updated at, ascending. Sort descending", + }), + ); + expect(store.get(sidebarSortDirectionAtom)).toBe("descending"); + fireEvent.click( + screen.getByRole("menuitemradio", { name: "Alphabetical" }), + ); + expect(store.get(sidebarChronologicalSortAtom)).toBe("alpha"); + expect(store.get(sidebarSortDirectionAtom)).toBe("ascending"); + expect(screen.queryByRole("menuitem", { name: /^Reset/ })).toBeNull(); + expect( + screen + .getByRole("menuitemradio", { + name: "Alphabetical, ascending. Sort descending", + }) + .getAttribute("aria-checked"), + ).toBe("true"); + }); it("announces compact sort direction and resets the nested page after closing", async () => { viewport.compact = true; diff --git a/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx b/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx index 25f8cdc83d7..5efd6110585 100644 --- a/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx +++ b/apps/app/src/components/sidebar/SidebarThreadLifecycles.test.tsx @@ -13,13 +13,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MemoryRouter } from "react-router-dom"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { ThreadArchiveFilter } from "@/lib/thread-lifecycle-filter"; -import { - buildMachineThreadGroups, - buildPinnedSidebarState, - buildProjectThreadGroups, - buildSectionThreadList, - buildSidebarEntitySectionId, -} from "@bb/client-core"; +import { buildSidebarEntitySectionId } from "@bb/client-core"; import { TooltipProvider } from "@bb/shared-ui/tooltip"; import { makeThreadListEntry } from "@bb/test-helpers/domain-fixtures"; import { SidebarThreadLifecycles, useSidebarThreadLifecycles } from "./SidebarThreadLifecycles"; @@ -180,12 +174,11 @@ describe("sidebar lifecycle placement", () => { store.set(sidebarThreadLifecyclesAtom, ["active", "archived"]); const active = makeThreadListEntry({ id: "active" }); const duplicate = makeThreadListEntry({ id: "archived-thread" }); - const draft = makeThreadListEntry({ id: "draft" }); const client = new QueryClient(); const { result, rerender } = renderHook( ({ bootstrap }) => useSidebarThreadLifecycles(bootstrap), { - initialProps: { bootstrap: [active, duplicate, draft] }, + initialProps: { bootstrap: [active, duplicate] }, wrapper: ({ children }) => ( {children} @@ -193,8 +186,8 @@ describe("sidebar lifecycle placement", () => { ), }, ); - expect(result.current.threads).toEqual([duplicate, active, draft]); - rerender({ bootstrap: [active, draft] }); + expect(result.current.threads).toEqual([duplicate, active]); + rerender({ bootstrap: [active] }); expect(result.current.threads[0]).toMatchObject({ id: "archived-thread", projectId: "archive-project", @@ -204,61 +197,42 @@ describe("sidebar lifecycle placement", () => { pinnedAt: 1, pinSortKey: "a0", }); - const archived = result.current.threads[0]!; - expect(buildPinnedSidebarState({ threads: result.current.threads }).rootNodes) - .toMatchObject([{ thread: archived }]); - expect(buildProjectThreadGroups([archived])) - .toMatchObject([{ kind: "thread", node: { thread: archived } }]); - expect(buildMachineThreadGroups([archived], [])) - .toMatchObject([{ key: "archive-host", threads: [archived] }]); - expect(buildSectionThreadList([archived], () => 0, [ - { id: "archive-section", name: "Review" }, - ])).toMatchObject([{ - kind: "section", - group: { id: "archive-section", items: [{ kind: "thread", node: { thread: archived } }] }, - }]); - act(() => store.set(sidebarThreadLifecyclesAtom, ["active"])); - expect(result.current.threads).toEqual([active, draft]); }); - it.each<{ lifecycles: ThreadArchiveFilter[]; active: boolean; archived: boolean }>([ - { lifecycles: ["active"], active: true, archived: false }, - { lifecycles: ["archived"], active: false, archived: true }, - { lifecycles: ["active", "archived"], active: true, archived: true }, - ])("includes saved messages in the ordinary hierarchy for $lifecycles", ({ lifecycles, active, archived }) => { - setup(lifecycles); - expect(screen.queryByText("Active work") !== null).toBe(active); - expect(screen.queryByText("Saved work") !== null).toBe(active); - expect(screen.queryByText("Archived work") !== null).toBe(archived); + it("filters the existing hierarchy and only pages archives while selected", () => { + const store = setup(); + expect(screen.getByText("Active work")).toBeTruthy(); + expect(screen.getByText("Saved work")).toBeTruthy(); + expect(screen.queryByText("Archived work")).toBeNull(); + expect(screen.queryByRole("heading", { name: "Active" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Active actions" })).toBeNull(); + expect(archiveQuery.enabled).toBe(false); + + act(() => store.set(sidebarThreadLifecyclesAtom, ["active", "archived"])); + expect(screen.getByText("Active work")).toBeTruthy(); + expect(screen.getByText("Saved work")).toBeTruthy(); + expect(screen.getByText("Archived work")).toBeTruthy(); expect(screen.queryByRole("region", { name: "Drafts" })).toBeNull(); expect(screen.queryByRole("heading", { name: "Drafts" })).toBeNull(); - expect(archiveQuery.enabled).toBe(archived); - }); + expect(archiveQuery.enabled).toBe(true); + fireEvent.click( + screen.getByRole("button", { name: "Load more archived threads" }), + ); + expect(archiveQuery.fetchNextPage).toHaveBeenCalledOnce(); - it.each([false, true])( - "uses the existing hierarchy menu without an Active header (empty=%s)", - async (empty) => { - setup(["active"], empty); - expect(screen.queryByRole("heading", { name: "Active" })).toBeNull(); - expect(screen.queryByRole("region", { name: "Active" })).toBeNull(); - expect( - screen.queryByRole("button", { name: /^Active actions/ }), - ).toBeNull(); - expect( - screen.getByText(empty ? "No threads" : "Active work"), - ).toBeTruthy(); - fireEvent.keyDown( - screen.getByRole("button", { name: /^Threads actions(?:;|$)/ }), - { key: "Enter" }, - ); - expect( - await screen.findByRole("menuitem", { name: "Filter" }), - ).toBeTruthy(); - }, - ); + act(() => store.set(sidebarThreadLifecyclesAtom, ["archived"])); + expect(screen.queryByText("Active work")).toBeNull(); + expect(screen.queryByText("Saved work")).toBeNull(); + expect(screen.getByText("Archived work")).toBeTruthy(); + + act(() => store.set(sidebarThreadLifecyclesAtom, ["active"])); + expect(screen.getByText("Active work")).toBeTruthy(); + expect(screen.queryByText("Archived work")).toBeNull(); + expect(archiveQuery.enabled).toBe(false); + }); it( - "keeps the combined menu reachable in an empty archived-only group", + "keeps the combined menu reachable when empty, before and after returning to Active", async () => { const store = setup(["archived"], true); expect(screen.getByText("No threads")).toBeTruthy(); @@ -301,18 +275,4 @@ describe("sidebar lifecycle placement", () => { ).toBeTruthy(); }, ); - - it("starts and stops archived paging when the synced preference changes", () => { - const store = setup(); - expect(archiveQuery.enabled).toBe(false); - act(() => store.set(sidebarThreadLifecyclesAtom, ["archived"])); - expect(archiveQuery.enabled).toBe(true); - fireEvent.click( - screen.getByRole("button", { name: "Load more archived threads" }), - ); - expect(archiveQuery.fetchNextPage).toHaveBeenCalledOnce(); - act(() => store.set(sidebarThreadLifecyclesAtom, ["active"])); - expect(archiveQuery.enabled).toBe(false); - expect(screen.queryByText("Archived work")).toBeNull(); - }); }); diff --git a/apps/app/src/hooks/cache-owners/thread-lifecycle-cache.test.ts b/apps/app/src/hooks/cache-owners/thread-lifecycle-cache.test.ts index b4bd04151f0..b0a9479658f 100644 --- a/apps/app/src/hooks/cache-owners/thread-lifecycle-cache.test.ts +++ b/apps/app/src/hooks/cache-owners/thread-lifecycle-cache.test.ts @@ -63,32 +63,4 @@ describe("sidebar archive cache", () => { expect(queryClient.getQueryData(archivedKey)).toEqual(pages); }, ); - - it("restores archived hierarchy metadata after an unsuccessful optimistic restore", async () => { - const queryClient = new QueryClient(); - const archivedKey = archivedThreadsListQueryKey({}); - const archived = makeThreadListEntry({ - id: "archived", - projectId: "project-1", - archivedAt: 1, - sectionId: "section-1", - pinnedAt: 1, - pinSortKey: "a0", - environmentId: "environment-1", - environmentHostId: "host-1", - }); - const pages = { pages: [[archived]], pageParams: [0] }; - queryClient.setQueryData(archivedKey, pages); - const transaction = await beginUnarchiveThreadTransaction({ - queryClient, - threadId: archived.id, - }); - expect(queryClient.getQueryData(archivedKey)).toMatchObject({ pages: [[]] }); - rollbackThreadListMutationTransaction({ - queryClient, - threadId: archived.id, - transaction, - }); - expect(queryClient.getQueryData(archivedKey)).toEqual(pages); - }); });