From 64e9963fa8d28267b6ddb00b379705bfe3063b29 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 18 Sep 2026 22:52:49 -0400 Subject: [PATCH 01/48] 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 acf02e3764..31ff5a720f 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 7e3a40b846..7b3cb50828 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 61a1584aa9..77edad530f 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 695a69c1c4..9fe2ebb3cb 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 8ce19f522e..8ddaf6be9a 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 23557696b3..418b466756 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 5c2edcab64..165447871d 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 a4d0212928..813bce5830 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 b94badaed0..a0e7eb34b3 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 f7a4aff90b..e9d17133b3 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 d7bbee371c..9179b0656f 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 eb4111bd24..ec71258b41 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 f5e53141b5..a1f7955a72 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 afb9221714..786dfadf2c 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 2cfce1d7be..59db40e053 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 e26770630a..999e7f404b 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 12e62a3271..7831745f53 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 f4f905949d..0b65a696c4 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 6b62d8380b..27ffeb04dd 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 554d9fe199..e1a7fe457d 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 0914d5b04a..0e3052714e 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 4dc73f3599..b51372589f 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 132257c3c4..332a73f980 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 63f425d13c..09f9a508dd 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 2deaaf650c..5f2137aeed 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 9953b894e9..c15e1f5199 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 79a029c6a6..c76e004e1d 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 9375627e1b..7aedd70b59 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 0000000000..9b85005297 --- /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 405edfee51..e13e8ff1a2 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 292420f438..33dbfc683a 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 e5fe470a80..a613f52afc 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 6e3b33f2dd..ed9725d44a 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 602d1dae15..0ed1771770 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 0329c745ac..42884a1869 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 2e17d4e52a..44a55bc173 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 bbe137475b..c35436392b 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 88984cd9ad..2f0dd120ee 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/48] 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 b1060de990..5014f6b5fd 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 0ed1771770..61faa28157 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/48] 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 165447871d..76db1d88c9 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 813bce5830..faa8279361 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 a613f52afc..36b689d23f 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 02162dd6e2..7dedda7040 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/48] 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 aa5a6de97c..d6eba84f72 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 0000000000..ec6e6c846a --- /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 0000000000..9ef6bd3103 --- /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 4cebed98a6..8f5f37e6e1 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 0000000000..28c39751f4 --- /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 0000000000..7699c6737b --- /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 7b3cb50828..5b3980511c 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 0000000000..378b79df22 --- /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 9fe2ebb3cb..3c6eadf3b8 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 a7be0d344e..198d2d3c0f 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 2c7d83de43..50405c4ae1 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 5ec43f9aff..e5261af26c 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 0000000000..d59b91dd9c --- /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 800584f7c3..82bbc7b10d 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 4d7eb50138..4724f269d8 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/48] 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 faa8279361..a96f1cddd6 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/48] 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 28c39751f4..541d63f805 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 7699c6737b..c3fdc72367 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 06ee0c9000..ecb88a34da 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 ff8fd284fc..c14edf87b5 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 12/48] 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 c91095aeb0..9b4f406c86 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 ef4ed94ebf..1a3db2707a 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 13/48] 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 b507f18623..514b607ab1 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 1a3db2707a..ece39e0761 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 0000000000..d94d041f1d --- /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 14/48] 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 ecb88a34da..fad8e86636 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 07c3510a06..a157eb5cab 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 15/48] 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 d6eba84f72..9e26e9aa51 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 ece39e0761..62c1aa56e8 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 fad8e86636..64225f2676 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 a157eb5cab..520bb49223 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 378b79df22..22ad39fe57 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 c14edf87b5..c5d0b61192 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 16/48] 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 64225f2676..5a65f1a9c8 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 520bb49223..c6e79e78d6 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 c5d0b61192..c63e098596 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 17/48] 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 82bbc7b10d..2ae916431e 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 4724f269d8..dcf12cf5c6 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 18/48] 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 5a65f1a9c8..13fd066149 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 bcdbe008655b2ea8bec1ed6d7555f1d656c17457 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 16:07:56 -0700 Subject: [PATCH 19/48] 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 f956b20aab..b63ff17e9d 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 62c1aa56e8..67e6a7c5b0 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 13fd066149..e6b6f4b7d7 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 04f96930f0..390bed826d 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 3518f07918..e50878ce2d 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 c63e098596..bb6c8c6d69 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 2ae916431e..9e556305ec 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 20/48] 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 b63ff17e9d..5371e2ada1 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 67e6a7c5b0..1148390fd4 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 a44074e16b..84565ba4ac 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 390bed826d..58c2b69e6d 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 e50878ce2d..8c8581087c 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 bb6c8c6d69..41c2194cb0 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 9e556305ec..e13c718923 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 dcf12cf5c6..2a8d3ec11c 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 21/48] 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 5371e2ada1..7940c35862 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 1148390fd4..9aba4f79f5 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 84565ba4ac..dd3bab622d 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 41c2194cb0..8967b88232 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 e13c718923..03992751e2 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 22/48] 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 7940c35862..186427cd9b 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 541d63f805..056ac95183 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 54aa965f9b..923be522d7 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 23/48] 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 0000000000..d4d889867e --- /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 0000000000..a337674ca4 --- /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 6a30ce673b..4e47f078fa 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 0000000000..eb79fde336 --- /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 24/48] 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 9e26e9aa51..b7339e2823 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 e6b6f4b7d7..7ff865bcd4 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 c6e79e78d6..2eb91bba71 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 056ac95183..163fad9d7d 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 923be522d7..8efa0bf8da 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 0000000000..10c04d36fd --- /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 8967b88232..95c5a8f7c7 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 03992751e2..9ce8418371 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 2a8d3ec11c..49bce137d9 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 421870d88239d5f2aa1ad1741794d9e59ad4c43c Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 18:14:46 -0700 Subject: [PATCH 25/48] 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 d4d889867e..1045cdee96 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 4da166327e..ed2ab8f853 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 26/48] 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 ed2ab8f853..b91110698a 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 27/48] 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 b91110698a..bccf553e04 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 4b4bc3aaeccbfda7b5d832c6a586b43d60dedaaa Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 19:36:43 -0700 Subject: [PATCH 28/48] 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 9aba4f79f5..1d31ef3450 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 dd3bab622d..6c5419316c 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 29/48] 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 31ff5a720f..acf02e3764 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 7b3cb50828..7e3a40b846 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 77edad530f..61a1584aa9 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 9fe2ebb3cb..695a69c1c4 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 8ddaf6be9a..8ce19f522e 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 418b466756..23557696b3 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 76db1d88c9..5c2edcab64 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 9179b0656f..d7bbee371c 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 ec71258b41..eb4111bd24 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 a1f7955a72..f5e53141b5 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 59db40e053..2cfce1d7be 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 5014f6b5fd..b1060de990 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 999e7f404b..e26770630a 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 b51372589f..4dc73f3599 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 09f9a508dd..e20f02e608 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 5f2137aeed..2deaaf650c 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 aa984fcfc7..89ddc62932 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 c76e004e1d..79a029c6a6 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 ea3390124b..9375627e1b 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 903728bbfc..0000000000 --- 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 e13e8ff1a2..405edfee51 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 04d105225c..8dd6bd7b42 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 36b689d23f..e5fe470a80 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 dc8078de73..6dedb4a04d 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 95f98331a7..9f42226af4 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 7dedda7040..02162dd6e2 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 152b027e23..d079cdf2cf 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 44a55bc173..2e17d4e52a 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 1f19264b8b..af7b852c31 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 30/48] 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 58c2b69e6d..5030efa98c 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 69da42835d..8160354a1c 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 741fd214d8badb3e23a13f68340e65c9641e501e Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 21:16:55 -0700 Subject: [PATCH 31/48] 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 8492c9b26c..e09fe937c1 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 6c5419316c..dd52301d5a 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 9df3403f79..6bbf5a1a4f 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 c717d3498520d1ab489a9ac284c082be1be2d60d Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 21:19:58 -0700 Subject: [PATCH 32/48] 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 42901107a6..cfd0b73293 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 33/48] 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 6bbf5a1a4f..535b4d5944 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 34/48] 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 535b4d5944..2dbf5f55e8 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 46a3b3cbba6489e7970dbc59b335e7dd092b225a Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 22:03:32 -0700 Subject: [PATCH 35/48] 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 e09fe937c1..93d458ee6f 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 36/48] 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 64e243feba..ba833dbd9e 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 6b0b14b899..e276d174f6 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 dd52301d5a..7429e4a725 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 37/48] 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 7429e4a725..26626caa80 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 38/48] 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 34d4eb90ee..9a54cb38f4 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 c0988a0b53..94626316bd 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 9cb27802dc..364ab6a509 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 0000000000..fc914faf87 --- /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 39/48] 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 5030efa98c..bdce01f169 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 cbca4fdaf5..5304ad5c3c 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 40/48] 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 bdce01f169..516fabf5f2 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 5304ad5c3c..871b2056df 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 41/48] 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 ba833dbd9e..6a340beeaf 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 68bf4dd0b0..1f609875d9 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 42/48] 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 6a340beeaf..b0c6879d25 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 1f609875d9..bb14af9eb6 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 2426b2a943..7d7f287932 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 ef4a245c7b..74a62127fb 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 43/48] 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 52c32e5866..b4bd04151f 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 44/48] 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 7e3a40b846..c5cd825a7b 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 fcbe95b79c..7565c9209d 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 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 45/48] 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 b0c6879d25..0269af5322 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 93d458ee6f..9f1bb750a1 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 46/48] 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 9f1bb750a1..25f8cdc83d 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 3e206c9fb2..365e5d7a1f 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 cfd0b73293..b6afbc1bf4 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 2dbf5f55e8..7d27eb6e05 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 0000000000..6ca1e5eb3d --- /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 be240bfbe8..eac341d88d 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 563bfe671d..b95b6a0a07 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 53206e8219..443e9e9f80 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 2d1e44a2a7..0000000000 --- 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 8be1e23fba..ea95d8dc0a 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 87de3b009c..0be967a793 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 47/48] 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 fe100dd458..8a2fb95cbd 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 48/48] 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 0269af5322..29ec32ecec 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 25f8cdc83d..5efd611058 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 b4bd04151f..b0a9479658 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); - }); });