From 2590dfc0cb4160728e56bffb4db527f7351123b1 Mon Sep 17 00:00:00 2001 From: Michael Yong <610102+ymichael@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:36:57 -0700 Subject: [PATCH 001/232] =?UTF-8?q?Make=20=E2=80=98This=20machine=E2=80=99?= =?UTF-8?q?=20mean=20the=20client-local=20daemon=20(#2004)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What was wrong The UI reused the server-resolved primary host as the meaning of “This machine” in Machines and Updates. On remote and multi-host clients, that could label an execution default or server host as the device running the client. The primary-host fallback could also leak into removal policy. ## What changed - Drive “This machine” only from the daemon reachable on the client device, and suppress the badge when only one host is known. - Keep authoritative primary-host policy and primary markers separate from client-local identity. - Avoid promoting the first-connected fallback into primary-host removal policy. - On mobile, use “Primary” only where multi-host disambiguation is useful and never claim the phone is a bb machine. - Clarify nearby copy that means the selected or primary machine. No server/daemon wire contract changed, so the protocol version is unchanged. ## How you verified - `pnpm exec turbo run test --filter=@bb/app -- src/components/settings/MachinesSettingsSection.test.tsx src/views/MachineSettingsView.test.tsx src/components/settings/UpdatesSettingsSection.test.tsx` (48 tests passed) - `pnpm exec turbo run typecheck --filter=@bb/app --filter=@bb/mobile` - `pnpm exec turbo run test --filter=@bb/app --filter=@bb/mobile` (3,858 tests passed, 3 skipped) - `pnpm exec turbo run lint --filter=@bb/app --filter=@bb/mobile` (0 errors; existing warnings only) - `git diff --check` Fixes: N/A (no linked issue) > AGENT GENERATED: by GPT-5 --- apps/app/.ladle/settings-story-fixtures.tsx | 1 + .../settings/InstallCliSkillsDialog.tsx | 2 +- .../settings/MachinesSettingsSection.test.tsx | 78 ++++++++++++++++++- .../settings/MachinesSettingsSection.tsx | 38 ++++++--- .../UpdatesSettingsSection.stories.tsx | 15 +++- .../settings/UpdatesSettingsSection.test.tsx | 54 ++++++++++++- .../settings/UpdatesSettingsSection.tsx | 14 +++- .../app/src/hooks/useThreadCreationOptions.ts | 2 +- .../src/views/MachineSettingsView.test.tsx | 56 ++++++++++++- apps/app/src/views/MachineSettingsView.tsx | 35 ++++++--- .../src/data/compose/execution-options.ts | 2 +- apps/mobile/src/data/hosts/host-display.ts | 4 +- .../src/data/updates/use-update-inventory.ts | 5 +- .../screens/extensions/SkillDetailScreen.tsx | 2 +- .../screens/machines/MachineDetailScreen.tsx | 13 ++-- .../src/screens/machines/MachinesScreen.tsx | 10 +-- .../src/screens/machines/ProviderCliRows.tsx | 2 +- .../src/screens/pickers/EnvironmentPicker.tsx | 2 +- .../mobile/src/screens/pickers/HostPicker.tsx | 2 +- .../mobile/src/screens/pickers/PathPicker.tsx | 4 +- .../src/screens/pickers/ProviderPicker.tsx | 2 +- .../src/screens/projects/NewProjectScreen.tsx | 2 +- .../projects/ProjectSettingsScreen.tsx | 2 +- .../src/screens/settings/UpdatesScreen.tsx | 9 ++- 24 files changed, 282 insertions(+), 74 deletions(-) diff --git a/apps/app/.ladle/settings-story-fixtures.tsx b/apps/app/.ladle/settings-story-fixtures.tsx index f74a9ae8a0..ee28765403 100644 --- a/apps/app/.ladle/settings-story-fixtures.tsx +++ b/apps/app/.ladle/settings-story-fixtures.tsx @@ -179,6 +179,7 @@ export function SettingsUpdatesStory() {
{choosable ? "Choose the machines to install them onto. Each one gets the skills in ~/.agents/skills and ~/.claude/skills, replacing any copy already there." - : `The skills go in ~/.agents/skills and ~/.claude/skills on ${hosts[0]?.name ?? "this machine"}, replacing any copy already there.`} + : `The skills go in ~/.agents/skills and ~/.claude/skills on ${hosts[0]?.name ?? "the selected machine"}, replacing any copy already there.`} diff --git a/apps/app/src/components/settings/MachinesSettingsSection.test.tsx b/apps/app/src/components/settings/MachinesSettingsSection.test.tsx index 22edc25007..172841bb68 100644 --- a/apps/app/src/components/settings/MachinesSettingsSection.test.tsx +++ b/apps/app/src/components/settings/MachinesSettingsSection.test.tsx @@ -17,7 +17,7 @@ import { } from "@bb/domain"; import type { SystemConfigResponse } from "@bb/server-contract"; import { MemoryRouter } from "react-router-dom"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { sdk } from "@/lib/sdk"; import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; import { MachinesSettingsSection } from "./MachinesSettingsSection"; @@ -38,6 +38,18 @@ vi.mock("@/lib/ws", () => ({ wsManager: { subscribe: vi.fn(), unsubscribe: vi.fn() }, })); +const hostDaemon = vi.hoisted(() => ({ + localDaemonHostId: "host_primary" as string | null, + platform: "darwin" as "darwin" | "linux" | "wsl" | "unknown" | null, +})); + +vi.mock("@/hooks/useHostDaemon", () => ({ + useHostDaemon: () => ({ + localDaemonHostId: hostDaemon.localDaemonHostId, + platform: hostDaemon.platform, + }), +})); + const NOW = Date.now(); function host(overrides: Partial & Pick): Host { @@ -127,6 +139,11 @@ async function openHostMenu(hostName: string): Promise { ); } +beforeEach(() => { + hostDaemon.localDaemonHostId = "host_primary"; + hostDaemon.platform = "darwin"; +}); + afterEach(() => { cleanup(); vi.unstubAllGlobals(); @@ -144,6 +161,7 @@ describe("MachinesSettingsSection", () => { expect(await screen.findByText("MacBook Pro")).toBeDefined(); expect(screen.getByText("dev-vm")).toBeDefined(); expect(screen.getByText("this machine")).toBeDefined(); + expect(screen.getByText("primary")).toBeDefined(); await waitFor(() => { expect(screen.getByRole("img", { name: "Online" })).toBeDefined(); }); @@ -167,6 +185,62 @@ describe("MachinesSettingsSection", () => { expect(screen.queryByText(/Online ·/u)).toBeNull(); }); + it("distinguishes the client-local daemon from the primary machine", async () => { + hostDaemon.localDaemonHostId = "host_remote"; + hostDaemon.platform = "linux"; + vi.mocked(sdk.system.config).mockResolvedValue(systemConfig()); + vi.mocked(sdk.hosts.list).mockResolvedValue([primaryHost, offlineHost]); + stubSidebarBootstrapFetch(); + + renderSection(); + + const primaryName = await screen.findByText("MacBook Pro"); + const localName = screen.getByText("dev-vm"); + expect(primaryName.parentElement?.textContent).toContain("primary"); + expect(primaryName.parentElement?.textContent).not.toContain( + "this machine", + ); + expect(localName.parentElement?.textContent).toContain("this machine"); + expect(localName.parentElement?.textContent).not.toContain("primary"); + expect(screen.getByText("Linux")).toBeDefined(); + }); + + it("does not infer client-local identity when no daemon is reachable", async () => { + hostDaemon.localDaemonHostId = null; + hostDaemon.platform = null; + vi.mocked(sdk.system.config).mockResolvedValue(systemConfig()); + vi.mocked(sdk.hosts.list).mockResolvedValue([primaryHost, offlineHost]); + stubSidebarBootstrapFetch(); + + renderSection(); + + await screen.findByText("MacBook Pro"); + expect(screen.queryByText("this machine")).toBeNull(); + expect(screen.getByText("primary")).toBeDefined(); + }); + + it("does not promote a fallback host to primary policy", async () => { + hostDaemon.localDaemonHostId = null; + vi.mocked(sdk.system.config).mockResolvedValue({ + ...systemConfig(), + primaryHostId: null, + primaryHostPlatform: null, + }); + vi.mocked(sdk.hosts.list).mockResolvedValue([primaryHost, offlineHost]); + stubSidebarBootstrapFetch(); + + renderSection(); + + await screen.findByText("MacBook Pro"); + expect(screen.queryByText("primary")).toBeNull(); + await openHostMenu("MacBook Pro"); + expect( + screen + .getByRole("menuitem", { name: "Remove machine" }) + .getAttribute("aria-disabled"), + ).toBeNull(); + }); + it("shows protocol versions when a machine needs an update", async () => { vi.mocked(sdk.system.config).mockResolvedValue(systemConfig()); vi.mocked(sdk.hosts.list).mockResolvedValue([ @@ -412,7 +486,7 @@ describe("MachinesSettingsSection", () => { fireEvent.focus(removeItem); expect( await screen.findByRole("tooltip", { - name: "This machine runs bb and can't be removed.", + name: "bb's primary machine can't be removed.", }), ).toBeDefined(); fireEvent.click(removeItem); diff --git a/apps/app/src/components/settings/MachinesSettingsSection.tsx b/apps/app/src/components/settings/MachinesSettingsSection.tsx index 0e28f6816f..70f42e1612 100644 --- a/apps/app/src/components/settings/MachinesSettingsSection.tsx +++ b/apps/app/src/components/settings/MachinesSettingsSection.tsx @@ -41,9 +41,10 @@ import { useRenameHost, useRetryHostUpdate, } from "@/hooks/mutations/host-mutations"; -import { selectPrimaryHost, useHosts } from "@/hooks/queries/host-queries"; +import { useHosts } from "@/hooks/queries/host-queries"; import { useSidebarNavigation } from "@/hooks/queries/sidebar-navigation-query"; import { useSystemConfig } from "@/hooks/queries/system-queries"; +import { useHostDaemon } from "@/hooks/useHostDaemon"; import { PersistentHostIconName } from "@/lib/host-display"; import { getSettingsMachineRoutePath } from "@/lib/route-paths"; import { PERMISSION_MODE_OPTIONS } from "@/lib/permission-mode-options"; @@ -64,8 +65,7 @@ const PERMISSION_MODE_PRESENTATION: Record< const MACHINES_SECTION_DESCRIPTION = "Computers that can run your tasks. Pair a machine to run projects and threads on it."; -const PRIMARY_REMOVE_DISABLED_REASON = - "This machine runs bb and can't be removed."; +const PRIMARY_REMOVE_DISABLED_REASON = "bb's primary machine can't be removed."; const MACHINE_MENU_ITEM_CLASS = "min-h-9 px-2.5 py-2"; @@ -112,6 +112,8 @@ function MachineMetadataIcon({ interface MachineRowProps { host: Host; isPrimary: boolean; + isThisMachine: boolean; + showPrimaryBadge: boolean; platformLabel: string | null; projectCount: number; now: number; @@ -124,6 +126,8 @@ interface MachineRowProps { function MachineRow({ host, isPrimary, + isThisMachine, + showPrimaryBadge, platformLabel, projectCount, now, @@ -180,7 +184,10 @@ function MachineRow({ {host.name} - {isPrimary ? this machine : null} + {isThisMachine ? ( + this machine + ) : null} + {showPrimaryBadge ? primary : null}
selectPrimaryHost(hosts, serverPrimaryHostId)?.id ?? null, - [hosts, serverPrimaryHostId], - ); const projects = sidebarNavigationQuery.data?.projects; const projectCountByHostId = useMemo(() => { const counts = new Map(); @@ -303,6 +307,7 @@ export function MachinesSettingsSection() { const now = Date.now(); const primaryHostPlatform = systemConfig.data?.primaryHostPlatform ?? null; + const showMachineIdentityBadges = (hosts?.length ?? 0) > 1; return ( <> @@ -338,11 +343,20 @@ export function MachinesSettingsSection() { + {app ? ( {children} + + {children} + ); } @@ -473,7 +479,10 @@ export function UpdateStates() { name="Installing" note="The provider update is currently running." > - + ({ useDesktopUpdateInfo: vi.fn(), })); +const hostDaemon = vi.hoisted(() => ({ + localDaemonHostId: null as string | null, +})); + +vi.mock("@/hooks/useHostDaemon", () => ({ + useHostDaemon: () => ({ + localDaemonHostId: hostDaemon.localDaemonHostId, + }), +})); + const openUrlInExternalBrowserMock = vi.hoisted(() => vi.fn()); vi.mock("@/lib/url-open-routing", () => ({ @@ -264,6 +274,7 @@ const useDesktopUpdateInfoMock = vi.mocked(useDesktopUpdateInfo); const useProviderCliInstallRunnerMock = vi.mocked(useProviderCliInstallRunner); beforeEach(() => { + hostDaemon.localDaemonHostId = null; vi.stubGlobal( "fetch", vi.fn().mockRejectedValue(new Error("Changelog unavailable offline")), @@ -431,7 +442,8 @@ The canonical release summary. }); expect(screen.queryByText("2 up to date")).toBeNull(); expect(screen.getByRole("heading", { name: /workstation/ })).toBeDefined(); - expect(screen.getByText("This machine")).toBeDefined(); + expect(screen.queryByText("Primary")).toBeNull(); + expect(screen.queryByText("This machine")).toBeNull(); expect(screen.queryByText("studio-mac")).toBeNull(); expect(screen.queryByText(/Checked/)).toBeNull(); expect(screen.queryByText(/ago$/)).toBeNull(); @@ -935,9 +947,8 @@ The canonical release summary. expect(machineHeading.className).toContain("font-semibold"); expect(machineHeading.className).toContain("text-foreground"); const machineName = screen.getByText("workstation"); - const thisMachine = screen.getByText("This machine"); expect(machineHeading.querySelector('[data-icon="Laptop"]')).not.toBeNull(); - expect(machineName.nextElementSibling).toBe(thisMachine); + expect(machineName.nextElementSibling).toBeNull(); // No summary banner above the rows: with work outstanding the rows are // the statement, and with none the settled card is the only thing shown. expect(screen.getByText("bb app")).toBeDefined(); @@ -983,6 +994,43 @@ The canonical release summary. expect(screen.queryByText("1 up to date")).toBeNull(); }); + it("badges the client-local daemon independently from the primary update owner", () => { + useDesktopUpdateInfoMock.mockReturnValue({ + desktopApi: null, + desktopInfo: null, + isDesktop: false, + }); + const primary = makeHost({ id: "host_primary", name: "workstation" }); + const local = makeHost({ id: "host_local", name: "studio-mac" }); + hostDaemon.localDaemonHostId = local.id; + useUpdateInventoryMock.mockReturnValue( + makeInventory({ + machines: [ + makeMachine({ + host: primary, + issues: [makeUpdateIssue({ provider: "codex" })], + isPrimary: true, + }), + makeMachine({ + host: local, + issues: [makeUpdateIssue({ provider: "claudeCode" })], + }), + ], + }), + ); + + renderSection(); + + const primaryHeading = screen.getByRole("heading", { + name: /workstation/u, + }); + const localHeading = screen.getByRole("heading", { name: /studio-mac/u }); + expect(primaryHeading.textContent).not.toContain("Primary"); + expect(primaryHeading.textContent).not.toContain("This machine"); + expect(localHeading.textContent).toContain("This machine"); + expect(localHeading.textContent).not.toContain("Primary"); + }); + it("lists Cursor updates with the other provider CLIs", () => { useDesktopUpdateInfoMock.mockReturnValue({ desktopApi: null, diff --git a/apps/app/src/components/settings/UpdatesSettingsSection.tsx b/apps/app/src/components/settings/UpdatesSettingsSection.tsx index 42794e1e61..8f9e33db8b 100644 --- a/apps/app/src/components/settings/UpdatesSettingsSection.tsx +++ b/apps/app/src/components/settings/UpdatesSettingsSection.tsx @@ -70,6 +70,7 @@ import { useUpdateInventory, type UpdateInventoryMachine, } from "@/hooks/useUpdateInventory"; +import { useHostDaemon } from "@/hooks/useHostDaemon"; import { useDesktopUpdateInfo } from "@/hooks/useDesktopUpdateInfo"; import { copyToClipboardWithToast } from "@/lib/clipboard"; import { @@ -1360,10 +1361,12 @@ export function MachineUpdatesRows({ /** One machine owns one settings section; the badge makes local scope explicit. */ export function MachineUpdatesSection({ machine, + isThisMachine, action, children, }: { machine: UpdateInventoryMachine; + isThisMachine: boolean; action?: ReactNode; children: ReactNode; }) { @@ -1379,9 +1382,7 @@ export function MachineUpdatesSection({ aria-hidden /> {machine.host.name} - {machine.isPrimary ? ( - This machine - ) : null} + {isThisMachine ? This machine : null} } action={ @@ -1421,6 +1422,7 @@ export function UpdatesSettingsSection({ const queryClient = useQueryClient(); const navigate = useNavigate(); const inventory = useUpdateInventory(); + const { localDaemonHostId } = useHostDaemon(); const { desktopApi, desktopInfo, isDesktop } = useDesktopUpdateInfo(); const retryHostUpdate = useRetryHostUpdate(); // The check store outlives this view, so an in-flight check stays visible @@ -1535,7 +1537,7 @@ export function UpdatesSettingsSection({ (candidate) => candidate.host.id === hostId, ); appToast.success( - `Retrying the update on ${machine?.host.name ?? "this machine"}`, + `Retrying the update on ${machine?.host.name ?? "the requested machine"}`, ); }, }); @@ -1608,6 +1610,10 @@ export function UpdatesSettingsSection({ 1 && + machine.host.id === localDaemonHostId + } action={index === 0 ? bulkActions : null} > {ownsApp ? ( diff --git a/apps/app/src/hooks/useThreadCreationOptions.ts b/apps/app/src/hooks/useThreadCreationOptions.ts index 34b6afcfcd..f547ebd87c 100644 --- a/apps/app/src/hooks/useThreadCreationOptions.ts +++ b/apps/app/src/hooks/useThreadCreationOptions.ts @@ -64,7 +64,7 @@ const EMPTY_COMPOSER_ACTIONS: ProviderComposerAction[] = []; const DEFAULT_SUPPORTED_PERMISSION_MODES: readonly PermissionMode[] = ["full"]; const PERMISSION_CEILING_REASON = - "Above this machine's permission limit. Change it in Settings → Machines."; + "Above the selected machine's permission limit. Change it in Settings → Machines."; type StringSelectionSetter = (value: string) => void; type ServiceTierSelectionSetter = (value: ServiceTier | undefined) => void; diff --git a/apps/app/src/views/MachineSettingsView.test.tsx b/apps/app/src/views/MachineSettingsView.test.tsx index 799061cd98..a80384e69d 100644 --- a/apps/app/src/views/MachineSettingsView.test.tsx +++ b/apps/app/src/views/MachineSettingsView.test.tsx @@ -20,7 +20,7 @@ import type { ProviderCliStatusResponse, } from "@bb/host-daemon-contract"; import { MemoryRouter, Route, Routes } from "react-router-dom"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { sdk } from "@/lib/sdk"; import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; import { MachineSettingsView } from "./MachineSettingsView"; @@ -42,6 +42,18 @@ vi.mock("@/lib/ws", () => ({ wsManager: { subscribe: vi.fn(), unsubscribe: vi.fn() }, })); +const hostDaemon = vi.hoisted(() => ({ + localDaemonHostId: null as string | null, + platform: null as "darwin" | "linux" | "wsl" | "unknown" | null, +})); + +vi.mock("@/hooks/useHostDaemon", () => ({ + useHostDaemon: () => ({ + localDaemonHostId: hostDaemon.localDaemonHostId, + platform: hostDaemon.platform, + }), +})); + const HOST_ID = "host_remote"; function host(overrides: Partial = {}): Host { @@ -143,6 +155,11 @@ function stubSupportingFetches(): void { ); } +beforeEach(() => { + hostDaemon.localDaemonHostId = null; + hostDaemon.platform = null; +}); + afterEach(() => { cleanup(); vi.unstubAllGlobals(); @@ -326,7 +343,40 @@ describe("MachineSettingsView", () => { expect(remove.hasAttribute("disabled")).toBe(true); expect(remove.className).toContain("bg-destructive"); expect(remove.parentElement?.className).not.toContain("justify-end"); - expect(screen.getAllByText("This machine")).toHaveLength(1); + expect(screen.queryByText("This machine")).toBeNull(); + expect(screen.queryByText("Primary")).toBeNull(); + expect( + screen.getByText("bb's primary machine can't be removed."), + ).toBeDefined(); + }); + + it("shows client-local identity only when several machines need disambiguation", async () => { + hostDaemon.localDaemonHostId = HOST_ID; + hostDaemon.platform = "linux"; + vi.mocked(sdk.system.config).mockResolvedValue(systemConfig()); + vi.mocked(sdk.hosts.list).mockResolvedValue([ + host(), + host({ id: "host_primary", name: "workstation" }), + ]); + stubSupportingFetches(); + + renderView(); + + expect(await screen.findByText("This machine")).toBeDefined(); + expect(screen.queryByText("Primary")).toBeNull(); + expect(screen.getByText(/Linux/u)).toBeDefined(); + }); + + it("suppresses the client-local badge when there is only one machine", async () => { + hostDaemon.localDaemonHostId = HOST_ID; + vi.mocked(sdk.system.config).mockResolvedValue(systemConfig()); + vi.mocked(sdk.hosts.list).mockResolvedValue([host()]); + stubSupportingFetches(); + + renderView(); + + await screen.findByRole("heading", { name: /dev-vm/u }); + expect(screen.queryByText("This machine")).toBeNull(); }); it("explains a machine that is no longer paired", async () => { @@ -337,7 +387,7 @@ describe("MachineSettingsView", () => { renderView(); expect( - await screen.findByText("This machine is no longer paired."), + await screen.findByText("Machine is no longer paired."), ).toBeDefined(); }); }); diff --git a/apps/app/src/views/MachineSettingsView.tsx b/apps/app/src/views/MachineSettingsView.tsx index a372957c41..9dfa27efe9 100644 --- a/apps/app/src/views/MachineSettingsView.tsx +++ b/apps/app/src/views/MachineSettingsView.tsx @@ -35,11 +35,12 @@ import { useRetryHostUpdate, useUpdateHostPermissionCeiling, } from "@/hooks/mutations/host-mutations"; -import { selectPrimaryHost, useHosts } from "@/hooks/queries/host-queries"; +import { useHosts } from "@/hooks/queries/host-queries"; import { useSidebarNavigation } from "@/hooks/queries/sidebar-navigation-query"; import { useSystemConfig } from "@/hooks/queries/system-queries"; import { isProviderCliUpdateIssue } from "@/components/provider-cli/provider-cli-install"; import { useUpdateInventory } from "@/hooks/useUpdateInventory"; +import { useHostDaemon } from "@/hooks/useHostDaemon"; import { formatHostUpdateStatus, hostCanRetryUpdate, @@ -57,11 +58,10 @@ import { getSettingsRoutePath, } from "@/lib/route-paths"; -const PRIMARY_REMOVE_DISABLED_REASON = - "This machine runs bb and can't be removed."; +const PRIMARY_REMOVE_DISABLED_REASON = "bb's primary machine can't be removed."; const PERMISSION_LIMIT_DESCRIPTION = - "Highest permission mode any thread on this machine may run with. Threads that ask for more resolve down to it, and a provider that supports nothing this low can't run here."; + "Highest permission mode any thread on the selected machine may run with. Threads that ask for more resolve down to it, and a provider that supports nothing this low can't run here."; const PLATFORM_LABELS: Record = { darwin: "macOS", @@ -203,6 +203,7 @@ export function MachineSettingsView() { const navigate = useNavigate(); const hostsQuery = useHosts(); const systemConfig = useSystemConfig(); + const { localDaemonHostId, platform: localDaemonPlatform } = useHostDaemon(); const sidebarNavigationQuery = useSidebarNavigation(); const updateInventory = useUpdateInventory(); const renameHost = useRenameHost(); @@ -214,10 +215,11 @@ export function MachineSettingsView() { const hosts = hostsQuery.data; const host = hosts?.find((candidate) => candidate.id === hostId) ?? null; - const primaryHostId = - selectPrimaryHost(hosts, systemConfig.data?.primaryHostId ?? null)?.id ?? - null; + const primaryHostId = systemConfig.data?.primaryHostId ?? null; const isPrimary = host !== null && host.id === primaryHostId; + const showMachineIdentityBadges = (hosts?.length ?? 0) > 1; + const isThisMachine = + showMachineIdentityBadges && host !== null && host.id === localDaemonHostId; const projects: MachineProject[] = useMemo(() => { const navigation = sidebarNavigationQuery.data?.projects ?? []; @@ -256,9 +258,13 @@ export function MachineSettingsView() { const now = Date.now(); const platformLabel = - isPrimary && systemConfig.data?.primaryHostPlatform - ? PLATFORM_LABELS[systemConfig.data.primaryHostPlatform] - : null; + host !== null && + host.id === localDaemonHostId && + localDaemonPlatform !== null + ? PLATFORM_LABELS[localDaemonPlatform] + : isPrimary && systemConfig.data?.primaryHostPlatform + ? PLATFORM_LABELS[systemConfig.data.primaryHostPlatform] + : null; if (hosts === undefined) { return ( @@ -282,7 +288,7 @@ export function MachineSettingsView() { Machines

- This machine is no longer paired. + Machine is no longer paired.

@@ -311,7 +317,12 @@ export function MachineSettingsView() { aria-hidden /> {host.name} - {isPrimary ? This machine : null} + {isThisMachine ? ( + This machine + ) : null} + {showMachineIdentityBadges && isPrimary ? ( + Primary + ) : null} } titleAction={ diff --git a/apps/mobile/src/data/compose/execution-options.ts b/apps/mobile/src/data/compose/execution-options.ts index c5724f3ddc..e4bbb5c2cd 100644 --- a/apps/mobile/src/data/compose/execution-options.ts +++ b/apps/mobile/src/data/compose/execution-options.ts @@ -61,7 +61,7 @@ export const REASONING_LABELS: Record = { }; export const PERMISSION_CEILING_REASON = - "Above this machine's permission limit. Change it in Settings → Machines."; + "Above the selected machine's permission limit. Change it in Settings → Machines."; const DEFAULT_SUPPORTED_PERMISSION_MODES: readonly PermissionMode[] = ["full"]; diff --git a/apps/mobile/src/data/hosts/host-display.ts b/apps/mobile/src/data/hosts/host-display.ts index 6222016770..f40077c013 100644 --- a/apps/mobile/src/data/hosts/host-display.ts +++ b/apps/mobile/src/data/hosts/host-display.ts @@ -17,13 +17,13 @@ export const PERMISSION_MODE_SHORT_LABELS: Record = { }; export const PRIMARY_HOST_REMOVE_DISABLED_REASON = - "This machine runs bb and can't be removed."; + "bb's primary machine can't be removed."; export const MACHINES_SECTION_DESCRIPTION = "Computers that can run your tasks. Pair a machine to run projects and threads on it."; export const PERMISSION_LIMIT_DESCRIPTION = - "Highest permission mode any thread on this machine may run with. Threads that ask for more resolve down to it, and a provider that supports nothing this low can't run here."; + "Highest permission mode any thread on the selected machine may run with. Threads that ask for more resolve down to it, and a provider that supports nothing this low can't run here."; const MINUTE_MS = 60_000; const HOUR_MS = 60 * MINUTE_MS; diff --git a/apps/mobile/src/data/updates/use-update-inventory.ts b/apps/mobile/src/data/updates/use-update-inventory.ts index 967098832a..c5b6c24526 100644 --- a/apps/mobile/src/data/updates/use-update-inventory.ts +++ b/apps/mobile/src/data/updates/use-update-inventory.ts @@ -12,7 +12,6 @@ import { useHostsProviderCliStatus, useServerProtocolVersion, } from "../hosts/host-queries"; -import { selectPrimaryHost } from "../hosts/select-primary-host"; import { useSystemConfig, useSystemVersion } from "../system/system-queries"; import { buildUpdateInventory, @@ -48,9 +47,7 @@ export function useUpdateInventory( const providerStatuses = useHostsProviderCliStatus(connectedHostIds, { enabled, }); - const primaryHostId = - selectPrimaryHost(hosts, configQuery.data?.primaryHostId ?? null)?.id ?? - null; + const primaryHostId = configQuery.data?.primaryHostId ?? null; const inventory = useMemo( () => buildUpdateInventory({ diff --git a/apps/mobile/src/screens/extensions/SkillDetailScreen.tsx b/apps/mobile/src/screens/extensions/SkillDetailScreen.tsx index cd2430f839..4b42cd4d2b 100644 --- a/apps/mobile/src/screens/extensions/SkillDetailScreen.tsx +++ b/apps/mobile/src/screens/extensions/SkillDetailScreen.tsx @@ -208,7 +208,7 @@ export function SkillDetailScreen() { {isSkillDeletable(skill) ? ( candidate.id === hostId) ?? null; - const primaryHostId = - selectPrimaryHost(hosts, configQuery.data?.primaryHostId ?? null)?.id ?? - null; + const primaryHostId = configQuery.data?.primaryHostId ?? null; const isPrimary = host !== null && host.id === primaryHostId; const online = host?.status === "connected"; @@ -131,7 +128,7 @@ function ConnectedMachineDetailScreen({ hostId }: { hostId: string }) { if (host === null) { return ( - This machine is no longer paired. + Machine is no longer paired. @@ -178,9 +175,9 @@ function ConnectedMachineDetailScreen({ hostId }: { hostId: string }) { > {host.name} - {isPrimary ? ( + {hosts.length > 1 && isPrimary ? ( - this machine + Primary ) : null} @@ -233,7 +230,7 @@ function ConnectedMachineDetailScreen({ hostId }: { hostId: string }) { /> - + {projects.length === 0 ? ( ) : ( diff --git a/apps/mobile/src/screens/machines/MachinesScreen.tsx b/apps/mobile/src/screens/machines/MachinesScreen.tsx index 860a9d5945..560e24f81d 100644 --- a/apps/mobile/src/screens/machines/MachinesScreen.tsx +++ b/apps/mobile/src/screens/machines/MachinesScreen.tsx @@ -11,7 +11,6 @@ import { machineMetaLine, PERMISSION_MODE_SHORT_LABELS, PRIMARY_HOST_REMOVE_DISABLED_REASON, - selectPrimaryHost, useHosts, useAddMachineSession, useRemoveHost, @@ -76,12 +75,7 @@ function ConnectedMachinesScreen() { const retryUpdate = useRetryHostUpdate(); const hosts = hostsQuery.data; - const primaryHostId = useMemo( - () => - selectPrimaryHost(hosts, configQuery.data?.primaryHostId ?? null)?.id ?? - null, - [hosts, configQuery.data?.primaryHostId], - ); + const primaryHostId = configQuery.data?.primaryHostId ?? null; const projectCounts = useMemo( () => countProjectsByHost(bootstrap.data?.projects ?? []), [bootstrap.data?.projects], @@ -139,7 +133,7 @@ function ConnectedMachinesScreen() { key={host.id} title={host.name} titleLines={1} - subtitle={`${isPrimary ? "This machine · " : ""}${machineMetaLine( + subtitle={`${hosts.length > 1 && isPrimary ? "Primary · " : ""}${machineMetaLine( { host, platformLabel: diff --git a/apps/mobile/src/screens/machines/ProviderCliRows.tsx b/apps/mobile/src/screens/machines/ProviderCliRows.tsx index 876bfb8921..b03d1b30dd 100644 --- a/apps/mobile/src/screens/machines/ProviderCliRows.tsx +++ b/apps/mobile/src/screens/machines/ProviderCliRows.tsx @@ -182,7 +182,7 @@ export function ProviderCliRows({ return ( - Couldn't check provider CLIs on this machine. + Couldn't check provider CLIs on {host.name}. ); diff --git a/apps/mobile/src/screens/pickers/EnvironmentPicker.tsx b/apps/mobile/src/screens/pickers/EnvironmentPicker.tsx index abfafa7967..dfd062a6de 100644 --- a/apps/mobile/src/screens/pickers/EnvironmentPicker.tsx +++ b/apps/mobile/src/screens/pickers/EnvironmentPicker.tsx @@ -142,7 +142,7 @@ export function EnvironmentPicker({ hostUnavailableReason ?? (isPersonalProject || hostHasSource ? null - : `${host?.name ?? "This machine"} has no checkout of this project`); + : `${host?.name ?? "The selected machine"} has no checkout of this project`); const worktreeReason = isPersonalProject ? "Personal threads have no repository" : (workspaceDisabledReason ?? worktreeDisabledReason); diff --git a/apps/mobile/src/screens/pickers/HostPicker.tsx b/apps/mobile/src/screens/pickers/HostPicker.tsx index ea28ece9a0..38bb6f9cb2 100644 --- a/apps/mobile/src/screens/pickers/HostPicker.tsx +++ b/apps/mobile/src/screens/pickers/HostPicker.tsx @@ -112,7 +112,7 @@ export function HostPicker({ ? onRequestSetup ? "Not set up for this project · tap to set up" : "Not set up for this project" - : isPrimary + : hosts.length > 1 && isPrimary ? "Primary machine" : undefined; return ( diff --git a/apps/mobile/src/screens/pickers/PathPicker.tsx b/apps/mobile/src/screens/pickers/PathPicker.tsx index 5a07212a0f..b43c3e2894 100644 --- a/apps/mobile/src/screens/pickers/PathPicker.tsx +++ b/apps/mobile/src/screens/pickers/PathPicker.tsx @@ -56,7 +56,9 @@ export function PathPicker({ > { diff --git a/apps/mobile/src/screens/pickers/ProviderPicker.tsx b/apps/mobile/src/screens/pickers/ProviderPicker.tsx index 51749671ad..caf118a961 100644 --- a/apps/mobile/src/screens/pickers/ProviderPicker.tsx +++ b/apps/mobile/src/screens/pickers/ProviderPicker.tsx @@ -49,7 +49,7 @@ export function ProviderPicker({ ), description: option.available ? undefined - : "Not available on this machine", + : "Not available on the selected machine", })), [options, tokens.foreground, tokens.subtleForeground], ); diff --git a/apps/mobile/src/screens/projects/NewProjectScreen.tsx b/apps/mobile/src/screens/projects/NewProjectScreen.tsx index e71dc85c8d..47e40636e6 100644 --- a/apps/mobile/src/screens/projects/NewProjectScreen.tsx +++ b/apps/mobile/src/screens/projects/NewProjectScreen.tsx @@ -122,7 +122,7 @@ function ConnectedNewProjectScreen() { subtitle={ host ? host.status === "connected" - ? host.id === primaryHost?.id + ? hosts.length > 1 && host.id === primaryHost?.id ? "Primary machine" : undefined : "Offline" diff --git a/apps/mobile/src/screens/projects/ProjectSettingsScreen.tsx b/apps/mobile/src/screens/projects/ProjectSettingsScreen.tsx index 2a69c3347f..9089351947 100644 --- a/apps/mobile/src/screens/projects/ProjectSettingsScreen.tsx +++ b/apps/mobile/src/screens/projects/ProjectSettingsScreen.tsx @@ -280,7 +280,7 @@ function ConnectedProjectSettingsScreen({ projectId }: { projectId: string }) { title="Remove this source?" message={ sourceForMenu - ? `bb stops using ${sourceForMenu.path} on ${hostById.get(sourceForMenu.hostId)?.name ?? "this machine"} for this project. The folder stays on disk.` + ? `bb stops using ${sourceForMenu.path} on ${hostById.get(sourceForMenu.hostId)?.name ?? "that machine"} for this project. The folder stays on disk.` : undefined } actions={[ diff --git a/apps/mobile/src/screens/settings/UpdatesScreen.tsx b/apps/mobile/src/screens/settings/UpdatesScreen.tsx index ee709c0bb4..1f34f9cef3 100644 --- a/apps/mobile/src/screens/settings/UpdatesScreen.tsx +++ b/apps/mobile/src/screens/settings/UpdatesScreen.tsx @@ -131,12 +131,14 @@ function BbAppRow({ function MachineUpdatesBlock({ machine, + showPrimaryBadge, serverProtocolVersion, runner, retryPending, onRetry, }: { machine: UpdateInventoryMachine; + showPrimaryBadge: boolean; serverProtocolVersion: number | null; runner: ReturnType; retryPending: boolean; @@ -152,9 +154,9 @@ function MachineUpdatesBlock({ {host.name} - {machine.isPrimary ? ( + {showPrimaryBadge ? ( - this machine + Primary ) : null} @@ -455,6 +457,9 @@ function ConnectedUpdatesScreen() { {index > 0 ? : null} 1 && machine.isPrimary + } serverProtocolVersion={inventory.serverProtocolVersion} runner={runner} retryPending={ From ba14b8164291b780b40a53d68276452e4e821166 Mon Sep 17 00:00:00 2001 From: Michael Yong <610102+ymichael@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:43:16 -0700 Subject: [PATCH 002/232] Delete the newOnboarding experiment and the first-run flow (#2001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What was wrong The first-run setup guide shipped behind the `newOnboarding` experiment, which defaults to false (`packages/domain/src/experiments.ts`) and was never enabled by default. Nobody saw the flow unless they turned the toggle on by hand, so its UI, its telemetry funnel, its persisted completion timestamp, and a dedicated host-daemon command existed to serve a surface that never ran — spread across seven packages. ## What changed Deleted the experiment and everything that existed only for it: - `OnboardingHost` / `OnboardingFlow` (828 lines) and their `App.tsx` mount. - The `newOnboarding` experiment key, its Settings → Experiments toggle (web and `apps/mobile`), and every config fixture that listed it. - `appSettingsSchema.onboardingCompletedAt`, the Settings → General "Setup guide" replay control, and `bb settings replay-onboarding`. No migration is needed: since #0102 app settings are key/value rows read through `inArray(key, appSettingsKeys)`, so the retired key's row is ignored. The legacy wide `app_settings` table keeps its column, which is deliberately frozen for `seedKeepAwakePluginConfiguration`. - The five onboarding funnel telemetry events, `POST /system/onboarding/event`, and `sdk.system.onboardingEvent`. - `GET /system/onboarding/repos`, `sdk.system.onboardingRepos`, and — since nothing else sent it — the `workspace.discover_repos` daemon command with its 460-line handler. **Wire change: `HOST_DAEMON_PROTOCOL_VERSION` 137 → 138**, because a command left the protocol. The reason is recorded in the `protocol.ts` header. Docs and agent surfaces updated in the same change: `bb-guide-customization.md` (regenerated), the `bb-cli` SKILL and its `app-settings` reference, `bb-plugin-authoring`'s SDK method table, and `docs/configuration.md`. `getOnboardingAgentOverview` and `GET /system/onboarding/agents` deliberately stay. Despite the name they are not onboarding-only: the root composer resolves an unset provider selection through `useOnboardingAgents` (`useThreadCreationOptions.ts` ← `RootComposeView.tsx`), which ships unconditionally. Renaming that endpoint and its SDK method to match its real purpose is a follow-up, not part of a deletion. `useOnboardingAgents` does lose its `poll` option, which became constant once the flow's caller went away. Two test adjustments worth calling out, both because their subject was deleted rather than because they broke: - `apps/cli` had a test using `onboardingCompletedAt` as its nullable-string case for `bb settings general`. `appSettingsSchema` now holds only booleans, so that path has no live key to exercise; the test is trimmed to the unknown-key assertion it also covered. The generic value-parsing code in `updateGeneralSetting` is untouched and still accepts `null`. - `packages/db`'s key/value migration test no longer asserts a settings field that no longer exists. ## How you verified Nothing here is a behavior fix, so the evidence is that removing a never-enabled surface changes nothing else: - `pnpm exec turbo run typecheck` — 72/72 tasks green. - `pnpm exec turbo run test` for `@bb/app` (3037), `@bb/server` (1787), `@bb/mobile` (812), `@bb/host-daemon` (47 files), plus `@bb/db`, `@bb/domain`, `@bb/sdk`, `@bb/cli`, `@bb/desktop`, `@bb/server-contract`, `@bb/host-daemon-contract`, `@bb/templates`, `@get-bb/plugin-sdk` — all green. - `pnpm exec turbo run lint` — 0 errors (144 pre-existing react-compiler warnings in `@bb/app`, unchanged). - `packages/host-daemon-contract`'s protocol-version test pins 138, and its command-fixture map no longer accepts `workspace.discover_repos`, so a stray sender fails the contract test. - Repo-wide grep for `newOnboarding`, `onboardingCompletedAt`, `replay-onboarding`, `onboardingRepos`, `onboardingEvent`, and `discover_repos` returns only the `protocol.ts` changelog note. No CHANGELOG entry: per `docs/bb-release-process.md` those sections are written at release-prep time, and this removes nothing a user could see. Fixes: N/A — no linked issue. > AGENT GENERATED: by Claude Opus 5 Co-authored-by: Claude Opus 5 (1M context) --- apps/app/src/App.tsx | 4 - .../AppLayout.plugin-panel-header.test.tsx | 1 - .../AppLayout.root-compose-project.test.tsx | 1 - .../layout/AppLayout.sidebar-resize.test.tsx | 1 - .../components/onboarding/OnboardingFlow.tsx | 615 ------------------ .../onboarding/OnboardingHost.test.tsx | 99 --- .../components/onboarding/OnboardingHost.tsx | 213 ------ apps/app/src/hooks/queries/query-keys.ts | 8 - .../src/hooks/queries/system-queries.test.tsx | 11 +- apps/app/src/hooks/queries/system-queries.ts | 28 +- .../app/src/hooks/useLocalPathPicker.test.tsx | 4 +- .../app/src/hooks/useThreadCreationOptions.ts | 1 - apps/app/src/lib/system-config-atoms.ts | 1 - .../views/SettingsView.experiments.test.tsx | 12 - apps/app/src/views/SettingsView.stories.tsx | 9 - apps/app/src/views/SettingsView.tsx | 63 -- .../__tests__/command-output/settings.test.ts | 89 +-- apps/cli/src/commands/settings.ts | 24 - apps/desktop/scripts/smoke-packaged-app.mjs | 1 - apps/desktop/test/preload-build.test.ts | 1 - apps/host-daemon/src/command-dispatch.ts | 8 - .../command-handlers/discover-repos.test.ts | 116 ---- .../src/command-handlers/discover-repos.ts | 453 ------------- .../settings/ExperimentsSettingsScreen.tsx | 6 - apps/server/src/routes/system.ts | 15 +- .../skills/builtin-skills/bb-cli/SKILL.md | 4 - .../bb-cli/references/app-settings.md | 10 +- .../bb-plugin-authoring/SKILL.md | 30 +- apps/server/src/services/system/onboarding.ts | 79 +-- apps/server/src/services/system/telemetry.ts | 34 - apps/server/test/system/experiments.test.ts | 7 - docs/configuration.md | 3 +- packages/db/test/experiments.test.ts | 3 +- packages/db/test/migrate.test.ts | 70 -- packages/domain/src/app-settings.ts | 11 - packages/domain/src/experiments.ts | 2 - packages/host-daemon-contract/src/commands.ts | 52 -- packages/host-daemon-contract/src/protocol.ts | 7 +- packages/host-daemon-contract/src/session.ts | 1 - .../test/contract.test.ts | 15 +- packages/sdk/src/areas/system.ts | 30 +- packages/sdk/test/public-types.test.ts | 2 - packages/server-contract/src/api/system.ts | 42 -- packages/server-contract/src/public-api.ts | 25 +- .../src/templates/bb-guide-customization.md | 8 - 45 files changed, 47 insertions(+), 2172 deletions(-) delete mode 100644 apps/app/src/components/onboarding/OnboardingFlow.tsx delete mode 100644 apps/app/src/components/onboarding/OnboardingHost.test.tsx delete mode 100644 apps/app/src/components/onboarding/OnboardingHost.tsx delete mode 100644 apps/host-daemon/src/command-handlers/discover-repos.test.ts delete mode 100644 apps/host-daemon/src/command-handlers/discover-repos.ts diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx index 9773f91a1f..9a25e277ed 100644 --- a/apps/app/src/App.tsx +++ b/apps/app/src/App.tsx @@ -53,7 +53,6 @@ import { getSkillDetailRoutePath, } from "./lib/route-paths"; import { AppCommandProvider } from "./components/commands/AppCommandProvider"; -import { OnboardingHost } from "@/components/onboarding/OnboardingHost"; import { ProviderCliInstallLogDialogHost } from "./components/provider-cli/provider-cli-install"; import { PluginSettingsCompatibilityRoute } from "./components/settings/PluginSettingsCompatibilityRoute"; import { RouteLoadingSkeleton } from "./components/ui/route-loading-skeleton"; @@ -397,9 +396,6 @@ export function App() { started it, so its failure toast can be clicked from any route — including auth callback, which renders no app shell. */} - {/* First-run onboarding. Outside so it is not tied to a - page. It self-gates on the experiment and completion timestamp. */} - diff --git a/apps/app/src/components/layout/AppLayout.plugin-panel-header.test.tsx b/apps/app/src/components/layout/AppLayout.plugin-panel-header.test.tsx index ac83ee51c1..ebe6ff7454 100644 --- a/apps/app/src/components/layout/AppLayout.plugin-panel-header.test.tsx +++ b/apps/app/src/components/layout/AppLayout.plugin-panel-header.test.tsx @@ -30,7 +30,6 @@ vi.mock("@/hooks/queries/system-queries", () => ({ changelogPreview: false, editMessages: false, mobileApp: false, - newOnboarding: false, providerSessionReaping: false, }, }, diff --git a/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx b/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx index 73ecfe6c74..5deb06e07d 100644 --- a/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx +++ b/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx @@ -35,7 +35,6 @@ vi.mock("@/hooks/queries/system-queries", () => ({ changelogPreview: false, editMessages: false, mobileApp: false, - newOnboarding: false, providerSessionReaping: false, }, }, diff --git a/apps/app/src/components/layout/AppLayout.sidebar-resize.test.tsx b/apps/app/src/components/layout/AppLayout.sidebar-resize.test.tsx index 3d7c200eda..cb134e5772 100644 --- a/apps/app/src/components/layout/AppLayout.sidebar-resize.test.tsx +++ b/apps/app/src/components/layout/AppLayout.sidebar-resize.test.tsx @@ -41,7 +41,6 @@ vi.mock("@/hooks/queries/system-queries", () => ({ data: { experiments: { editMessages: false, - newOnboarding: false, providerSessionReaping: false, }, }, diff --git a/apps/app/src/components/onboarding/OnboardingFlow.tsx b/apps/app/src/components/onboarding/OnboardingFlow.tsx deleted file mode 100644 index ee3140bdc8..0000000000 --- a/apps/app/src/components/onboarding/OnboardingFlow.tsx +++ /dev/null @@ -1,615 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; -import type { ReactNode } from "react"; -import type { OnboardingAgent } from "@bb/server-contract"; -import type { DiscoveredRepo } from "@bb/host-daemon-contract"; -import { Badge } from "@bb/shared-ui/badge"; -import { Button } from "@bb/shared-ui/button"; -import { Checkbox } from "@bb/shared-ui/checkbox"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@bb/shared-ui/dialog"; -import { Icon } from "@bb/shared-ui/icon"; -import { cn } from "@bb/shared-ui/lib/utils"; -import { copyToClipboardWithToast } from "@/lib/clipboard"; -import { getProviderIconInfo } from "@/lib/provider-icon"; -import { useLocalPathPicker } from "@/hooks/useLocalPathPicker"; -import { ProjectPathDialog } from "@/components/dialogs/ProjectPathDialog"; -import { - useOnboardingAgents, - useOnboardingRepos, -} from "@/hooks/queries/system-queries"; - -/** - * First-run onboarding. - * - * Two steps in a modal over the real app shell. The first is a *confirmation* - * whenever the machine already has a usable agent — bb runs the coding agent - * CLIs that are already installed and bills inference to those plans, so the - * honest thing to do is state that rather than ask a question. Only a machine - * with nothing installed is asked to install something. - * - * Escape and the close button both exit to the app; outside-click is blocked so - * a stray click cannot dismiss setup mid-install. - */ - -export type OnboardingAgentState = "connected" | "signed_out" | "none"; - -export interface OnboardingFlowProps { - /** Adds the selected projects; resolves once they exist. */ - onAddProjects: (repos: readonly DiscoveredRepo[]) => Promise; - /** Called when the flow finishes or is dismissed — persists the flag. */ - onClose: (outcome: { - completed: boolean; - step: "agents" | "projects"; - projectsAdded: number; - agentState: OnboardingAgentState; - }) => void; - /** Reports funnel events; the app wires this to the server's telemetry. */ - onEvent?: (event: OnboardingUiEvent) => void; - - /** Starts the managed CLI install; resolves when the job is queued. */ - onInstallAgent: (agent: OnboardingAgent) => void; - /** Set of provider ids with an install running or queued. */ - installing: ReadonlySet; -} - -export type OnboardingUiEvent = - | { name: "started"; agentState: OnboardingAgentState; agentCount: number } - | { name: "step_completed"; step: "agents" | "projects" } - /** Distinct from completion so the funnel can tell the two apart. */ - | { name: "step_skipped"; step: "agents" | "projects" }; - -/** The real brand mark the rest of the app uses for this provider. */ -function ProviderMark({ providerId }: { providerId: string }) { - const info = getProviderIconInfo(providerId); - if (!info) { - return ( - - ); - } - const Mark = info.icon; - return ; -} - -function Card({ children }: { children: ReactNode }) { - return ( -
- {children} -
- ); -} - -/** Two-step progress, sized for a dialog footer. */ -function StepDots({ current }: { current: number }) { - return ( -
- {[0, 1].map((index) => ( - - ))} -
- ); -} - -function agentStateOf( - agents: readonly OnboardingAgent[], -): OnboardingAgentState { - if (agents.some((agent) => agent.status === "connected")) return "connected"; - if (agents.some((agent) => agent.status !== "not_installed")) - return "signed_out"; - return "none"; -} - -function AgentRows({ - agents, - onInstall, - installing, - expandedSignIn, - onToggleSignIn, - onRecheck, -}: { - agents: readonly OnboardingAgent[]; - onInstall: (agent: OnboardingAgent) => void; - installing: ReadonlySet; - expandedSignIn: string | null; - onToggleSignIn: (providerId: string | null) => void; - onRecheck: () => void; -}) { - return ( - - {agents.map((agent) => { - const isInstalling = installing.has(agent.providerId); - const connected = agent.status === "connected"; - const needsAuth = - !isInstalling && - (agent.status === "unauthenticated" || agent.status === "expired"); - const expanded = expandedSignIn === agent.providerId; - const label = isInstalling - ? "Installing…" - : connected - ? "Connected" - : agent.status === "expired" - ? "Session expired" - : agent.status === "unauthenticated" - ? "Not signed in" - : "Not installed"; - return ( -
-
- -
-
{agent.displayName}
- {agent.accountEmail === null ? null : ( -
- {agent.accountEmail} -
- )} -
- {/* Fixed column so plan badges share one right edge, and rows - without a plan keep the same gutter. Only Claude Code, Codex, - and Cursor can report a plan; the rest get no badge rather - than a fabricated one. */} - - {agent.planLabel === null ? null : ( - - {agent.planLabel} - - )} - - - {label} - {isInstalling ? ( - - ) : null} - -
- {needsAuth && agent.loginCommand !== null ? ( - - ) : agent.status === "not_installed" && - agent.canInstall && - !isInstalling ? ( - - ) : null} -
-
- {expanded && agent.loginCommand !== null ? ( - /* bb deliberately does not drive another tool's login: it shows - the agent's own command and re-checks, so credentials never - pass through bb. */ -
-

- Run this in a terminal, then come back: -

-
- - {agent.loginCommand} - - - -
-
- ) : null} -
- ); - })} -
- ); -} - -export function OnboardingFlow({ - onAddProjects, - onClose, - onEvent, - onInstallAgent, - installing, -}: OnboardingFlowProps) { - const [step, setStep] = useState<0 | 1>(0); - const [selected, setSelected] = useState>(new Set()); - const [expandedSignIn, setExpandedSignIn] = useState(null); - /** Folders the user picked by hand, shown and checked alongside the scan. */ - const [addedRepos, setAddedRepos] = useState([]); - const [addError, setAddError] = useState(null); - const [adding, setAdding] = useState(false); - const [startedReported, setStartedReported] = useState(false); - - // Poll only while the agents step is visible; the projects step has no use - // for it and each read is several host round-trips. - const agentsQuery = useOnboardingAgents({ poll: step === 0 }); - // Re-read after a terminal sign-in rather than waiting out the poll. Using the - // query's own refetch keeps cache writes inside the query layer. - const recheck = useCallback(() => { - void agentsQuery.refetch(); - }, [agentsQuery]); - const reposQuery = useOnboardingRepos({ enabled: step === 1 }); - - const agents = useMemo( - () => agentsQuery.data?.agents ?? [], - [agentsQuery.data], - ); - const agentState = agentStateOf(agents); - const scanningAgents = agentsQuery.isPending; - - // Only a machine with nothing installed is asked to install something. - const nothingInstalled = - !scanningAgents && - agents.length > 0 && - agents.every((agent) => agent.status === "not_installed"); - const canContinue = - !scanningAgents && agents.some((agent) => agent.status === "connected"); - - useEffect(() => { - if (startedReported || scanningAgents) return; - // A failed probe is not evidence of an empty machine; reporting it would - // inflate `agent_state: none`, the metric this event exists to answer. - if (agentsQuery.isError || agentsQuery.data === undefined) return; - setStartedReported(true); - onEvent?.({ - name: "started", - agentState, - agentCount: agents.filter((agent) => agent.status !== "not_installed") - .length, - }); - }, [ - agentState, - agents, - agentsQuery.data, - agentsQuery.isError, - onEvent, - scanningAgents, - startedReported, - ]); - - const repos = useMemo(() => { - const discovered = reposQuery.data?.repos ?? []; - const seen = new Set(discovered.map((repo) => repo.path)); - // Hand-picked folders lead: the user just chose them. - return [ - ...addedRepos.filter((repo) => !seen.has(repo.path)), - ...discovered, - ]; - }, [addedRepos, reposQuery.data]); - - // Same path-entry surface the rest of the app uses (native picker on a - // single-machine desktop, in-app browser otherwise). Onboarding's submit adds - // the folder to this step's list instead of creating a project immediately, - // so one "Add projects" click still creates everything at once. - const pathPicker = useLocalPathPicker({ - isPending: false, - submit: ({ path, closeDialog }) => { - const name = path.split("/").filter(Boolean).pop() ?? path; - setAddedRepos((current) => - current.some((repo) => repo.path === path) - ? current - : [ - ...current, - { - path, - name, - lastActivityAt: new Date().toISOString(), - originUrl: null, - agentSeen: false, - agentSeenAt: null, - }, - ], - ); - setSelected((current) => new Set(current).add(path)); - closeDialog(); - }, - }); - - // Pre-check repos an agent has already worked in — that is the strongest - // signal the user wants them in bb — and fall back to the most recent. - useEffect(() => { - if (reposQuery.data === undefined) return; - setSelected((current) => { - if (current.size > 0) return current; - const seen = repos.filter((repo) => repo.agentSeen).map((r) => r.path); - return new Set( - seen.length > 0 ? seen : repos.slice(0, 2).map((r) => r.path), - ); - }); - }, [repos, reposQuery.data]); - - const finish = useCallback( - (completed: boolean, atStep: "agents" | "projects", added: number) => { - onClose({ completed, step: atStep, projectsAdded: added, agentState }); - }, - [agentState, onClose], - ); - - const toggleRepo = useCallback((path: string) => { - setSelected((current) => { - const next = new Set(current); - if (next.has(path)) next.delete(path); - else next.add(path); - return next; - }); - }, []); - - const addProjects = useCallback(async () => { - const chosen = repos.filter((repo) => selected.has(repo.path)); - setAdding(true); - setAddError(null); - try { - await onAddProjects(chosen); - onEvent?.({ name: "step_completed", step: "projects" }); - finish(true, "projects", chosen.length); - } catch (error) { - // Keep the dialog open and say so, rather than stranding the user with a - // half-added set and no explanation. - setAddError( - error instanceof Error - ? error.message - : "Could not add every project. Try again.", - ); - } finally { - setAdding(false); - } - }, [finish, onAddProjects, onEvent, repos, selected]); - - const title = - step === 0 - ? nothingInstalled - ? "Install a coding agent" - : "bb uses your existing coding agents" - : "Add your projects"; - - const description = - step === 0 - ? nothingInstalled - ? "bb has no inference of its own. It runs coding agent CLIs on your computer and bills usage to their plans. Install one to get started." - : "It runs the agents below locally, so inference is billed to their plans." - : "bb works inside your code. Add the folders you want it to work in. You can add more any time."; - - return ( - { - if (next) return; - finish(false, step === 0 ? "agents" : "projects", 0); - }} - > - event.preventDefault()} - > - - {title} - {description} - - -
- {step === 0 ? ( - scanningAgents ? ( -
- - Checking which coding agents are installed… -
- ) : ( - agent.canInstall) - : agents.filter((agent) => agent.status !== "not_installed") - } - expandedSignIn={expandedSignIn} - installing={installing} - onInstall={onInstallAgent} - onRecheck={recheck} - onToggleSignIn={setExpandedSignIn} - /> - ) - ) : ( -
-

- {reposQuery.isPending - ? "Searching ~ for git repos…" - : `Found ${reposQuery.data?.repos.length ?? 0} repos you edited in the last 30 days`} -

- {reposQuery.isPending ? null : ( - - {repos.map((repo) => ( -
toggleRepo(repo.path)} - className={cn( - "flex h-14 cursor-pointer items-center gap-3 border-b border-border-hairline px-4 last:border-b-0", - // Hover only applies to unselected rows. `state-hover` - // replaces the background rather than layering over it, - // so applying both made a hovered selected row read - // *lighter* than its selected neighbours. - selected.has(repo.path) - ? "bg-surface-selected" - : "hover:bg-state-hover", - )} - > - - -
-
{repo.name}
-
- {repo.path} -
-
- - {new Date(repo.lastActivityAt).toLocaleDateString()} - -
- ))} -
- )} - -
pathPicker.openPathEntry({ kind: "create" })} - className="flex h-14 cursor-pointer items-center gap-3 px-4 hover:bg-state-hover" - > - -
-
Add a folder
-
- Choose a project outside your home directory -
-
- - Browse… - -
-
- {addError === null ? null : ( -

{addError}

- )} -
- )} -
- - -
- - - {step === 0 - ? canContinue - ? "" - : nothingInstalled - ? "Install an agent to continue" - : "Sign in to at least one agent to continue" - : selected.size > 0 - ? `${selected.size} project${selected.size > 1 ? "s" : ""} selected` - : "You can add projects any time"} - -
-
- {step === 0 ? ( - <> - {canContinue ? null : ( - - )} - - - ) : ( - <> - - - - )} -
-
-
- - {/* Rendered inside the onboarding dialog's tree so it portals above it. */} - -
- ); -} diff --git a/apps/app/src/components/onboarding/OnboardingHost.test.tsx b/apps/app/src/components/onboarding/OnboardingHost.test.tsx deleted file mode 100644 index b77f2d34e5..0000000000 --- a/apps/app/src/components/onboarding/OnboardingHost.test.tsx +++ /dev/null @@ -1,99 +0,0 @@ -// @vitest-environment jsdom -import { cleanup, render, screen } from "@testing-library/react"; -import { defaultAppSettings, defaultExperiments } from "@bb/domain"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { OnboardingHost } from "./OnboardingHost"; - -const mocks = vi.hoisted(() => ({ - useCreateProject: vi.fn(), - useHostProviderCliStatus: vi.fn(), - usePrimaryHost: vi.fn(), - useProviderCliInstallRunner: vi.fn(), - useSidebarNavigation: vi.fn(), - useSystemConfig: vi.fn(), - useUpdateGeneralSettings: vi.fn(), -})); - -vi.mock("@/hooks/queries/system-queries", () => ({ - useHostProviderCliStatus: mocks.useHostProviderCliStatus, - useSystemConfig: mocks.useSystemConfig, -})); -vi.mock("@/hooks/mutations/settings-mutations", () => ({ - useUpdateGeneralSettings: mocks.useUpdateGeneralSettings, -})); -vi.mock("@/hooks/mutations/project-mutations", () => ({ - useCreateProject: mocks.useCreateProject, -})); -vi.mock("@/hooks/queries/host-queries", () => ({ - usePrimaryHost: mocks.usePrimaryHost, -})); -vi.mock("@/hooks/queries/sidebar-navigation-query", () => ({ - useSidebarNavigation: mocks.useSidebarNavigation, -})); -vi.mock("@/components/provider-cli/provider-cli-install", () => ({ - buildProviderCliIssue: vi.fn(), - hasProviderCliAction: vi.fn(), - providerCliEntries: vi.fn(() => []), - useProviderCliInstallRunner: mocks.useProviderCliInstallRunner, -})); -vi.mock("@/components/provider-cli/provider-cli-install-store", () => ({ - providerCliJobKey: vi.fn(() => "job"), -})); -vi.mock("./OnboardingFlow", () => ({ - OnboardingFlow: () =>
Onboarding flow
, -})); - -beforeEach(() => { - mocks.useCreateProject.mockReturnValue({ mutateAsync: vi.fn() }); - mocks.useHostProviderCliStatus.mockReturnValue({ data: undefined }); - mocks.usePrimaryHost.mockReturnValue({ id: "host-1" }); - mocks.useProviderCliInstallRunner.mockReturnValue({ - failuresByJobKey: new Map(), - queuedJobKeys: new Set(), - runningJobKey: null, - startInstall: vi.fn(), - }); - mocks.useSidebarNavigation.mockReturnValue({ data: { projects: [] } }); - mocks.useUpdateGeneralSettings.mockReturnValue({ mutate: vi.fn() }); -}); - -afterEach(() => { - cleanup(); - vi.clearAllMocks(); -}); - -describe("OnboardingHost", () => { - it("does not show or run provider checks while the experiment is off", () => { - mocks.useSystemConfig.mockReturnValue({ - data: { - experiments: defaultExperiments, - generalSettings: defaultAppSettings, - }, - }); - - render(); - - expect(screen.queryByText("Onboarding flow")).toBeNull(); - expect(mocks.useHostProviderCliStatus).toHaveBeenCalledWith({ - enabled: false, - hostId: "host-1", - }); - }); - - it("shows onboarding when the experiment is on and setup is incomplete", () => { - mocks.useSystemConfig.mockReturnValue({ - data: { - experiments: { ...defaultExperiments, newOnboarding: true }, - generalSettings: defaultAppSettings, - }, - }); - - render(); - - expect(screen.getByText("Onboarding flow")).toBeTruthy(); - expect(mocks.useHostProviderCliStatus).toHaveBeenCalledWith({ - enabled: true, - hostId: "host-1", - }); - }); -}); diff --git a/apps/app/src/components/onboarding/OnboardingHost.tsx b/apps/app/src/components/onboarding/OnboardingHost.tsx deleted file mode 100644 index aae054bfda..0000000000 --- a/apps/app/src/components/onboarding/OnboardingHost.tsx +++ /dev/null @@ -1,213 +0,0 @@ -import { useCallback, useEffect, useRef } from "react"; -import type { DiscoveredRepo } from "@bb/host-daemon-contract"; -import { useSystemConfig } from "@/hooks/queries/system-queries"; -import { useUpdateGeneralSettings } from "@/hooks/mutations/settings-mutations"; -import { useCreateProject } from "@/hooks/mutations/project-mutations"; -import { usePrimaryHost } from "@/hooks/queries/host-queries"; -import { useHostProviderCliStatus } from "@/hooks/queries/system-queries"; -import { useSidebarNavigation } from "@/hooks/queries/sidebar-navigation-query"; -import { - buildProviderCliIssue, - hasProviderCliAction, - providerCliEntries, - useProviderCliInstallRunner, -} from "@/components/provider-cli/provider-cli-install"; -import { providerCliJobKey } from "@/components/provider-cli/provider-cli-install-store"; -import { sdk } from "@/lib/sdk"; - -/** - * Collapse the two spellings of one remote so SSH and HTTPS clones of the same - * repository compare equal. A repo with no remote returns null and is never - * matched — path is not available on a project, so those are left to the - * server's own duplicate handling. - */ -function normalizeRemote(url: string | null): string | null { - if (url === null) return null; - const trimmed = url.trim(); - if (trimmed === "") return null; - return trimmed - .replace(/\.git$/u, "") - .replace(/^git@([^:]+):/u, "https://$1/") - .replace(/^ssh:\/\/git@/u, "https://") - .replace(/\/+$/u, "") - .toLowerCase(); -} - -/** Maps an onboarding provider id back to its managed-CLI key. */ -const CLI_KEY_BY_PROVIDER: Record = { - codex: "codex", - "claude-code": "claudeCode", - "acp-cursor": "cursor", -}; -import { - OnboardingFlow, - type OnboardingAgentState, - type OnboardingUiEvent, -} from "./OnboardingFlow"; - -/** - * Decides whether first-run onboarding is showing, and owns its side effects: - * creating the chosen projects, persisting the completion timestamp, and - * reporting the funnel to the server's telemetry. - * - * Mounted once by the app shell. The new-onboarding experiment and the - * `onboardingCompletedAt` timestamp gate the flow. Whether an agent is actually - * usable is answered live by the agents query, so dismissing onboarding never - * claims the machine is configured. - */ -export function OnboardingHost() { - const configQuery = useSystemConfig(); - const updateSettings = useUpdateGeneralSettings(); - const createProject = useCreateProject(); - const primaryHost = usePrimaryHost(); - const navigationQuery = useSidebarNavigation(); - const installRunner = useProviderCliInstallRunner(); - // Stamped in an effect rather than during render: `Date.now()` in a render - // body is impure and would drift on every re-render. - const startedAt = useRef(null); - - const settings = configQuery.data?.generalSettings; - const newOnboardingEnabled = - configQuery.data?.experiments.newOnboarding ?? false; - const primaryHostId = primaryHost?.id ?? null; - // Migration 0085 stamps existing installs as already onboarded, so a null - // timestamp means exactly one thing here: the flow remains incomplete. That - // is what lets Settings re-trigger it by clearing the column. - const neverOnboarded = - settings !== undefined && settings.onboardingCompletedAt === null; - const shouldShow = - newOnboardingEnabled && neverOnboarded && primaryHostId !== null; - const cliStatusQuery = useHostProviderCliStatus({ - hostId: primaryHostId, - // Only needed to build an install job, and only while the flow is open. - // Left ungated this runs provider CLI and package-registry checks on every - // app start, forever, for users who finished onboarding long ago. - enabled: shouldShow, - }); - - const projects = navigationQuery.data?.projects; - - const installingProviders = new Set( - Object.entries(CLI_KEY_BY_PROVIDER) - .filter(([, cliKey]) => { - if (primaryHostId === null) return false; - const jobKey = providerCliJobKey(primaryHostId, cliKey); - return ( - installRunner.runningJobKey === jobKey || - installRunner.queuedJobKeys.has(jobKey) - ); - }) - .map(([providerId]) => providerId), - ); - - const installAgent = useCallback( - (agent: { providerId: string }) => { - const cliKey = CLI_KEY_BY_PROVIDER[agent.providerId]; - if (cliKey === undefined || primaryHostId === null) return; - const status = cliStatusQuery.data; - if (status === undefined) return; - const issue = providerCliEntries(status) - .filter((entry) => entry.provider === cliKey) - .map(buildProviderCliIssue) - .find((candidate) => candidate !== null); - if (!issue || !hasProviderCliAction(issue)) return; - installRunner.startInstall({ hostId: primaryHostId, issue }); - }, - [cliStatusQuery.data, installRunner, primaryHostId], - ); - - // Stamp when the flow actually opens, so a re-trigger hours into a session - // does not report the whole session as its duration. - useEffect(() => { - if (shouldShow) startedAt.current ??= Date.now(); - else startedAt.current = null; - }, [shouldShow]); - - const addProjects = useCallback( - async (repos: readonly DiscoveredRepo[]) => { - if (primaryHostId === null) return; - // Guard against re-adding a repo bb already tracks on replay. Projects - // expose their remote, not their path, so the remote is the join key — - // normalized, because `git@host:o/r.git` and `https://host/o/r` are the - // same repository. - const existingRemotes = new Set( - (projects ?? []) - .map((project) => normalizeRemote(project.gitRemoteUrl)) - .filter((remote): remote is string => remote !== null), - ); - // Sequential: project creation touches the host workspace, and a burst of - // parallel creates would race on the same daemon. - for (const repo of repos) { - const remote = normalizeRemote(repo.originUrl); - if (remote !== null && existingRemotes.has(remote)) continue; - await createProject.mutateAsync({ - name: repo.name, - source: { - type: "local_path", - hostId: primaryHostId, - path: repo.path, - }, - }); - } - }, - [createProject, primaryHostId, projects], - ); - - const report = useCallback((event: OnboardingUiEvent) => { - void sdk.system - .onboardingEvent( - event.name === "started" - ? { - name: "onboarding_started", - agentState: event.agentState, - detectedAgentCount: event.agentCount, - } - : event.name === "step_skipped" - ? { name: "onboarding_step_skipped", step: event.step } - : { name: "onboarding_step_completed", step: event.step }, - ) - .catch(() => { - // Telemetry is analytics, not workflow state. - }); - }, []); - - const close = useCallback( - (outcome: { - completed: boolean; - step: "agents" | "projects"; - projectsAdded: number; - agentState: OnboardingAgentState; - }) => { - if (settings === undefined) return; - updateSettings.mutate({ - ...settings, - onboardingCompletedAt: new Date().toISOString(), - }); - void sdk.system - .onboardingEvent( - outcome.completed - ? { - name: "onboarding_completed", - agentState: outcome.agentState, - projectsAdded: outcome.projectsAdded, - durationMs: Date.now() - (startedAt.current ?? Date.now()), - } - : { name: "onboarding_dismissed", step: outcome.step }, - ) - .catch(() => {}); - }, - [settings, updateSettings], - ); - - if (!shouldShow) return null; - - return ( - - ); -} diff --git a/apps/app/src/hooks/queries/query-keys.ts b/apps/app/src/hooks/queries/query-keys.ts index dfd375c037..2e23fda288 100644 --- a/apps/app/src/hooks/queries/query-keys.ts +++ b/apps/app/src/hooks/queries/query-keys.ts @@ -63,7 +63,6 @@ export const SYSTEM_VERSION_QUERY_KEY = "systemVersion"; export const HOST_PROVIDER_CLI_STATUS_QUERY_KEY = "hostProviderCliStatus"; export const SYSTEM_USAGE_LIMITS_QUERY_KEY = "systemUsageLimits"; export const ONBOARDING_AGENTS_QUERY_KEY = "onboardingAgents"; -export const ONBOARDING_REPOS_QUERY_KEY = "onboardingRepos"; export const HOST_PATH_EXISTENCE_QUERY_KEY = "hostPathExistence"; export const PROJECT_SKILLS_QUERY_KEY = "projectSkills"; export const SKILL_CONTENT_QUERY_KEY = "skillContent"; @@ -458,9 +457,6 @@ export type OnboardingAgentsQueryKey = readonly [ string | null, string | null, ]; -export type OnboardingReposQueryKey = readonly [ - typeof ONBOARDING_REPOS_QUERY_KEY, -]; export type SystemExecutionOptionsQueryKey = readonly [ typeof SYSTEM_EXECUTION_OPTIONS_QUERY_KEY, string | null, @@ -1087,10 +1083,6 @@ export function onboardingAgentsQueryKey( return [ONBOARDING_AGENTS_QUERY_KEY, args.environmentId, args.hostId]; } -export function onboardingReposQueryKey(): OnboardingReposQueryKey { - return [ONBOARDING_REPOS_QUERY_KEY]; -} - export interface SystemExecutionOptionsQueryKeyArgs { environmentId: string | null; hostId: string | null; diff --git a/apps/app/src/hooks/queries/system-queries.test.tsx b/apps/app/src/hooks/queries/system-queries.test.tsx index 9487759792..62ce2fe6c0 100644 --- a/apps/app/src/hooks/queries/system-queries.test.tsx +++ b/apps/app/src/hooks/queries/system-queries.test.tsx @@ -545,8 +545,8 @@ describe("useOnboardingAgents", () => { const { result } = renderHook( () => [ - useOnboardingAgents({ hostId: "host-a", poll: false }), - useOnboardingAgents({ hostId: "host-b", poll: false }), + useOnboardingAgents({ hostId: "host-a" }), + useOnboardingAgents({ hostId: "host-b" }), ], { wrapper }, ); @@ -577,10 +577,9 @@ describe("useOnboardingAgents", () => { ); const { wrapper } = createQueryClientTestHarness(); - renderHook( - () => useOnboardingAgents({ environmentId: "env-remote", poll: false }), - { wrapper }, - ); + renderHook(() => useOnboardingAgents({ environmentId: "env-remote" }), { + wrapper, + }); await waitFor(() => { expect(sdk.system.onboardingAgents).toHaveBeenCalledWith({ diff --git a/apps/app/src/hooks/queries/system-queries.ts b/apps/app/src/hooks/queries/system-queries.ts index 36dae036e5..5a060c7d8d 100644 --- a/apps/app/src/hooks/queries/system-queries.ts +++ b/apps/app/src/hooks/queries/system-queries.ts @@ -21,10 +21,9 @@ import type { SystemVersionResponse, } from "@bb/server-contract"; import type { - DiscoverReposResult, ProviderCliStatusResponse, + ProviderUsageResponse, } from "@bb/host-daemon-contract"; -import type { ProviderUsageResponse } from "@bb/host-daemon-contract"; import { BbHttpError, sdk } from "@/lib/sdk"; import { modelCatalogCacheKey, @@ -41,7 +40,6 @@ import { hostProviderCliStatusQueryKey, systemCliSkillsQueryKey, onboardingAgentsQueryKey, - onboardingReposQueryKey, systemConfigQueryKey, systemExecutionOptionsQueryKey, systemProvidersQueryKey, @@ -65,7 +63,6 @@ export interface UseSystemExecutionOptionsArgs { export interface UseOnboardingAgentsOptions extends QueryOptions { environmentId?: string; hostId?: string; - poll?: boolean; } interface QueryOptions { @@ -535,8 +532,8 @@ export function useHostProviderCliStatus({ } /** - * Live agent state for onboarding. Polled while the step is open so installing - * or signing in from a terminal updates the list without a manual refresh. + * Install, auth, and plan state per agent provider. The root composer reads it + * to default an unset provider selection to one the machine is signed in to. */ export function useOnboardingAgents(options: UseOnboardingAgentsOptions = {}) { const environmentId = options.environmentId ?? null; @@ -551,22 +548,9 @@ export function useOnboardingAgents(options: UseOnboardingAgentsOptions = {}) { }), enabled: options.enabled ?? true, // Each read runs CLI health checks, known-agent checks, and up to three - // provider usage requests, so this polls slowly and only while the agents - // step is actually on screen. An explicit re-check covers the impatient - // case. Other readers (the composer's provider default) want one answer. - ...(options.poll === false - ? { staleTime: 60_000 } - : { refetchInterval: 15_000 }), - }); -} - -/** Candidate projects on the host. Runs once when the projects step opens. */ -export function useOnboardingRepos(options: QueryOptions = {}) { - return useQuery({ - queryKey: onboardingReposQueryKey(), - queryFn: ({ signal }) => sdk.system.onboardingRepos({ signal }), - enabled: options.enabled ?? true, - staleTime: Infinity, + // provider usage requests on the host, so one answer is cached rather than + // polled: the composer only needs a default at open time. + staleTime: 60_000, }); } diff --git a/apps/app/src/hooks/useLocalPathPicker.test.tsx b/apps/app/src/hooks/useLocalPathPicker.test.tsx index 4803394308..e5033062d7 100644 --- a/apps/app/src/hooks/useLocalPathPicker.test.tsx +++ b/apps/app/src/hooks/useLocalPathPicker.test.tsx @@ -122,8 +122,8 @@ describe("useLocalPathPicker", () => { /** * Choosing between the native folder picker and the in-app dialog. This lived - * in `useQuickCreateProject` until onboarding needed the same behavior; it is - * shared here so every path-entry caller agrees. + * in `useQuickCreateProject` until a second caller needed the same behavior; it + * is shared here so every path-entry caller agrees. */ describe("useLocalPathPicker openPathEntry", () => { it("opens the dialog instead of the native picker when several machines exist", () => { diff --git a/apps/app/src/hooks/useThreadCreationOptions.ts b/apps/app/src/hooks/useThreadCreationOptions.ts index f547ebd87c..5849e70bb7 100644 --- a/apps/app/src/hooks/useThreadCreationOptions.ts +++ b/apps/app/src/hooks/useThreadCreationOptions.ts @@ -317,7 +317,6 @@ export function useThreadCreationOptions( const connectedAgentsQuery = useOnboardingAgents({ enabled: shouldResolveConnectedProvider, ...executionOptionsRouting, - poll: false, }); const connectedProviderId = shouldResolveConnectedProvider ? connectedAgentsQuery.data?.agents.find( diff --git a/apps/app/src/lib/system-config-atoms.ts b/apps/app/src/lib/system-config-atoms.ts index a9fdf66fce..66fb4e24a7 100644 --- a/apps/app/src/lib/system-config-atoms.ts +++ b/apps/app/src/lib/system-config-atoms.ts @@ -24,7 +24,6 @@ const unavailableSystemConfig: SystemConfigResponse = { changelogPreview: false, editMessages: false, mobileApp: false, - newOnboarding: false, providerSessionReaping: false, }, appearance: defaultAppTheme, diff --git a/apps/app/src/views/SettingsView.experiments.test.tsx b/apps/app/src/views/SettingsView.experiments.test.tsx index 7bbc329639..06a82bba53 100644 --- a/apps/app/src/views/SettingsView.experiments.test.tsx +++ b/apps/app/src/views/SettingsView.experiments.test.tsx @@ -8,7 +8,6 @@ afterEach(cleanup); function renderSection(overrides?: { onChangelogPreviewEnabledChange?: (enabled: boolean) => void; onMobileAppEnabledChange?: (enabled: boolean) => void; - onNewOnboardingEnabledChange?: (enabled: boolean) => void; onProviderSessionReapingEnabledChange?: (enabled: boolean) => void; }) { return render( @@ -17,16 +16,12 @@ function renderSection(overrides?: { disabled={false} editMessagesEnabled={false} mobileAppEnabled={false} - newOnboardingEnabled={false} providerSessionReapingEnabled={false} onChangelogPreviewEnabledChange={ overrides?.onChangelogPreviewEnabledChange ?? vi.fn() } onEditMessagesEnabledChange={vi.fn()} onMobileAppEnabledChange={overrides?.onMobileAppEnabledChange ?? vi.fn()} - onNewOnboardingEnabledChange={ - overrides?.onNewOnboardingEnabledChange ?? vi.fn() - } onProviderSessionReapingEnabledChange={ overrides?.onProviderSessionReapingEnabledChange ?? vi.fn() } @@ -42,13 +37,6 @@ describe("ExperimentsSettingsSection", () => { expect(onChange).toHaveBeenCalledWith(true); }); - it("reports new onboarding changes", () => { - const onChange = vi.fn(); - renderSection({ onNewOnboardingEnabledChange: onChange }); - fireEvent.click(screen.getByLabelText("New onboarding")); - expect(onChange).toHaveBeenCalledWith(true); - }); - it("reports mobile app changes", () => { const onChange = vi.fn(); renderSection({ onMobileAppEnabledChange: onChange }); diff --git a/apps/app/src/views/SettingsView.stories.tsx b/apps/app/src/views/SettingsView.stories.tsx index e48daf83d3..48476fb150 100644 --- a/apps/app/src/views/SettingsView.stories.tsx +++ b/apps/app/src/views/SettingsView.stories.tsx @@ -275,14 +275,12 @@ function GeneralSettingsStory({ state.setNavigateToThreadAfterCreate } onOpenLinksInAppBrowserChange={state.setOpenLinksInAppBrowser} - onReplayOnboarding={() => {}} onRewriteLocalhostLinksChange={state.setRewriteLocalhostLinks} onRichTextEditingChange={state.setRichTextEditing} onSteerActiveThreadOnEnterChange={state.setSteerActiveThreadOnEnter} openLinksInAppBrowser={state.openLinksInAppBrowser} rewriteLocalhostLinks={state.rewriteLocalhostLinks} richTextEditing={state.richTextEditing} - replayOnboardingAvailable={state.experiments.newOnboarding} steerActiveThreadOnEnter={state.steerActiveThreadOnEnter} steerActiveThreadOnEnterDisabled={false} /> @@ -352,7 +350,6 @@ function ExperimentsStory() { disabled={false} editMessagesEnabled={state.experiments.editMessages} mobileAppEnabled={state.experiments.mobileApp} - newOnboardingEnabled={state.experiments.newOnboarding} providerSessionReapingEnabled={state.experiments.providerSessionReaping} onChangelogPreviewEnabledChange={(enabled) => state.setExperiments((current) => ({ @@ -372,12 +369,6 @@ function ExperimentsStory() { mobileApp: enabled, })) } - onNewOnboardingEnabledChange={(enabled) => - state.setExperiments((current) => ({ - ...current, - newOnboarding: enabled, - })) - } onProviderSessionReapingEnabledChange={(enabled) => state.setExperiments((current) => ({ ...current, diff --git a/apps/app/src/views/SettingsView.tsx b/apps/app/src/views/SettingsView.tsx index 2e7ebe4977..3b3b334676 100644 --- a/apps/app/src/views/SettingsView.tsx +++ b/apps/app/src/views/SettingsView.tsx @@ -176,7 +176,6 @@ export interface AppearanceSettingsSectionProps { } export interface GeneralSettingsSectionProps { - onReplayOnboarding: () => void; desktopBrowserAvailable: boolean; navigateToThreadAfterCreate: boolean; onNavigateToThreadAfterCreateChange: (enabled: boolean) => void; @@ -187,7 +186,6 @@ export interface GeneralSettingsSectionProps { openLinksInAppBrowser: boolean; rewriteLocalhostLinks: boolean; richTextEditing: boolean; - replayOnboardingAvailable: boolean; steerActiveThreadOnEnter: boolean; steerActiveThreadOnEnterDisabled: boolean; } @@ -213,12 +211,10 @@ export interface ExperimentsSettingsSectionProps { changelogPreviewEnabled: boolean; editMessagesEnabled: boolean; mobileAppEnabled: boolean; - newOnboardingEnabled: boolean; providerSessionReapingEnabled: boolean; onChangelogPreviewEnabledChange: (enabled: boolean) => void; onEditMessagesEnabledChange: (enabled: boolean) => void; onMobileAppEnabledChange: (enabled: boolean) => void; - onNewOnboardingEnabledChange: (enabled: boolean) => void; onProviderSessionReapingEnabledChange: (enabled: boolean) => void; } @@ -836,10 +832,8 @@ export function GeneralSettingsSection({ openLinksInAppBrowser, rewriteLocalhostLinks, richTextEditing, - replayOnboardingAvailable, steerActiveThreadOnEnter, steerActiveThreadOnEnterDisabled, - onReplayOnboarding, }: GeneralSettingsSectionProps) { return ( @@ -873,39 +867,11 @@ export function GeneralSettingsSection({ enabled={rewriteLocalhostLinks} onEnabledChange={onRewriteLocalhostLinksChange} /> - - {replayOnboardingAvailable ? ( - - ) : null} ); } -/** - * The parent only shows this control when the new-onboarding experiment is on. - * Clearing `onboardingCompletedAt` then reopens the flow on the spot. - */ -function ReplayOnboardingSettingsControl({ - onReplay, -}: { - onReplay: () => void; -}) { - return ( -
-
-
Setup guide
-

- Walk through agent detection and adding projects again. -

-
- -
- ); -} - export function DebugSettingsSection({ disabled, enabled, @@ -999,7 +965,6 @@ export function ProviderSettingsSection({ const CHANGELOG_PREVIEW_EXPERIMENT_LABEL = "Changelog preview"; const EDIT_MESSAGES_EXPERIMENT_LABEL = "Edit messages"; const MOBILE_APP_EXPERIMENT_LABEL = "Mobile app"; -const NEW_ONBOARDING_EXPERIMENT_LABEL = "New onboarding"; const PROVIDER_SESSION_REAPING_EXPERIMENT_LABEL = "Idle provider session release"; export function ExperimentsSettingsSection({ @@ -1007,12 +972,10 @@ export function ExperimentsSettingsSection({ disabled, editMessagesEnabled, mobileAppEnabled, - newOnboardingEnabled, providerSessionReapingEnabled, onChangelogPreviewEnabledChange, onEditMessagesEnabledChange, onMobileAppEnabledChange, - onNewOnboardingEnabledChange, onProviderSessionReapingEnabledChange, }: ExperimentsSettingsSectionProps) { return ( @@ -1057,18 +1020,6 @@ export function ExperimentsSettingsSection({ /> - - - - - updateExperimentsMutation.mutate({ - ...experiments, - newOnboarding: enabled, - }) - } providerSessionReapingEnabled={experiments.providerSessionReaping} onProviderSessionReapingEnabledChange={(enabled) => updateExperimentsMutation.mutate({ @@ -1290,7 +1234,6 @@ export function SettingsView() { openLinksInAppBrowser={openLinksInAppBrowser} rewriteLocalhostLinks={rewriteLocalhostLinks} richTextEditing={richTextEditing} - replayOnboardingAvailable={experiments.newOnboarding} steerActiveThreadOnEnter={generalSettings.steerActiveThreadOnEnter} steerActiveThreadOnEnterDisabled={ systemConfigQuery.data === undefined || @@ -1298,12 +1241,6 @@ export function SettingsView() { } onNavigateToThreadAfterCreateChange={setNavigateToThreadAfterCreate} onOpenLinksInAppBrowserChange={setOpenLinksInAppBrowser} - onReplayOnboarding={() => - updateGeneralSettingsMutation.mutate({ - ...generalSettings, - onboardingCompletedAt: null, - }) - } onRewriteLocalhostLinksChange={setRewriteLocalhostLinks} onRichTextEditingChange={setRichTextEditing} onSteerActiveThreadOnEnterChange={(enabled) => diff --git a/apps/cli/src/__tests__/command-output/settings.test.ts b/apps/cli/src/__tests__/command-output/settings.test.ts index 6341576ef5..1785960b45 100644 --- a/apps/cli/src/__tests__/command-output/settings.test.ts +++ b/apps/cli/src/__tests__/command-output/settings.test.ts @@ -34,39 +34,15 @@ describe("bb settings commands", () => { }); }); - // Keys and value shapes come from `appSettingsSchema`, so non-boolean and - // nullable preferences are settable without a per-key branch in the command. - it("sets a nullable setting and rejects an unknown key", async () => { - const put = vi.fn(async ({ json }) => json); + // Keys come from `appSettingsSchema`, so an unknown one is rejected by the + // command rather than sent to the server. + it("rejects an unknown general setting key", async () => { stubServerApi({ "v1.system.config.$get": vi.fn(async () => ({ - generalSettings: { - ...defaultAppSettings, - onboardingCompletedAt: "2026-08-06T00:00:00.000Z", - }, + generalSettings: defaultAppSettings, experiments: defaultExperiments, })), - "v1.settings.general.$put": put, - }); - - await runCommand( - ["settings", "general", "onboardingCompletedAt", "null"], - register, - ); - - expect(put).toHaveBeenCalledWith({ - json: { ...defaultAppSettings, onboardingCompletedAt: null }, - }); - - // "2026" reads as JSON, but this setting takes a string, so the raw text - // has to win: the setting's own schema decides which reading applies. - await runCommand( - ["settings", "general", "onboardingCompletedAt", "2026"], - register, - ); - - expect(put).toHaveBeenLastCalledWith({ - json: { ...defaultAppSettings, onboardingCompletedAt: "2026" }, + "v1.settings.general.$put": vi.fn(async ({ json }) => json), }); await expect( @@ -134,61 +110,6 @@ describe("bb settings commands", () => { }); }); - it("enables new onboarding before replaying the setup guide", async () => { - const updateExperiments = vi.fn(async ({ json }) => json); - const updateGeneralSettings = vi.fn(async ({ json }) => json); - stubServerApi({ - "v1.system.config.$get": vi.fn(async () => ({ - generalSettings: { - ...defaultAppSettings, - onboardingCompletedAt: "2026-08-06T00:00:00.000Z", - }, - experiments: defaultExperiments, - })), - "v1.settings.experiments.$put": updateExperiments, - "v1.settings.general.$put": updateGeneralSettings, - }); - - await runCommand(["settings", "replay-onboarding"], register); - - expect(updateExperiments).toHaveBeenCalledWith({ - json: { ...defaultExperiments, newOnboarding: true }, - }); - expect(updateGeneralSettings).toHaveBeenCalledWith({ - json: { ...defaultAppSettings, onboardingCompletedAt: null }, - }); - expect(console.log).toHaveBeenCalledWith( - "New onboarding is enabled; onboarding will show again", - ); - }); - - it("reports both replay side effects as JSON", async () => { - stubServerApi({ - "v1.system.config.$get": vi.fn(async () => ({ - generalSettings: defaultAppSettings, - experiments: defaultExperiments, - })), - "v1.settings.experiments.$put": vi.fn(async ({ json }) => json), - "v1.settings.general.$put": vi.fn(async ({ json }) => json), - }); - - await runCommand(["settings", "replay-onboarding", "--json"], register); - - expect(console.log).toHaveBeenCalledWith( - JSON.stringify( - { - experiments: { ...defaultExperiments, newOnboarding: true }, - generalSettings: { - ...defaultAppSettings, - onboardingCompletedAt: null, - }, - }, - null, - 2, - ), - ); - }); - it("reads usage from a selected machine", async () => { const getUsage = vi.fn(async () => ({ codex: { status: "unauthenticated" }, diff --git a/apps/cli/src/commands/settings.ts b/apps/cli/src/commands/settings.ts index 45ba952250..81ca779c24 100644 --- a/apps/cli/src/commands/settings.ts +++ b/apps/cli/src/commands/settings.ts @@ -153,30 +153,6 @@ export function registerSettingsCommands( }), ); - settings - .command("replay-onboarding") - .description("Show the first-run setup guide again on the next app load") - .option("--json", "Print machine-readable JSON output") - .action( - action(async (opts: JsonOptions) => { - const sdk = createCliBbSdk(getUrl()); - const config = await sdk.system.config(); - let experiments = config.experiments; - if (!config.experiments.newOnboarding) { - experiments = await sdk.system.updateExperiments({ - ...config.experiments, - newOnboarding: true, - }); - } - const generalSettings = await sdk.system.updateGeneralSettings({ - ...config.generalSettings, - onboardingCompletedAt: null, - }); - if (outputJson(opts, { experiments, generalSettings })) return; - console.log("New onboarding is enabled; onboarding will show again"); - }), - ); - settings .command("experiment ") .description("Set an experiment value") diff --git a/apps/desktop/scripts/smoke-packaged-app.mjs b/apps/desktop/scripts/smoke-packaged-app.mjs index 0e0400d0de..140fd09965 100644 --- a/apps/desktop/scripts/smoke-packaged-app.mjs +++ b/apps/desktop/scripts/smoke-packaged-app.mjs @@ -145,7 +145,6 @@ async function startSmokeServer({ dataDir, experiments: { mobileApp: false, - newOnboarding: false, providerSessionReaping: false, }, featureFlags: { diff --git a/apps/desktop/test/preload-build.test.ts b/apps/desktop/test/preload-build.test.ts index 844852885f..6cbe09808e 100644 --- a/apps/desktop/test/preload-build.test.ts +++ b/apps/desktop/test/preload-build.test.ts @@ -130,7 +130,6 @@ async function startDesktopSmokeServer( changelogPreview: false, editMessages: false, mobileApp: false, - newOnboarding: false, providerSessionReaping: false, }, featureFlags: { diff --git a/apps/host-daemon/src/command-dispatch.ts b/apps/host-daemon/src/command-dispatch.ts index b48d641565..eaa10d07c6 100644 --- a/apps/host-daemon/src/command-dispatch.ts +++ b/apps/host-daemon/src/command-dispatch.ts @@ -53,7 +53,6 @@ import { completeCodexInference, transcribeCodexVoice, } from "./codex-chatgpt-client.js"; -import { discoverRepos } from "./command-handlers/discover-repos.js"; import { getProviderUsage } from "./provider-usage.js"; import { getKnownAcpAgentsStatus, @@ -683,13 +682,6 @@ const onlineRpcHandlers: OnlineRpcHandlerMap = { env: providerCliEnvFromShellEnv(options.runtimeManager.getShellEnv()), }), "provider_cli.install": installProviderCliOnHost, - "workspace.discover_repos": async (command, options) => - discoverRepos({ - maxDepth: command.maxDepth, - sinceDays: command.sinceDays, - limit: command.limit, - env: options.runtimeManager.getShellEnv(), - }), "workspace.status": async (command, options) => { const resolution = await resolveWorkspaceForCommand({ dataDir: options.dataDir, diff --git a/apps/host-daemon/src/command-handlers/discover-repos.test.ts b/apps/host-daemon/src/command-handlers/discover-repos.test.ts deleted file mode 100644 index 817451ddd2..0000000000 --- a/apps/host-daemon/src/command-handlers/discover-repos.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { mkdir, writeFile } from "node:fs/promises"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { discoverRepos } from "./discover-repos.js"; - -/** - * The walk's whole value is what it refuses to enter, so these cover the - * skip rules rather than the happy path. - */ -describe("discoverRepos", () => { - let home: string; - - const makeRepo = async (relativePath: string) => { - const gitDir = join(home, relativePath, ".git"); - await mkdir(gitDir, { recursive: true }); - await writeFile(join(gitDir, "HEAD"), "ref: refs/heads/main\n"); - }; - - beforeEach(async () => { - home = await mkdtemp(join(tmpdir(), "bb-discover-")); - }); - - afterEach(async () => { - await rm(home, { recursive: true, force: true }); - }); - - const run = () => - discoverRepos({ - maxDepth: 5, - sinceDays: 3650, - limit: 50, - home, - env: { PATH: "/nonexistent" }, - }); - - it("stops descending at a repo root so nested checkouts are not listed", async () => { - await makeRepo("projects/app"); - await makeRepo("projects/app/vendor/inner"); - - const { repos } = await run(); - - expect(repos.map((repo) => repo.name)).toEqual(["app"]); - }); - - it("skips dot-directories, which hold tool internals rather than projects", async () => { - await makeRepo("projects/app"); - await makeRepo(".nvm/versions/thing"); - await makeRepo(".bb-dev/worktrees/copy"); - - const { repos } = await run(); - - expect(repos.map((repo) => repo.name)).toEqual(["app"]); - }); - - it("skips heavy build directories on the way down", async () => { - await makeRepo("projects/app"); - await makeRepo("code/node_modules/pkg"); - - const { repos } = await run(); - - expect(repos.map((repo) => repo.name)).toEqual(["app"]); - }); - - it("drops repos older than the recency window", async () => { - await makeRepo("projects/app"); - const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); - - try { - const { repos } = await discoverRepos({ - maxDepth: 5, - sinceDays: 30, - limit: 50, - home, - env: { PATH: "/nonexistent" }, - // A "now" far in the future puts the fixture outside the window. - now: Date.now() + 400 * 86_400_000, - }); - - expect(repos).toEqual([]); - const timerDelays = setTimeoutSpy.mock.calls.flatMap(([, delay]) => - typeof delay === "number" ? [delay] : [], - ); - expect(timerDelays.length).toBeGreaterThan(0); - expect(Math.max(...timerDelays)).toBeLessThanOrEqual(3_000); - } finally { - setTimeoutSpy.mockRestore(); - } - }); - - it("detects linked worktrees, whose .git is a file rather than a directory", async () => { - // `git worktree add` and submodules both write a `.git` file pointing at - // the real git dir. Treating only directories as repo markers walked - // straight into them and returned nothing. - await mkdir(join(home, "projects/linked"), { recursive: true }); - await writeFile( - join(home, "projects/linked/.git"), - "gitdir: /home/user/projects/app/.git/worktrees/linked\n", - ); - - const { repos } = await run(); - - expect(repos.map((repo) => repo.name)).toEqual(["linked"]); - }); - - it("still returns repos when no agent history is available", async () => { - await makeRepo("projects/app"); - - const { repos, truncated } = await run(); - - expect(truncated).toBe(false); - expect(repos[0]?.agentSeen).toBe(false); - expect(repos[0]?.agentSeenAt).toBeNull(); - }); -}); diff --git a/apps/host-daemon/src/command-handlers/discover-repos.ts b/apps/host-daemon/src/command-handlers/discover-repos.ts deleted file mode 100644 index 339a225460..0000000000 --- a/apps/host-daemon/src/command-handlers/discover-repos.ts +++ /dev/null @@ -1,453 +0,0 @@ -import { spawn } from "node:child_process"; -import { readFile, stat } from "node:fs/promises"; -import { opendir } from "node:fs/promises"; -import { homedir } from "node:os"; -import { basename, join } from "node:path"; -import type { - DiscoverReposResult, - DiscoveredRepo, -} from "@bb/host-daemon-contract"; - -/** - * Find candidate projects on this host: git repositories under the user's home - * directory, ranked by how likely the user wants them in bb. - * - * The walk is cheap because of one rule: stop descending the moment a directory - * contains `.git`. Everything below a repo root belongs to that repo, so we - * never enter `node_modules`, `target`, `dist`, or any other build tree inside - * a project. Measured on a real developer home directory this visits ~77 - * directories in ~5ms, versus ~5,100 for a naive `find -name .git -prune`. - * - * Cold page cache — not CPU — is the real cost, so the walk is time-boxed and - * reports `truncated` rather than blocking onboarding on a slow or - * network-mounted home directory. - */ - -/** - * Races a filesystem call against the walk deadline. `opendir`/`stat` on a dead - * network mount can block indefinitely with no abort signal, so a per-call - * ceiling is the only thing that keeps discovery bounded. - */ -async function withDeadline( - operation: Promise, - deadline: number, - /** Releases a result that arrives after the deadline, so nothing leaks. */ - disposeLate?: (value: T) => void, -): Promise { - const remaining = deadline - Date.now(); - if (remaining <= 0) { - void operation.then((value) => disposeLate?.(value)).catch(() => {}); - return null; - } - let timer: NodeJS.Timeout | undefined; - let timedOut = false; - try { - const result = await Promise.race([ - operation.then((value) => { - // The race already resolved null; this value has no owner. - if (timedOut) disposeLate?.(value); - return value; - }), - new Promise((resolve) => { - timer = setTimeout(() => { - timedOut = true; - resolve(null); - }, remaining); - timer.unref?.(); - }), - ]); - return timedOut ? null : result; - } catch { - return null; - } finally { - if (timer) clearTimeout(timer); - } -} - -/** Directories that never contain a user project and are expensive to enter. */ -const SKIP_DIRECTORIES = new Set([ - "node_modules", - "Library", - "Applications", - "target", - "vendor", - "dist", - "build", - "out", - "venv", - "__pycache__", - "Pictures", - "Music", - "Movies", -]); - -const WALK_BUDGET_MS = 3_000; -/** - * Directories opened at once. Unbounded recursion over a wide home directory - * can start thousands of `opendir` calls together and reach the process file - * limit, which would disrupt active agent work on the same daemon. - */ -const WALK_CONCURRENCY = 32; -const AGENT_HISTORY_BUDGET_MS = 2_000; - -interface FoundRepo { - path: string; - lastActivityMs: number; -} - -/** - * Walk `root` breadth-first to `maxDepth`, stopping at each repo root. - * - * Dot-directories are skipped deliberately, and not only for speed: on a real - * machine the repos they hide were inside `.nvm`, `.codex/.tmp`, and `.bb-dev` - * — tool internals, never user projects. - */ -async function walkForRepos( - root: string, - maxDepth: number, - deadline: number, -): Promise<{ repos: FoundRepo[]; truncated: boolean }> { - const repos: FoundRepo[] = []; - let truncated = false; - - const walk = async (dir: string, depth: number): Promise => { - if (depth > maxDepth) return; - if (Date.now() > deadline) { - truncated = true; - return; - } - - // Unreadable or hung directory (permissions, broken mount) is not an error - // here — it just contributes nothing. - const handle = await withDeadline(opendir(dir), deadline, (late) => { - void late.close().catch(() => {}); - }); - if (handle === null) { - if (Date.now() > deadline) truncated = true; - return; - } - - const children: string[] = []; - let isRepo = false; - try { - for await (const entry of handle) { - // Linked worktrees and submodules carry `.git` as a file pointing at - // the real git dir, so the name check has to come before the - // directory check or those repos are never detected. - if (entry.name === ".git") { - isRepo = true; - continue; - } - if (!entry.isDirectory()) continue; - if (entry.name.startsWith(".")) continue; - if (SKIP_DIRECTORIES.has(entry.name)) continue; - children.push(entry.name); - } - } catch { - return; - } - - if (isRepo) { - // Stop here. Nested repos below a repo root are submodules or vendored - // copies, not separate projects the user thinks about. - // `.git/HEAD` is the best activity signal, but a linked worktree or - // submodule has `.git` as a file, so fall back to stat-ing the marker - // itself. Leaving mtime 0 would drop those repos at the recency filter. - const head = await withDeadline( - stat(join(dir, ".git", "HEAD")), - deadline, - ); - const marker = - head ?? (await withDeadline(stat(join(dir, ".git")), deadline)); - const lastActivityMs = marker?.mtimeMs ?? 0; - repos.push({ path: dir, lastActivityMs }); - return; - } - - for (let index = 0; index < children.length; index += WALK_CONCURRENCY) { - if (Date.now() > deadline) { - truncated = true; - return; - } - await Promise.all( - children - .slice(index, index + WALK_CONCURRENCY) - .map((child) => walk(join(dir, child), depth + 1)), - ); - } - }; - - await walk(root, 0); - return { repos, truncated }; -} - -/** `git config --get remote.origin.url`, read straight from the config file. */ -async function readOriginUrl(repoPath: string): Promise { - let config: string; - try { - config = await readFile(join(repoPath, ".git", "config"), "utf8"); - } catch { - return null; - } - const section = config.split(/\[remote "origin"\]/u)[1]; - if (section === undefined) return null; - const match = /^\s*url\s*=\s*(.+)$/mu.exec(section.split("[")[0] ?? ""); - return match?.[1]?.trim() ?? null; -} - -/** - * Normalize a remote URL so `git@github.com:o/r.git` and - * `https://github.com/o/r` collapse to the same key. Only used to join agent - * history to repos, never shown to the user. - */ -function normalizeOrigin(url: string | null): string | null { - if (!url) return null; - return url - .trim() - .replace(/\.git$/u, "") - .replace(/^git@([^:]+):/u, "https://$1/") - .replace(/^ssh:\/\/git@/u, "https://") - .replace(/\/+$/u, "") - .toLowerCase(); -} - -/** - * Directories where Claude Code has been run. `~/.claude.json` holds a flat - * `projects` map keyed by absolute path, with no recency. - */ -async function readClaudeHistory(home: string): Promise> { - const seen = new Map(); - let raw: string; - try { - raw = await readFile(join(home, ".claude.json"), "utf8"); - } catch { - return seen; - } - try { - const parsed: unknown = JSON.parse(raw); - const projects = - typeof parsed === "object" && parsed !== null && "projects" in parsed - ? (parsed as { projects: unknown }).projects - : null; - if (typeof projects !== "object" || projects === null) return seen; - for (const key of Object.keys(projects)) { - // No timestamp available; 1 is "seen, time unknown" and still outranks - // never-seen repos without competing with Codex's real timestamps. - seen.set(key, 1); - } - } catch { - // Malformed file is a missing hint, not a failure. - } - return seen; -} - -interface CodexHistory { - byPath: Map; - byOrigin: Map; -} - -/** - * Directories where Codex has been run, via the supported app-server API - * (`thread/list`) rather than its private SQLite file. `useStateDbOnly` skips - * the JSONL rollout scan; measured at ~190ms for 142 threads including spawn. - * - * `gitInfo.originUrl` matters here: a user running Codex through bb accumulates - * many ephemeral worktree paths for a single repo, and the origin collapses - * them into one signal. - */ -async function readCodexHistory( - env: NodeJS.ProcessEnv, - budgetMs: number, -): Promise { - const byPath = new Map(); - const byOrigin = new Map(); - - const rows = await new Promise((resolve) => { - let child: ReturnType; - try { - child = spawn("codex", ["app-server"], { - env, - stdio: ["pipe", "pipe", "ignore"], - }); - } catch { - resolve([]); - return; - } - - const collected: unknown[] = []; - let settled = false; - const finish = () => { - if (settled) return; - settled = true; - clearTimeout(timer); - child.kill(); - resolve(collected); - }; - const timer = setTimeout(finish, budgetMs); - - child.on("error", finish); - child.on("exit", finish); - - const send = (message: unknown) => { - try { - child.stdin?.write(`${JSON.stringify(message)}\n`); - } catch { - finish(); - } - }; - - let buffer = ""; - child.stdout?.on("data", (chunk: Buffer) => { - buffer += chunk.toString(); - let newline = buffer.indexOf("\n"); - while (newline >= 0) { - const line = buffer.slice(0, newline); - buffer = buffer.slice(newline + 1); - newline = buffer.indexOf("\n"); - if (!line.trim()) continue; - let message: { id?: number; result?: { data?: unknown[] } }; - try { - message = JSON.parse(line) as typeof message; - } catch { - continue; - } - if (message.id === 1) { - send({ - jsonrpc: "2.0", - id: 2, - method: "thread/list", - params: { - useStateDbOnly: true, - limit: 200, - sortDirection: "desc", - }, - }); - } else if (message.id === 2) { - collected.push(...(message.result?.data ?? [])); - finish(); - } - } - }); - - send({ - jsonrpc: "2.0", - id: 1, - method: "initialize", - params: { - clientInfo: { name: "bb", title: "bb", version: "0.0.0" }, - }, - }); - }); - - for (const row of rows) { - if (typeof row !== "object" || row === null) continue; - const thread = row as { - cwd?: unknown; - updatedAt?: unknown; - gitInfo?: { originUrl?: unknown } | null; - }; - // Codex reports seconds; everything else here is milliseconds. - const at = - typeof thread.updatedAt === "number" ? thread.updatedAt * 1000 : 1; - if (typeof thread.cwd === "string" && thread.cwd.length > 0) { - byPath.set(thread.cwd, Math.max(byPath.get(thread.cwd) ?? 0, at)); - } - const origin = normalizeOrigin( - typeof thread.gitInfo?.originUrl === "string" - ? thread.gitInfo.originUrl - : null, - ); - if (origin) { - byOrigin.set(origin, Math.max(byOrigin.get(origin) ?? 0, at)); - } - } - - return { byPath, byOrigin }; -} - -export interface DiscoverReposArgs { - maxDepth: number; - sinceDays: number; - limit: number; - /** Injectable root for tests. */ - home?: string; - env?: NodeJS.ProcessEnv; - /** Injectable recency reference for tests; operation budgets use wall time. */ - now?: number; -} - -export async function discoverRepos( - args: DiscoverReposArgs, -): Promise { - const home = args.home ?? homedir(); - const now = args.now ?? Date.now(); - const env = args.env ?? process.env; - - const { repos, truncated } = await walkForRepos( - home, - args.maxDepth, - Date.now() + WALK_BUDGET_MS, - ); - - // Ranking hints are best-effort in every direction: a missing file, an - // uninstalled `codex`, a spawn failure, or a protocol that does not know - // `thread/list` must never fail discovery. - const [claudeSeen, codexSeen] = await Promise.all([ - readClaudeHistory(home).catch(() => new Map()), - readCodexHistory(env, AGENT_HISTORY_BUDGET_MS).catch(() => ({ - byPath: new Map(), - byOrigin: new Map(), - })), - ]); - - const cutoff = now - args.sinceDays * 86_400_000; - - /** - * Strongest agent signal for a repo. Claude Code reports no timestamp, so a - * hit there scores `SEEN_NO_TIME` — enough to outrank never-seen repos - * without competing with Codex's real timestamps. - */ - const SEEN_NO_TIME = 1; - const agentScore = (repoPath: string, origin: string | null): number => - Math.max( - claudeSeen.get(repoPath) ?? 0, - codexSeen.byPath.get(repoPath) ?? 0, - origin ? (codexSeen.byOrigin.get(origin) ?? 0) : 0, - ); - - const enriched = await Promise.all( - repos.map(async (repo) => { - const originUrl = await readOriginUrl(repo.path); - const score = agentScore(repo.path, normalizeOrigin(originUrl)); - const entry: DiscoveredRepo = { - path: repo.path, - name: basename(repo.path), - lastActivityAt: new Date(repo.lastActivityMs).toISOString(), - originUrl, - agentSeen: score > 0, - agentSeenAt: - score > SEEN_NO_TIME ? new Date(score).toISOString() : null, - }; - return { entry, score }; - }), - ); - - // Recency filter, then rank: repos an agent has already worked in come first - // (the strongest signal that the user wants them in bb), then local activity. - const recent = enriched.filter( - ({ entry }) => Date.parse(entry.lastActivityAt) >= cutoff, - ); - - recent.sort((left, right) => { - if (left.score > 0 !== right.score > 0) return left.score > 0 ? -1 : 1; - return ( - Date.parse(right.entry.lastActivityAt) - - Date.parse(left.entry.lastActivityAt) - ); - }); - - return { - repos: recent.slice(0, args.limit).map(({ entry }) => entry), - truncated, - }; -} diff --git a/apps/mobile/src/screens/settings/ExperimentsSettingsScreen.tsx b/apps/mobile/src/screens/settings/ExperimentsSettingsScreen.tsx index c300e0bbb6..fb4daf300b 100644 --- a/apps/mobile/src/screens/settings/ExperimentsSettingsScreen.tsx +++ b/apps/mobile/src/screens/settings/ExperimentsSettingsScreen.tsx @@ -27,12 +27,6 @@ const EXPERIMENT_ROWS: readonly ExperimentRow[] = [ description: "Pair the bb mobile app over bb connect: shows Add mobile device under Remote access (web and desktop) and enables bb connect machine-code.", }, - { - key: "newOnboarding", - label: "New onboarding", - description: - "Enable the new first-run guide for agent setup and project selection (web and desktop).", - }, { key: "providerSessionReaping", label: "Idle provider session release", diff --git a/apps/server/src/routes/system.ts b/apps/server/src/routes/system.ts index b030dac80c..8538e48e29 100644 --- a/apps/server/src/routes/system.ts +++ b/apps/server/src/routes/system.ts @@ -38,11 +38,7 @@ import { listSystemProviderInfos, resolveSystemExecutionOptions, } from "../services/system/execution-options.js"; -import { - getOnboardingAgentOverview, - getOnboardingRepos, - recordOnboardingEvent, -} from "../services/system/onboarding.js"; +import { getOnboardingAgentOverview } from "../services/system/onboarding.js"; import { getProviderUsageLimits } from "../services/system/usage-limits.js"; import { listCustomThemeNames, @@ -334,19 +330,10 @@ export function registerSystemRoutes( }); }); - post(routes.onboardingEvent, async (context, body) => { - recordOnboardingEvent(deps, body); - return context.json({ ok: true } as const); - }); - get(routes.onboardingAgents, async (context, query) => context.json(await getOnboardingAgentOverview(deps, query)), ); - get(routes.onboardingRepos, async (context, query) => - context.json(await getOnboardingRepos(deps, query)), - ); - get(routes.usageLimits, async (context, query) => context.json(await getProviderUsageLimits(deps, query)), ); diff --git a/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md index 371a83352f..5fb5217435 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md +++ b/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md @@ -107,10 +107,6 @@ message agents, or inspect projects, providers, and environments. specific connected machine instead of the primary machine. - Extensions provides the unified Skills and Plugins management UI, while Automations stays in the Plugins section beside threads. -- The default-off `newOnboarding` experiment exposes the first-run agent and - project setup guide. Change it with - `bb settings experiment newOnboarding `. Use - `bb settings replay-onboarding` to enable it and show the guide again. - The default-off `changelogPreview` experiment shows the latest release notes on Settings → Updates. Change it with `bb settings experiment changelogPreview `. diff --git a/apps/server/src/services/skills/builtin-skills/bb-cli/references/app-settings.md b/apps/server/src/services/skills/builtin-skills/bb-cli/references/app-settings.md index b87d9d56fd..5e4ccd2e48 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-cli/references/app-settings.md +++ b/apps/server/src/services/skills/builtin-skills/bb-cli/references/app-settings.md @@ -7,8 +7,7 @@ every window and client sees the same value. - `bb settings general ` accepts any key listed under `generalSettings` in `bb settings show`. Boolean preferences take `true`, - `false`, `on`, or `off`; `null` clears a preference that can be unset, such - as `onboardingCompletedAt`. + `false`, `on`, or `off`; `null` clears a preference that can be unset. - Unknown keys and values of the wrong shape are rejected; the error names the keys bb knows. @@ -51,13 +50,6 @@ every window and client sees the same value. stays a newline; iPadOS WebKit preserves the Enter shortcuts for a connected Magic Keyboard. -## New onboarding - -- The `newOnboarding` experiment defaults to false. -- Enable it with `bb settings experiment newOnboarding true`. -- Use `bb settings replay-onboarding` to enable the experiment and show the - agent and project setup guide again. - ## Mobile app - The `mobileApp` experiment defaults to false while the bb mobile app is in diff --git a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md index 794fabf2a5..f91b1b529e 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md +++ b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md @@ -580,22 +580,22 @@ that need the singleton personal project use methods, not their arguments — read the bundled `bb-plugin-sdk.d.ts` for exact signatures (see "Looking up the exact API"). -| Area | Methods | -| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Area | Methods | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `threads` | `list` `get` `search` `spawn` `fork` `send` `update` `delete` `stop` `compact` `wait` `open` `output` `timeline` `conversationOutline` `promptHistory` `archive` `archiveAll` `unarchive` `pin` `unpin` `reorderPinned` `markRead` `markUnread` `childSummary` `paneAction` `timelineTurnSummaryDetails` `storageFiles` `storagePaths` `cancelPlan` `clearGoal` `defaultExecutionOptions`; sub-areas `events` (`list` `wait`), `interactions` (`get` `list` `cancel` `resolve` `respond`), `queuedMessages` (`create` `list` `update` `delete` `send` `reorder` `setGroupBoundary`), `tabs` (`get` `update`) | -| `threadSections` | `list` `create` `update` `delete` | -| `projects` | `list` `get` `create` `update` `delete` `reorder` `paths` `files` `fileContent` `branches` `commands` `defaultExecutionOptions` `promptHistory`; sub-areas `attachments` (`upload` `read` `copy`), `sources` (`add` `update` `delete`) | -| `environments` | `get` `update` `status` `paths` `commit` `archiveThreads` `diff` `diffFile` `diffFiles` `diffBranches` `diffPatch` `pullRequest` `markPullRequestDraft` `markPullRequestReady` `mergePullRequest` `squashMerge` | -| `hosts` | `list` `get` `update` `delete` `directory` `pathsExist` `pickFolder` `cloneDefaultPath` `createJoinCode` `retryUpdate` `providerCliStatus` `installProviderCli` | -| `files` | `read` `write` `list` `listPaths` `mkdir` `move` `remove` `createPreview` | -| `terminals` | `list` `create` `get` `input` `output` `resize` `rename` `restart` `close` | -| `providers` | `list` `models` | -| `skills` | `list` `listFiles` `getContent` `update` `remove`; sub-area `registry` (`search` `get` `detail` `install` `repositoryStars`) | -| `plugins` | `list` `install` `remove` `enable` `disable` `reload` `token` `callRpc` `getSource` `getSettings` `updateSettings` `checkUpdates` `listUpdateResults` `applyUpdate`; sub-area `catalog` (`search` `status` `install`) | -| `theme` | `get` `catalog` `set` | -| `status` | `get` | -| `system` | `version` `config` `reloadConfig` `attention` `usageLimits` `executionOptions` `transcribeVoice` `updateGeneralSettings` `updateKeyboardSettings` `updateExperiments` `cliSkillsStatus` `installCliSkills` `onboardingAgents` `onboardingRepos` `onboardingEvent` | -| `guide` | `render` (the `bb guide` text; local, no request) | +| `threadSections` | `list` `create` `update` `delete` | +| `projects` | `list` `get` `create` `update` `delete` `reorder` `paths` `files` `fileContent` `branches` `commands` `defaultExecutionOptions` `promptHistory`; sub-areas `attachments` (`upload` `read` `copy`), `sources` (`add` `update` `delete`) | +| `environments` | `get` `update` `status` `paths` `commit` `archiveThreads` `diff` `diffFile` `diffFiles` `diffBranches` `diffPatch` `pullRequest` `markPullRequestDraft` `markPullRequestReady` `mergePullRequest` `squashMerge` | +| `hosts` | `list` `get` `update` `delete` `directory` `pathsExist` `pickFolder` `cloneDefaultPath` `createJoinCode` `retryUpdate` `providerCliStatus` `installProviderCli` | +| `files` | `read` `write` `list` `listPaths` `mkdir` `move` `remove` `createPreview` | +| `terminals` | `list` `create` `get` `input` `output` `resize` `rename` `restart` `close` | +| `providers` | `list` `models` | +| `skills` | `list` `listFiles` `getContent` `update` `remove`; sub-area `registry` (`search` `get` `detail` `install` `repositoryStars`) | +| `plugins` | `list` `install` `remove` `enable` `disable` `reload` `token` `callRpc` `getSource` `getSettings` `updateSettings` `checkUpdates` `listUpdateResults` `applyUpdate`; sub-area `catalog` (`search` `status` `install`) | +| `theme` | `get` `catalog` `set` | +| `status` | `get` | +| `system` | `version` `config` `reloadConfig` `attention` `usageLimits` `executionOptions` `transcribeVoice` `updateGeneralSettings` `updateKeyboardSettings` `updateExperiments` `cliSkillsStatus` `installCliSkills` `onboardingAgents` | +| `guide` | `render` (the `bb guide` text; local, no request) | Prefer your own `bb.settings` and `bb.storage` over `sdk.system` and `sdk.plugins` for your plugin's own configuration. The `system` and `plugins` diff --git a/apps/server/src/services/system/onboarding.ts b/apps/server/src/services/system/onboarding.ts index fcf1ead349..86b27902dd 100644 --- a/apps/server/src/services/system/onboarding.ts +++ b/apps/server/src/services/system/onboarding.ts @@ -1,22 +1,12 @@ -import type { - DiscoverReposResult, - ProviderCliKey, - ProviderUsage, -} from "@bb/host-daemon-contract"; +import type { ProviderCliKey, ProviderUsage } from "@bb/host-daemon-contract"; import type { OnboardingAgent, - OnboardingTelemetryEvent, OnboardingAgentOverview, SystemProvidersQuery, - SystemOnboardingReposQuery, } from "@bb/server-contract"; import type { AppDeps } from "../../types.js"; import { COMMAND_TIMEOUT_MS } from "../../constants.js"; import { callHostRetryableOnlineRpc } from "../hosts/online-rpc.js"; -import { - assertUsableHostId, - requirePrimaryHostId, -} from "../hosts/primary-host.js"; import { resolveSystemLookupHostId } from "./host-lookup.js"; import { KNOWN_ACP_AGENTS, @@ -198,70 +188,3 @@ export async function getOnboardingAgentOverview( return { agents }; } - -export async function getOnboardingRepos( - deps: AppDeps, - query: SystemOnboardingReposQuery, -): Promise { - const hostId = query.hostId ?? requirePrimaryHostId(deps); - assertUsableHostId(deps, { hostId }); - return callHostRetryableOnlineRpc(deps, { - hostId, - timeoutMs: COMMAND_TIMEOUT_MS, - command: { - type: "workspace.discover_repos", - maxDepth: 5, - sinceDays: 30, - limit: 20, - }, - }); -} - -/** - * Forward one onboarding funnel event to anonymous telemetry. The client sends - * a typed event; the mapping to PostHog's snake_case property names lives here - * so the wire contract stays independent of the analytics schema. - */ -export function recordOnboardingEvent( - deps: AppDeps, - event: OnboardingTelemetryEvent, -): void { - switch (event.name) { - case "onboarding_started": - deps.telemetry.capture({ - name: "onboarding_started", - properties: { - agent_state: event.agentState, - detected_agent_count: event.detectedAgentCount, - }, - }); - return; - case "onboarding_step_completed": - deps.telemetry.capture({ - name: "onboarding_step_completed", - properties: { step: event.step }, - }); - return; - case "onboarding_step_skipped": - deps.telemetry.capture({ - name: "onboarding_step_skipped", - properties: { step: event.step }, - }); - return; - case "onboarding_completed": - deps.telemetry.capture({ - name: "onboarding_completed", - properties: { - agent_state: event.agentState, - projects_added: event.projectsAdded, - duration_ms: event.durationMs, - }, - }); - return; - case "onboarding_dismissed": - deps.telemetry.capture({ - name: "onboarding_dismissed", - properties: { step: event.step }, - }); - } -} diff --git a/apps/server/src/services/system/telemetry.ts b/apps/server/src/services/system/telemetry.ts index 9706caa6c1..d9b62e55f9 100644 --- a/apps/server/src/services/system/telemetry.ts +++ b/apps/server/src/services/system/telemetry.ts @@ -30,42 +30,8 @@ const TELEMETRY_ID_FILE_NAME = "telemetry-id"; const telemetryAppSurfaceStorage = new AsyncLocalStorage(); -/** - * Which coding agents the machine had when onboarding opened. Answers "how many - * installs have no compatible CLI" directly: count distinct install ids with - * `onboarding_started` where `agent_state = none`. - */ -export type OnboardingAgentState = "connected" | "signed_out" | "none"; - export type TelemetryEvent = | { name: "app_started" } - | { - name: "onboarding_started"; - properties: { - agent_state: OnboardingAgentState; - detected_agent_count: number; - }; - } - | { - name: "onboarding_step_completed"; - properties: { step: "agents" | "projects" }; - } - | { - name: "onboarding_step_skipped"; - properties: { step: "agents" | "projects" }; - } - | { - name: "onboarding_completed"; - properties: { - agent_state: OnboardingAgentState; - projects_added: number; - duration_ms: number; - }; - } - | { - name: "onboarding_dismissed"; - properties: { step: "agents" | "projects" }; - } | { name: "thread_created"; properties: { diff --git a/apps/server/test/system/experiments.test.ts b/apps/server/test/system/experiments.test.ts index 2d2d791afc..4a7d520cea 100644 --- a/apps/server/test/system/experiments.test.ts +++ b/apps/server/test/system/experiments.test.ts @@ -17,7 +17,6 @@ describe("experiments settings", () => { changelogPreview: false, editMessages: true, mobileApp: false, - newOnboarding: false, providerSessionReaping: false, }); }); @@ -32,7 +31,6 @@ describe("experiments settings", () => { changelogPreview: true, editMessages: true, mobileApp: true, - newOnboarding: true, providerSessionReaping: true, }), }); @@ -41,14 +39,12 @@ describe("experiments settings", () => { changelogPreview: true, editMessages: true, mobileApp: true, - newOnboarding: true, providerSessionReaping: true, }); expect(getExperiments(harness.db)).toEqual({ changelogPreview: true, editMessages: true, mobileApp: true, - newOnboarding: true, providerSessionReaping: true, }); @@ -59,7 +55,6 @@ describe("experiments settings", () => { changelogPreview: true, editMessages: true, mobileApp: true, - newOnboarding: true, providerSessionReaping: true, }); }); @@ -86,7 +81,6 @@ describe("experiments settings", () => { changelogPreview: false, editMessages: true, mobileApp: false, - newOnboarding: false, providerSessionReaping: true, }), }); @@ -111,7 +105,6 @@ describe("experiments settings", () => { changelogPreview: false, editMessages: false, mobileApp: false, - newOnboarding: false, providerSessionReaping: false, }), }); diff --git a/docs/configuration.md b/docs/configuration.md index fdf9099a77..ac59e8fae4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -635,8 +635,7 @@ plugin enabled; with the experiment off the panel hides the section and Experimental surfaces are changed in Settings → Experiments or with `bb settings experiment `. Most start off; `editMessages` -starts on and its toggle is the opt-out. The `newOnboarding` experiment exposes -the first-run agent and project setup guide. +starts on and its toggle is the opt-out. The default-off `changelogPreview` experiment shows the latest release notes as a compact, dismissible card on Settings → Updates. The `editMessages` experiment is on by default and enables replacing an diff --git a/packages/db/test/experiments.test.ts b/packages/db/test/experiments.test.ts index 367a78ba4a..e2d160d6e2 100644 --- a/packages/db/test/experiments.test.ts +++ b/packages/db/test/experiments.test.ts @@ -17,7 +17,7 @@ describe("experiments", () => { const experiments = { ...defaultExperiments, - newOnboarding: true, + mobileApp: true, }; setExperiments(db, experiments); db.$client @@ -39,7 +39,6 @@ describe("experiments", () => { "editMessages", "futureExperiment", "mobileApp", - "newOnboarding", "providerSessionReaping", ]); } finally { diff --git a/packages/db/test/migrate.test.ts b/packages/db/test/migrate.test.ts index 57aa4d4609..152b3519f8 100644 --- a/packages/db/test/migrate.test.ts +++ b/packages/db/test/migrate.test.ts @@ -620,9 +620,6 @@ function dropSteerActiveThreadOnEnterColumn(db: DbConnection): void { } } -// Journal `when` for 0085, used to rewind exactly that migration. -const onboardingMigrationWhen = 1785947206119; - // Migration 0085 adds the onboarding completion timestamp. Rewind scenarios // that clear its migration row must drop the column before replay, for the same // reason as the preference column above: ALTER TABLE ADD is not re-appliable. @@ -1588,7 +1585,6 @@ describe("migrate", () => { codexSubagentsDisabled: true, claudeCodeSubagentsDisabled: false, claudeCodeWorkflowsDisabled: true, - onboardingCompletedAt: "2026-08-01T00:00:00.000Z", }); expect(getAppKeybindingOverrides(db)).toEqual([ { command: "thread.new", shortcut: null }, @@ -1683,72 +1679,6 @@ describe("migrate", () => { // Side chats used to be their own origin kind. 0084 hands every existing one // to the builtin side-chat plugin, so old side chats keep opening in the // plugin's panel instead of stranding on a removed origin kind. - it("stamps existing installs as onboarded so upgrades skip the first-run flow", () => { - const db = createConnection(":memory:"); - migrate(db); - - // Rewind 0085 so it replays against an install that already has a project — - // exactly what an upgrading user's database looks like. - restoreWideExperimentsTable(db); - dropOnboardingCompletedAtColumn(db); - dropAppSettingsValuesTable(db); - dropNewOnboardingExperimentColumn(db); - dropEnvironmentRetireRequestedAtColumn(db); - dropPluginArtifactGitCheckoutRootColumn(db); - dropMarketplaceCatalogSchema(db); - dropEventParentToolCallIdColumn(db); - // Delete by the journal timestamp, not a hash substring: migration hashes - // are hex and can contain "0085" by coincidence. - db.$client - .prepare< - [number] - >("DELETE FROM __drizzle_migrations WHERE created_at >= ?") - .run(onboardingMigrationWhen); - db.$client - .prepare( - "INSERT INTO projects (id, name, created_at, updated_at, sort_key, kind) VALUES ('proj_a','app',1,1,'V','standard')", - ) - .run(); - // Deliberately no app_settings row: it is created on first save, so an - // existing user can have projects without one. - db.$client.prepare("DELETE FROM app_settings").run(); - - restoreLegacyThreadOriginColumn(db); - migrate(db); - - const row = db.$client - .prepare< - [], - { onboarding_completed_at: string | null } - >("SELECT onboarding_completed_at FROM app_settings WHERE id = 'current'") - .get(); - // Non-null means the flow will not open for this install. - expect(row?.onboarding_completed_at).toBeTruthy(); - - closeConnection(db); - }); - - it("leaves a fresh install unstamped so onboarding opens", () => { - const db = createConnection(":memory:"); - migrate(db); - - db.$client - .prepare( - "INSERT OR REPLACE INTO app_settings (id, updated_at) VALUES ('current', 1)", - ) - .run(); - - const row = db.$client - .prepare< - [], - { onboarding_completed_at: string | null } - >("SELECT onboarding_completed_at FROM app_settings WHERE id = 'current'") - .get(); - expect(row?.onboarding_completed_at).toBeNull(); - - closeConnection(db); - }); - it("adopts legacy side chats as the side-chat plugin's hidden forks", () => { const db = createConnection(":memory:"); diff --git a/packages/domain/src/app-settings.ts b/packages/domain/src/app-settings.ts index 2a7ab7d27b..88fb35daf1 100644 --- a/packages/domain/src/app-settings.ts +++ b/packages/domain/src/app-settings.ts @@ -29,16 +29,6 @@ export const appSettingsSchema = z claudeCodeSubagentsDisabled: z.boolean(), /** Prevent Claude Code from exposing its native Workflow tool. */ claudeCodeWorkflowsDisabled: z.boolean(), - /** - * ISO timestamp of when first-run onboarding last finished or was - * dismissed; null means it has never run. A timestamp rather than a boolean - * so we also know *when*, and so "never ran" has an honest value. - * - * Deliberately not a proxy for "is bb set up": whether an agent is usable is - * answered live by `provider.usage`, so dismissing onboarding never claims - * the machine is configured. Setting this back to null re-triggers the flow. - */ - onboardingCompletedAt: z.string().nullable(), }) .strict(); export type AppSettings = z.infer; @@ -52,5 +42,4 @@ export const defaultAppSettings: AppSettings = { codexSubagentsDisabled: false, claudeCodeSubagentsDisabled: false, claudeCodeWorkflowsDisabled: false, - onboardingCompletedAt: null, }; diff --git a/packages/domain/src/experiments.ts b/packages/domain/src/experiments.ts index 1f68578c7e..752b7e5982 100644 --- a/packages/domain/src/experiments.ts +++ b/packages/domain/src/experiments.ts @@ -14,7 +14,6 @@ export const experimentKeys = [ "changelogPreview", "editMessages", "mobileApp", - "newOnboarding", "providerSessionReaping", ] as const; export const experimentKeySchema = z.enum(experimentKeys); @@ -31,6 +30,5 @@ export const defaultExperiments: Experiments = { changelogPreview: false, editMessages: true, mobileApp: false, - newOnboarding: false, providerSessionReaping: false, }; diff --git a/packages/host-daemon-contract/src/commands.ts b/packages/host-daemon-contract/src/commands.ts index 5b51d713df..301679b91c 100644 --- a/packages/host-daemon-contract/src/commands.ts +++ b/packages/host-daemon-contract/src/commands.ts @@ -1586,49 +1586,6 @@ const providerUsageCommandSchema = z .object({ type: z.literal("provider.usage") }) .strict(); -/** - * One candidate project found on the host. `agentSeenAt` is set when a - * supported coding agent has been run here (or in another checkout of the same - * repo); it is a ranking hint only, never the reason a repo is listed. - */ -export const discoveredRepoSchema = z - .object({ - path: z.string().min(1), - name: z.string().min(1), - /** Last local activity, from `.git/HEAD` mtime. */ - lastActivityAt: z.string(), - /** Remote URL when the repo has one; used to collapse worktrees. */ - originUrl: z.string().nullable(), - /** True when a supported agent has been run here (or in a sibling checkout). */ - agentSeen: z.boolean(), - /** - * When that last agent session was, if the source reported a time. Claude - * Code's history carries no timestamp, so `agentSeen` can be true while - * this stays null. - */ - agentSeenAt: z.string().nullable(), - }) - .strict(); -export type DiscoveredRepo = z.infer; - -export const discoverReposResultSchema = z - .object({ - repos: z.array(discoveredRepoSchema), - /** True when the walk hit its time budget and results may be partial. */ - truncated: z.boolean(), - }) - .strict(); -export type DiscoverReposResult = z.infer; - -const discoverReposCommandSchema = z - .object({ - type: z.literal("workspace.discover_repos"), - maxDepth: z.number().int().min(1).max(8), - sinceDays: z.number().int().min(1).max(3650), - limit: z.number().int().min(1).max(200), - }) - .strict(); - const providerCliStatusCommandSchema = z .object({ type: z.literal("provider_cli.status") }) .strict(); @@ -2131,15 +2088,6 @@ export const hostDaemonCommandRegistry = { flushEventsBeforeResult: false, envLane: null, }), - "workspace.discover_repos": defineHostDaemonCommandDescriptor({ - type: "workspace.discover_repos", - schema: discoverReposCommandSchema, - resultSchema: discoverReposResultSchema, - transport: "onlineRpc", - retryable: true, - flushEventsBeforeResult: false, - envLane: null, - }), "provider_cli.status": defineHostDaemonCommandDescriptor({ type: "provider_cli.status", schema: providerCliStatusCommandSchema, diff --git a/packages/host-daemon-contract/src/protocol.ts b/packages/host-daemon-contract/src/protocol.ts index cc21f1f85b..4bc39f147a 100644 --- a/packages/host-daemon-contract/src/protocol.ts +++ b/packages/host-daemon-contract/src/protocol.ts @@ -1,3 +1,8 @@ +// Version 138 removes the `workspace.discover_repos` command. It existed only +// for the first-run onboarding flow's project step, which is deleted; no server +// sends it any more. A newer daemon no longer answers it, so an older server +// paired with a new daemon would fail that command instead of returning repos. +// // Version 137 removes the `claudeCodeMockCliTraffic` runtime option and the // Claude Code mock CLI traffic experiment behind it. Current servers no longer // send the field, and current bridges no longer accept it. @@ -55,7 +60,7 @@ // // The version mismatch is what triggers the enrolled daemon's automatic update // instead of an `invalid-message` reconnect loop. -export const HOST_DAEMON_PROTOCOL_VERSION = 137 as const; +export const HOST_DAEMON_PROTOCOL_VERSION = 138 as const; /** * Absolute ceiling for any executable artifact delivered to a host daemon — diff --git a/packages/host-daemon-contract/src/session.ts b/packages/host-daemon-contract/src/session.ts index 3f97032b18..a3595c5fa7 100644 --- a/packages/host-daemon-contract/src/session.ts +++ b/packages/host-daemon-contract/src/session.ts @@ -459,7 +459,6 @@ const hostDaemonOnlineRpcResponseSuccessSchema = z.discriminatedUnion( onlineRpcResponseSuccessSchemaFor("provider.usage"), onlineRpcResponseSuccessSchemaFor("provider_cli.status"), onlineRpcResponseSuccessSchemaFor("provider_cli.install"), - onlineRpcResponseSuccessSchemaFor("workspace.discover_repos"), onlineRpcResponseSuccessSchemaFor("workspace.status"), onlineRpcResponseSuccessSchemaFor("workspace.diff"), onlineRpcResponseSuccessSchemaFor("workspace.diffFiles"), diff --git a/packages/host-daemon-contract/test/contract.test.ts b/packages/host-daemon-contract/test/contract.test.ts index 926b933078..dd30f92ac4 100644 --- a/packages/host-daemon-contract/test/contract.test.ts +++ b/packages/host-daemon-contract/test/contract.test.ts @@ -442,19 +442,6 @@ const ONLINE_RPC_RESPONSE_RESULT_FIXTURES: OnlineRpcResponseResultFixtures = { }, ], }, - "workspace.discover_repos": { - repos: [ - { - path: "/home/user/projects/bb", - name: "bb", - lastActivityAt: "2026-08-05T00:00:00.000Z", - originUrl: "https://github.com/example/bb", - agentSeen: true, - agentSeenAt: "2026-08-04T00:00:00.000Z", - }, - ], - truncated: false, - }, "workspace.status": WORKSPACE_UNAVAILABLE_RESULT, "workspace.diff": WORKSPACE_UNAVAILABLE_RESULT, "workspace.diffFiles": WORKSPACE_UNAVAILABLE_RESULT, @@ -1147,7 +1134,7 @@ describe("host-daemon command schemas", () => { // mixed version. Version 113 carried the Devin Desktop open target rename // and remains part of the protocol lineage. it("uses the current host-daemon protocol version", () => { - expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(137); + expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(138); expect(HOST_ARTIFACT_MAX_BYTES).toBe(256 * 1024 * 1024); }); diff --git a/packages/sdk/src/areas/system.ts b/packages/sdk/src/areas/system.ts index 861f73cc32..60167eba94 100644 --- a/packages/sdk/src/areas/system.ts +++ b/packages/sdk/src/areas/system.ts @@ -3,10 +3,7 @@ import type { AppSettings, Experiments, } from "@bb/domain"; -import type { - DiscoverReposResult, - ProviderUsageResponse, -} from "@bb/host-daemon-contract"; +import type { ProviderUsageResponse } from "@bb/host-daemon-contract"; import type { SystemAttentionResponse, SystemConfigReloadResponse, @@ -17,9 +14,7 @@ import type { SystemInstallCliSkillsRequest, SystemInstallCliSkillsResponse, OnboardingAgentOverview, - OnboardingTelemetryEvent, SystemProvidersQuery, - SystemOnboardingReposQuery, SystemUsageLimitsQuery, SystemVersionQuery, SystemVersionResponse, @@ -75,11 +70,7 @@ export type SystemUsageLimitsResult = ProviderUsageResponse; export interface SystemOnboardingArgs extends SystemProvidersQuery { signal?: AbortSignal; } -export interface SystemOnboardingReposArgs extends SystemOnboardingReposQuery { - signal?: AbortSignal; -} export type SystemOnboardingAgentsResult = OnboardingAgentOverview; -export type SystemOnboardingReposResult = DiscoverReposResult; export type SystemVersionResult = SystemVersionResponse; export interface SystemArea { @@ -111,16 +102,10 @@ export interface SystemArea { updateKeyboardSettings( args: AppKeybindingOverrides, ): Promise; - /** Report one onboarding funnel event to anonymous telemetry. */ - onboardingEvent(args: OnboardingTelemetryEvent): Promise<{ ok: true }>; /** Live agent state for onboarding: install, auth, and plan per provider. */ onboardingAgents( args?: SystemOnboardingArgs, ): Promise; - /** Candidate projects discovered on the host, ranked for onboarding. */ - onboardingRepos( - args?: SystemOnboardingReposArgs, - ): Promise; usageLimits(args?: SystemUsageLimitsArgs): Promise; version(args?: SystemVersionArgs): Promise; } @@ -219,11 +204,6 @@ export function createSystemArea(args: CreateSdkAreaArgs): SystemArea { transport.api.v1.settings.keyboard.$put({ json: input }), ); }, - async onboardingEvent(input) { - return transport.readJson( - transport.api.v1.system.onboarding.event.$post({ json: input }), - ); - }, async onboardingAgents(input = {}) { return transport.readJson( transport.api.v1.system.onboarding.agents.$get( @@ -237,14 +217,6 @@ export function createSystemArea(args: CreateSdkAreaArgs): SystemArea { ), ); }, - async onboardingRepos(input = {}) { - return transport.readJson( - transport.api.v1.system.onboarding.repos.$get( - { query: { hostId: input.hostId } }, - ...signalRequestArgs(input.signal), - ), - ); - }, async usageLimits(input = {}) { return transport.readJson( transport.api.v1.system["usage-limits"].$get( diff --git a/packages/sdk/test/public-types.test.ts b/packages/sdk/test/public-types.test.ts index cd1ee49cca..a25253ab4e 100644 --- a/packages/sdk/test/public-types.test.ts +++ b/packages/sdk/test/public-types.test.ts @@ -342,8 +342,6 @@ type ExpectedSystemKey = | "updateGeneralSettings" | "updateKeyboardSettings" | "onboardingAgents" - | "onboardingEvent" - | "onboardingRepos" | "usageLimits" | "version"; diff --git a/packages/server-contract/src/api/system.ts b/packages/server-contract/src/api/system.ts index 790aaa37e4..17fa20b5f5 100644 --- a/packages/server-contract/src/api/system.ts +++ b/packages/server-contract/src/api/system.ts @@ -152,48 +152,6 @@ export type OnboardingAgentOverview = z.infer< typeof onboardingAgentOverviewSchema >; -/** Omission reads the primary machine, matching the usage-limits route. */ -export const systemOnboardingReposQuerySchema = z.object({ - hostId: z.string().min(1).optional(), -}); -export type SystemOnboardingReposQuery = z.infer< - typeof systemOnboardingReposQuerySchema ->; - -/** - * Onboarding funnel events, reported by the app and forwarded to the server's - * anonymous telemetry. Categorical or counts only — never paths, project names, - * or account emails. - */ -export const onboardingTelemetryEventSchema = z.discriminatedUnion("name", [ - z.object({ - name: z.literal("onboarding_started"), - agentState: z.enum(["connected", "signed_out", "none"]), - detectedAgentCount: z.number().int().min(0), - }), - z.object({ - name: z.literal("onboarding_step_completed"), - step: z.enum(["agents", "projects"]), - }), - z.object({ - name: z.literal("onboarding_step_skipped"), - step: z.enum(["agents", "projects"]), - }), - z.object({ - name: z.literal("onboarding_completed"), - agentState: z.enum(["connected", "signed_out", "none"]), - projectsAdded: z.number().int().min(0), - durationMs: z.number().int().min(0), - }), - z.object({ - name: z.literal("onboarding_dismissed"), - step: z.enum(["agents", "projects"]), - }), -]); -export type OnboardingTelemetryEvent = z.infer< - typeof onboardingTelemetryEventSchema ->; - export const systemConfigResponseSchema = z.object({ /** App-wide Settings → General preferences, persisted server-side. */ generalSettings: appSettingsSchema, diff --git a/packages/server-contract/src/public-api.ts b/packages/server-contract/src/public-api.ts index 648eb2c9a3..54f7f6f1a2 100644 --- a/packages/server-contract/src/public-api.ts +++ b/packages/server-contract/src/public-api.ts @@ -21,10 +21,7 @@ import { appThemeSelectionSchema, experimentsSchema, } from "@bb/domain"; -import type { - DiscoverReposResult, - ProviderUsageResponse, -} from "@bb/host-daemon-contract"; +import type { ProviderUsageResponse } from "@bb/host-daemon-contract"; import { binaryResponse, defineRoute, @@ -155,8 +152,6 @@ import type { SystemProviderInfo, SystemProvidersQuery, OnboardingAgentOverview, - OnboardingTelemetryEvent, - SystemOnboardingReposQuery, SystemUsageLimitsQuery, SystemVersionQuery, SystemVersionResponse, @@ -280,8 +275,6 @@ import { sendQueuedMessageRequestSchema, systemExecutionOptionsQuerySchema, systemProvidersQuerySchema, - onboardingTelemetryEventSchema, - systemOnboardingReposQuerySchema, systemUsageLimitsQuerySchema, systemVersionQuerySchema, threadEventWaitQuerySchema, @@ -1406,14 +1399,6 @@ export const publicApiRoutes = { request: noRequest(), response: binaryResponse(), }), - onboardingEvent: defineRoute({ - path: "/system/onboarding/event", - method: "post", - request: jsonRequest( - onboardingTelemetryEventSchema, - ), - response: jsonResponse<{ ok: true }>(), - }), onboardingAgents: defineRoute({ path: "/system/onboarding/agents", method: "get", @@ -1422,14 +1407,6 @@ export const publicApiRoutes = { ), response: jsonResponse(), }), - onboardingRepos: defineRoute({ - path: "/system/onboarding/repos", - method: "get", - request: optionalQueryRequest( - systemOnboardingReposQuerySchema, - ), - response: jsonResponse(), - }), usageLimits: defineRoute({ path: "/system/usage-limits", method: "get", diff --git a/packages/templates/src/templates/bb-guide-customization.md b/packages/templates/src/templates/bb-guide-customization.md index 521bc9546a..b9dab2f075 100644 --- a/packages/templates/src/templates/bb-guide-customization.md +++ b/packages/templates/src/templates/bb-guide-customization.md @@ -91,7 +91,6 @@ Magic Keyboard. bb settings show bb settings general - bb settings replay-onboarding bb settings experiment bb settings usage [--machine ] bb settings version [--force] @@ -101,13 +100,6 @@ Magic Keyboard. `bb settings show`. Boolean preferences take `true`, `false`, `on`, or `off`, and `null` clears a preference that can be unset. -`bb settings replay-onboarding` enables the `newOnboarding` experiment and -clears `onboardingCompletedAt`. The first-run setup guide then shows again on -the next app load. The same button lives in Settings → General → Setup guide -while the experiment is on. - -The `newOnboarding` experiment exposes the first-run agent and project setup -guide. The default-off `changelogPreview` experiment shows the latest release notes as a compact, dismissible card on Settings → Updates. The default-on `editMessages` experiment enables editing eligible, accepted From b6cd01f85895581cacae270368a174ccb43c627e Mon Sep 17 00:00:00 2001 From: Andrew Chan Date: Wed, 19 Aug 2026 22:54:33 -0700 Subject: [PATCH 003/232] Bold option titles in pickers to distinguish them from descriptions (#2003) --- apps/app/src/components/pickers/OptionPicker.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/app/src/components/pickers/OptionPicker.tsx b/apps/app/src/components/pickers/OptionPicker.tsx index 3c1c24be7d..937c6cecd0 100644 --- a/apps/app/src/components/pickers/OptionPicker.tsx +++ b/apps/app/src/components/pickers/OptionPicker.tsx @@ -215,7 +215,7 @@ export function OptionPicker({ ) : null} {option.label} From 7be260d74e20ec44e7830f140e0731bcf8a41ca5 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Wed, 19 Aug 2026 22:57:36 -0700 Subject: [PATCH 004/232] Mobile: native thread header, collapsible composer, home compose dock (#2009) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What was wrong The mobile app stacked two headers on the thread screen (the native bar plus a second title / status / environment / actions block), new threads went through a separate `/compose` page, and the composer always showed its full pill rows and a context readout. Picker sheets clipped their last row under the home indicator because the `scroll` layout rendered the title outside the measured scroll view, so dynamic sizing came up short by the header height. ## What changed - **Thread screen**: one native header — title (tap → rename) with a status subtitle only while working / needs input / error, panel + `…` on the right. Environment line, child roll-up, Workspace and the git action live in the `…` sheet (`leadingActions` / `headerDetail`). Table of contents removed end to end. - **Composer**: `collapsible` / `topControls` / `onExpandedChange`. Collapsed = `[+] placeholder [mic | Stop while running]`; expanded ⇔ focused (or has content). Sheets opened from inside report presence through `SheetPresenceContext` so the card stays open and refocuses after a picker. Context-window readout is a ring shown only at ≥60%. `PickerTrigger` is ghost by default. Pill text nudged up 3pt on iOS to centre on the buttons. - **Home**: `ComposeDock` replaces the FAB and the `/compose` route. It expands in place over a scrim; the drawer header is painted the same blend (`blendOver`) so the whole screen dims. `composeHref` → `newThreadHref` (home with params + `newThread=1`); drawer row, project "+", fork, handoff, new project, share intent and `bb://compose` all route there; home reads/clears the params. Controller re-seeds per param change and drops the dead `title` knob. Thread rows drop their timestamps (`relative-time` removed). - **Sheets**: the `scroll` layout keeps the title as a sticky first child inside `BottomSheetScrollView` so dynamic sizing includes it; the provider CLI log sheet pads `insets.bottom`. - Also removes the stale `claudeCodeMockCliTraffic` experiment row (pre-existing typecheck break). ## How you verified - `pnpm exec turbo run typecheck lint test --filter=@bb/mobile` green (812 tests; new tests for `blendOver`, `/compose` link mapping). - Maestro against the harness backend on a Debug dev client: `phase1-shell`, `phase3-compose`, `phase4a-timeline`, `phase4b-send`, `phase4b-actions`, `phase4b-thread-actions`, `phase4b-queue`, `phase4b-approve`, `phase4b-ask-user`, `phase6-panel` pass (flows updated for the moved controls and the pill-until-focused composer). - Visual checks in the iPhone 17 Pro simulator, light and dark: home pill → card, picker keeps it open, scrim + header dimming, create → thread, fork → dock with hint, thread header + menu, picker sheets with full content and bottom inset. Fixes #N/A > AGENT GENERATED: by Claude Opus 5 Co-authored-by: Claude --- apps/mobile/README.md | 30 +- apps/mobile/app/compose/index.tsx | 3 - apps/mobile/e2e/flows/phase3-compose.yaml | 9 +- apps/mobile/e2e/flows/phase4a-timeline.yaml | 16 +- apps/mobile/e2e/flows/phase4b-actions.yaml | 8 +- apps/mobile/e2e/flows/phase4b-approve.yaml | 3 + apps/mobile/e2e/flows/phase4b-ask-user.yaml | 3 + apps/mobile/e2e/flows/phase4b-queue.yaml | 3 + apps/mobile/e2e/flows/phase4b-send.yaml | 11 +- .../e2e/flows/phase4b-thread-actions.yaml | 12 +- .../src/app-shell/ShareIntentHandler.tsx | 6 +- apps/mobile/src/composer/Composer.tsx | 399 ++++++++++++------ apps/mobile/src/composer/ComposerInput.tsx | 12 +- .../mobile/src/composer/ExecutionControls.tsx | 6 +- apps/mobile/src/composer/model/actions.ts | 1 + .../src/lib/links/incoming-link.test.ts | 16 +- apps/mobile/src/lib/links/incoming-link.ts | 23 +- apps/mobile/src/markdown/colors.test.ts | 16 +- apps/mobile/src/markdown/colors.ts | 56 +++ .../src/screens/compose/ComposeDock.tsx | 315 ++++++++++++++ .../src/screens/compose/ComposeScreen.tsx | 272 ------------ .../screens/compose/ExecutionControlsRow.tsx | 111 +---- apps/mobile/src/screens/compose/index.ts | 8 +- .../screens/compose/useComposeController.ts | 40 +- apps/mobile/src/screens/home/HomeScreen.tsx | 311 +++++++++++--- apps/mobile/src/screens/index.ts | 1 - .../src/screens/machines/ProviderCliRows.tsx | 8 +- .../src/screens/panel/PanelToggleButton.tsx | 4 +- apps/mobile/src/screens/panel/README.md | 10 +- .../src/screens/pickers/PickerTrigger.tsx | 12 +- .../src/screens/projects/NewProjectScreen.tsx | 4 +- .../screens/settings/UsageLimitsScreen.tsx | 1 + .../src/screens/shell/RootNavigator.tsx | 1 - apps/mobile/src/screens/shell/hrefs.ts | 17 +- .../sidebar/SidebarActionsProvider.tsx | 20 +- .../src/screens/sidebar/SidebarRows.tsx | 9 - .../src/screens/sidebar/SidebarThreadList.tsx | 32 +- apps/mobile/src/screens/sidebar/index.ts | 1 - .../src/screens/sidebar/relative-time.test.ts | 30 -- .../src/screens/sidebar/relative-time.ts | 48 --- .../src/screens/thread/ThreadDetailHeader.tsx | 270 ++++-------- .../src/screens/thread/ThreadDetailScreen.tsx | 109 +++-- .../thread/ThreadTableOfContentsSheet.tsx | 55 --- .../thread/actions/ThreadActionsSheet.tsx | 51 ++- .../src/screens/thread/actions/index.ts | 1 + .../actions/use-message-action-handlers.ts | 8 +- .../banner/use-thread-context-banner.ts | 10 +- .../thread/cards/ThreadPromptStackCards.tsx | 76 +++- apps/mobile/src/screens/thread/index.ts | 8 +- .../thread/prompt-area/ThreadPromptArea.tsx | 5 +- .../prompt-area/follow-up-submission.ts | 4 +- .../screens/thread/timeline/TimelineList.tsx | 21 +- .../src/screens/thread/timeline/index.ts | 3 - .../screens/thread/timeline/list-entries.ts | 13 - .../src/screens/thread/timeline/rows.test.ts | 38 -- .../src/screens/thread/timeline/rows.ts | 46 -- .../screens/threads/ArchivedThreadsScreen.tsx | 8 - .../screens/threads/ThreadSearchScreen.tsx | 8 +- apps/mobile/src/ui/Sheet.tsx | 49 ++- apps/mobile/src/ui/index.ts | 1 + 60 files changed, 1420 insertions(+), 1252 deletions(-) delete mode 100644 apps/mobile/app/compose/index.tsx create mode 100644 apps/mobile/src/screens/compose/ComposeDock.tsx delete mode 100644 apps/mobile/src/screens/compose/ComposeScreen.tsx delete mode 100644 apps/mobile/src/screens/sidebar/relative-time.test.ts delete mode 100644 apps/mobile/src/screens/sidebar/relative-time.ts delete mode 100644 apps/mobile/src/screens/thread/ThreadTableOfContentsSheet.tsx diff --git a/apps/mobile/README.md b/apps/mobile/README.md index c58b2dc2c7..dcbced0747 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -13,7 +13,7 @@ thread list (drawer + home, long-press menus, organize/sort, search, archived), thread creation on the shared composer (mentions, attachments, voice, fork / handoff seeds), the thread detail screen (`/threads/[id]`: the virtualized timeline with every row kind, markdown, inline diffs, terminal output, images + -lightbox, sticky-bottom, older pages, unread divider, table of contents; the +lightbox, sticky-bottom, older pages, unread divider; the prompt area with pending-interaction banners, prompt-stack cards, the context banner, the queued-message list and the follow-up composer; header / message / git action sheets), deep links, and the workspace panel @@ -61,7 +61,9 @@ app/ Expo Router routes (thin: each file re-exports a screen registry skill + install) connect/index.tsx bb connect enrollment (QR / code) — also the re-pair target (`?profileId=`) and the `bb://connect?code=…` link - compose/index.tsx /compose?projectId=§ionId=&initialPrompt=&reuseEnvironmentId= + (the new-thread composer is the home screen's bottom + dock: `/?projectId=§ionId=&initialPrompt=&reuseEnvironmentId=` + + the fork / handoff seed params open it) projects/ new (create project), [id]/settings (rename, sources, delete), [id]/threads/[threadId] (web deep-link alias → threads/[id]) dev/ ui (gallery), diff (diff + terminal showcase), markdown @@ -89,8 +91,9 @@ src/ notifications/ push notifications arrive in a later PR (RN glue: expo-notifications behind the data-layer contract, registration sync, taps → thread, badge, Settings rows) - screens/ screen components (home/, settings/, shell/, compose/, - connect/ — bb connect enrollment: ConnectEnrollScreen, + screens/ screen components (home/ — thread list + the + new-thread ComposeDock; compose/ — ComposeDock, + useComposeController; settings/, shell/, connect/ — bb connect enrollment: ConnectEnrollScreen, ConnectScanner (expo-camera QR), AccountServersList; projects/, pickers/ — reusable picker sheets: project, provider, model+reasoning, permission mode, environment, @@ -99,10 +102,11 @@ src/ glyph, long-press action sheets, display options; threads/ — search, archived; thread/ — thread detail: ThreadDetailScreen (list + - prompt area inside KeyboardPaddingView), header, + prompt area inside KeyboardPaddingView), the native + header pieces (title + status subtitle, panel + "…"), cards/ (workflow, background commands, plan + Exit, goal + Clear, to-dos, model fallback, context-window - readout), table-of-contents sheet, prompt-area/ + ring), prompt-area/ (ThreadPromptArea: banner-or-stack + the follow-up Composer; useFollowUpComposer: draft, submit mode, send / queue / steer / stop, edit modes, quoting; @@ -131,7 +135,7 @@ src/ actions/ — MessageActionSheet + message-actions-model (copy / quote paragraph / add to chat / edit / fork / send to main), useMessageActionHandlers (fork → - /compose seed, side-chat send-to-main), + home dock seed, side-chat send-to-main), ThreadActionsSheet (header "…" menu: handoff, new thread in worktree, rename, pin, read state, move, copy link, open in web, archive, delete), @@ -328,13 +332,13 @@ cd apps/mobile && pnpm e2e:ios drawer → Settings → Server status shows realtime connected); `smoke.yaml` opens the Phase 0 diagnostics screen; `phase3-threads.yaml` exercises the thread list (rename, pin, archive, Settings → Archived → unarchive, search); -`phase3-compose.yaml` creates a thread from `/compose` -(pickers, model, environment) and exercises the New-project machine/folder +`phase3-compose.yaml` creates a thread from the home dock (`bb://compose` +→ home; pickers, model, environment) and exercises the New-project machine/folder pickers (pass `-e REPO_PARENT_DIR=` to also browse into the harness repo); `phase4a-timeline.yaml` opens the seeded "Rich thread" (the seed leaves it unread; after a run, `POST /api/v1/threads//unread` restores that), lands -on the unread divider, scrolls to the long message, and jumps back through the -table of contents. `phase4a-conversation-rows.yaml` opens +on the unread divider and scrolls to the long message. +`phase4a-conversation-rows.yaml` opens the seeded "Rows thread" (started on behalf of "Idle thread": the generated "Forked from" row, its preview/body, long-press → Copy text, and the source-thread chip) and the "Rich thread" (bubble, assistant prose, "Worked @@ -356,7 +360,7 @@ it first: a managed-worktree thread through the API with a file written into its worktree, so the context banner's changed-files row and the header git button appear), expands the banner, opens the git sheet, renames through the title, walks the "…" menu (Copy link toast), long-presses an assistant message -and forks into `/compose`. +and forks into the home dock. The Phase 4b thread-screen flows each open a thread by a fixed title that must exist on the backend (create it through the API first; Maestro ignores `-e` overrides for keys a flow's `env:` block defines): `phase4b-send.yaml` @@ -368,7 +372,7 @@ banner replaces the composer → answer → composer back, turn completes), queue": `delay:30000 first` → Stop + queue affordances → queue "second" → Send now → steered into the turn), `phase4b-actions.yaml` ("P4b actions": environment line, "…" → Rename, long-press → Copy text, Add to chat quotes -into the composer). `phase4b-composer.yaml` drives the compose screen's +into the composer). `phase4b-composer.yaml` drives the composer showcase's typeahead, pills, "+" menu and attachment chip. `phase5-links.yaml` takes `-e THREAD_ID= -e PROJECT_ID=` from the backend's startup JSON and drives the deep links while the app is warm: diff --git a/apps/mobile/app/compose/index.tsx b/apps/mobile/app/compose/index.tsx deleted file mode 100644 index 57a4753227..0000000000 --- a/apps/mobile/app/compose/index.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import { ComposeScreen } from "@/screens"; - -export default ComposeScreen; diff --git a/apps/mobile/e2e/flows/phase3-compose.yaml b/apps/mobile/e2e/flows/phase3-compose.yaml index 49d7e84311..92a6e3c53b 100644 --- a/apps/mobile/e2e/flows/phase3-compose.yaml +++ b/apps/mobile/e2e/flows/phase3-compose.yaml @@ -1,5 +1,5 @@ # Phase 3 compose: first run → add the harness server → deep link to -# /compose → pick the seeded project → open the environment picker (screenshot) +# bb://compose (home opens its dock) → pick the seeded project → open the environment picker (screenshot) # → type a prompt → Create → lands on the thread placeholder with the title → # deep link to /projects/new → the machine picker lists the harness host and # the remote path browser lists that machine's folders. @@ -38,7 +38,8 @@ env: - extendedWaitUntil: notVisible: "Connect to a bb server" timeout: 30000 -# Compose via deep link. +# Compose via deep link: home opens its bottom dock (expanded: the top +# pill row with the project picker is visible). - openLink: "bb://compose" # A fresh simulator confirms the first custom-scheme link. - runFlow: @@ -48,11 +49,11 @@ env: - tapOn: "Open" - extendedWaitUntil: visible: - id: "compose-screen" + id: "home-compose-dock" timeout: 30000 - extendedWaitUntil: visible: - id: "compose-input" + id: "compose-top-controls" timeout: 15000 # Project picker → the seeded project. - tapOn: diff --git a/apps/mobile/e2e/flows/phase4a-timeline.yaml b/apps/mobile/e2e/flows/phase4a-timeline.yaml index 996ddda3b6..9c520fea5b 100644 --- a/apps/mobile/e2e/flows/phase4a-timeline.yaml +++ b/apps/mobile/e2e/flows/phase4a-timeline.yaml @@ -64,21 +64,7 @@ env: speed: 40 visibilityPercentage: 10 - takeScreenshot: phase4a-timeline-long-message -# Table of contents → first message → the top of the thread again. -- tapOn: - id: "thread-toc-button" -- extendedWaitUntil: - visible: - id: "thread-toc-entry-0" - timeout: 10000 -- assertVisible: "(?s).*Hello rich thread, first message.*" -- takeScreenshot: phase4a-toc -- tapOn: - id: "thread-toc-entry-0" -- extendedWaitUntil: - visible: "Hello rich thread, first message" - timeout: 15000 # The Rich thread ends on a pending question, so the banner holds the bottom. - assertVisible: id: "thread-prompt-area" -- takeScreenshot: phase4a-timeline-after-toc +- takeScreenshot: phase4a-timeline-bottom diff --git a/apps/mobile/e2e/flows/phase4b-actions.yaml b/apps/mobile/e2e/flows/phase4b-actions.yaml index 822dbb4ffb..00fea1f52e 100644 --- a/apps/mobile/e2e/flows/phase4b-actions.yaml +++ b/apps/mobile/e2e/flows/phase4b-actions.yaml @@ -23,22 +23,20 @@ env: id: "thread-detail-title" text: "${THREAD_TITLE}" timeout: 30000 -# Environment line: project · host · workspace. -- assertVisible: - id: "thread-detail-environment" -- assertVisible: "Mobile E2E Project.*" - extendedWaitUntil: visible: id: "thread-composer-input" timeout: 30000 - takeScreenshot: phase4b-actions-thread -# Rename through the "…" sheet. +# Rename through the "…" sheet; its header carries the environment line +# (project · host · workspace). - tapOn: id: "thread-actions-button" - extendedWaitUntil: visible: id: "thread-action-rename" timeout: 10000 +- assertVisible: "Mobile E2E Project.*" - tapOn: id: "thread-action-rename" - extendedWaitUntil: diff --git a/apps/mobile/e2e/flows/phase4b-approve.yaml b/apps/mobile/e2e/flows/phase4b-approve.yaml index 1b01c74b86..11014bf8e7 100644 --- a/apps/mobile/e2e/flows/phase4b-approve.yaml +++ b/apps/mobile/e2e/flows/phase4b-approve.yaml @@ -25,6 +25,9 @@ env: visible: id: "thread-composer-input" timeout: 30000 +# The composer is a pill until focused; the pills render in the expanded card. +- tapOn: + id: "thread-composer-input" - extendedWaitUntil: visible: id: "thread-execution-controls" diff --git a/apps/mobile/e2e/flows/phase4b-ask-user.yaml b/apps/mobile/e2e/flows/phase4b-ask-user.yaml index 874dd2200d..2567a012d7 100644 --- a/apps/mobile/e2e/flows/phase4b-ask-user.yaml +++ b/apps/mobile/e2e/flows/phase4b-ask-user.yaml @@ -25,6 +25,9 @@ env: visible: id: "thread-composer-input" timeout: 30000 +# The composer is a pill until focused; the pills render in the expanded card. +- tapOn: + id: "thread-composer-input" - extendedWaitUntil: visible: id: "thread-execution-controls" diff --git a/apps/mobile/e2e/flows/phase4b-queue.yaml b/apps/mobile/e2e/flows/phase4b-queue.yaml index 5c1823066e..14398df6d4 100644 --- a/apps/mobile/e2e/flows/phase4b-queue.yaml +++ b/apps/mobile/e2e/flows/phase4b-queue.yaml @@ -28,6 +28,9 @@ env: visible: id: "thread-composer-input" timeout: 30000 +# The composer is a pill until focused; the pills render in the expanded card. +- tapOn: + id: "thread-composer-input" - extendedWaitUntil: visible: id: "thread-execution-controls" diff --git a/apps/mobile/e2e/flows/phase4b-send.yaml b/apps/mobile/e2e/flows/phase4b-send.yaml index 485183dddc..6f347001a9 100644 --- a/apps/mobile/e2e/flows/phase4b-send.yaml +++ b/apps/mobile/e2e/flows/phase4b-send.yaml @@ -28,14 +28,15 @@ env: visible: id: "thread-composer-input" timeout: 30000 -# Execution pills render once the thread defaults resolve. +- takeScreenshot: phase4b-send-idle +# The composer is a one-line pill until focused; the execution pills render +# in the expanded card once the thread defaults resolve. +- tapOn: + id: "thread-composer-input" - extendedWaitUntil: visible: id: "thread-execution-controls" timeout: 30000 -- takeScreenshot: phase4b-send-idle -- tapOn: - id: "thread-composer-input" - inputText: "hello" - takeScreenshot: phase4b-send-typed - tapOn: @@ -50,4 +51,4 @@ env: - takeScreenshot: phase4b-send-response # The draft was cleared: the input reads its placeholder again (the input's # accessibility label "Prompt" + the placeholder). -- assertVisible: "Prompt Ask a follow-up" +- assertVisible: "Prompt Follow up…" diff --git a/apps/mobile/e2e/flows/phase4b-thread-actions.yaml b/apps/mobile/e2e/flows/phase4b-thread-actions.yaml index 1ddc835b1f..98899df466 100644 --- a/apps/mobile/e2e/flows/phase4b-thread-actions.yaml +++ b/apps/mobile/e2e/flows/phase4b-thread-actions.yaml @@ -42,9 +42,13 @@ env: - assertVisible: id: "thread-banner-merge-base" - takeScreenshot: phase4b-banner -# Git sheet from the header button. -- assertVisible: - id: "thread-git-button" +# Git sheet from the "…" menu (the header has no second row). +- tapOn: + id: "thread-actions-button" +- extendedWaitUntil: + visible: + id: "thread-git-button" + timeout: 10000 - tapOn: id: "thread-git-button" - extendedWaitUntil: @@ -62,7 +66,7 @@ env: timeout: 10000 # Rename through the title. - tapOn: - id: "thread-detail-title-button" + id: "thread-detail-title" - extendedWaitUntil: visible: id: "thread-rename-input" diff --git a/apps/mobile/src/app-shell/ShareIntentHandler.tsx b/apps/mobile/src/app-shell/ShareIntentHandler.tsx index 21b8ca10d6..6cfa8f6a91 100644 --- a/apps/mobile/src/app-shell/ShareIntentHandler.tsx +++ b/apps/mobile/src/app-shell/ShareIntentHandler.tsx @@ -5,14 +5,14 @@ import { loadShareIntentModule, type ShareIntentModule, } from "@/lib/share"; -import { composeHref } from "@/screens/shell/hrefs"; +import { newThreadHref } from "@/screens/shell/hrefs"; import { toast } from "@/ui"; import { useProfiles } from "./ProfilesProvider"; /** * Inbound "Send to bb": when the binary bundles `expo-share-intent`, a share * from another app (text / URL) opens the composer seeded with it - * (`/compose?initialPrompt=`). Without the native module (the current dev + * (home, `/?initialPrompt=`). Without the native module (the current dev * client; see apps/mobile/README.md "Share sheet") this renders nothing, so * the JS side ships ahead of the native rebuild. Render once inside the * ProfilesProvider. @@ -50,7 +50,7 @@ function ShareIntentHandlerWithModule({ toast.info("Only text and links can be sent to bb for now."); return; } - router.push(composeHref({ initialPrompt: seed.initialPrompt })); + router.navigate(newThreadHref({ initialPrompt: seed.initialPrompt })); }, [activeProfile, hasShareIntent, resetShareIntent, router, shareIntent]); return null; } diff --git a/apps/mobile/src/composer/Composer.tsx b/apps/mobile/src/composer/Composer.tsx index 7f2812520f..f46c769208 100644 --- a/apps/mobile/src/composer/Composer.tsx +++ b/apps/mobile/src/composer/Composer.tsx @@ -23,6 +23,7 @@ import { ActionSheet, Button, Icon, + SheetPresenceContext, Spinner, useSheet, type ActionSheetAction, @@ -87,12 +88,26 @@ export interface ComposerProps { executionControls?: ExecutionControlsProps | null; /** Rendered inside the card above the attachments (context banners). */ header?: ReactNode; + /** + * Pill row rendered above the input while expanded (the home dock's + * project / environment pickers). Hidden in the collapsed pill. + */ + topControls?: ReactNode; /** Small trailing element in the footer (context-window readout). */ footerAccessory?: ReactNode; + /** + * Collapse to a one-line pill ("+ · placeholder · mic") while unfocused + * and empty; focus expands the card (top controls, the footer pills, the + * submit button) and blur folds it again. A picker sheet opened from the + * card keeps it expanded and refocuses the input when it closes. + */ + collapsible?: boolean; + /** Reports pill ↔ card transitions (the home screen drives its scrim). */ + onExpandedChange?: (expanded: boolean) => void; /** * Where the suggestion list opens. `above` floats over whatever sits above * the card (thread screen, composer at the bottom); `below` renders inline - * under the input (compose screen, composer near the top of a scroll view). + * under the input (the dev showcase, composer near the top of a scroll view). */ typeaheadPlacement?: "above" | "below"; autoFocus?: boolean; @@ -102,6 +117,9 @@ export interface ComposerProps { const EMPTY_ACTIONS: readonly ComposerAction[] = []; +/** A blur this close before a sheet opens is the sheet's keyboard dismissal. */ +const BLUR_FOR_SHEET_MS = 600; + /** * The shared native composer (root compose + follow-up): mention pills in a * native `TextInput`, `@` / `#` / `/` typeahead, attachments (library, @@ -126,7 +144,10 @@ export const Composer = forwardRef( actions = EMPTY_ACTIONS, executionControls, header, + topControls, footerAccessory, + collapsible = false, + onExpandedChange, typeaheadPlacement = "above", autoFocus = false, minInputHeight, @@ -146,6 +167,37 @@ export const Composer = forwardRef( end: 0, }); const [focused, setFocused] = useState(false); + // Sheets presented from inside the card (pickers, the "+" menu). They + // dismiss the keyboard, which must not fold the card; when the last + // one closes the input takes focus back. + const [openSheetCount, setOpenSheetCount] = useState(0); + const sheetOpen = openSheetCount > 0; + const refocusAfterSheetRef = useRef(false); + const lastBlurAtRef = useRef(0); + const onSheetPresenceChange = useCallback((open: boolean) => { + setOpenSheetCount((count) => Math.max(0, count + (open ? 1 : -1))); + // The keyboard dismissal a sheet triggers reaches the input either + // before or after the sheet reports itself open; either order arms + // the refocus. + if (open && Date.now() - lastBlurAtRef.current < BLUR_FOR_SHEET_MS) { + refocusAfterSheetRef.current = true; + } + }, []); + const sheetPresence = useMemo( + () => ({ onPresenceChange: onSheetPresenceChange }), + [onSheetPresenceChange], + ); + const handleFocus = useCallback(() => setFocused(true), []); + const handleBlur = useCallback(() => { + setFocused(false); + lastBlurAtRef.current = Date.now(); + if (sheetOpen) refocusAfterSheetRef.current = true; + }, [sheetOpen]); + useEffect(() => { + if (sheetOpen || !refocusAfterSheetRef.current) return; + refocusAfterSheetRef.current = false; + inputRef.current?.focus(); + }, [sheetOpen]); const systemConfig = useSystemConfig(); const providers = useSystemProviders(); const provider = useMemo( @@ -356,6 +408,23 @@ export const Composer = forwardRef( ); const showVoicePrimary = voice.enabled && !hasInput && !isSubmitting && !disabled; + // Focus, text, attachments, an edit header, a voice session, or a sheet + // opened from the card keep the full card; otherwise the pill folds. + const collapsed = + collapsible && + !focused && + !sheetOpen && + !hasInput && + attachments.length === 0 && + header == null && + !voiceBusy; + const expanded = !collapsed; + const lastExpandedRef = useRef(null); + useEffect(() => { + if (lastExpandedRef.current === expanded) return; + lastExpandedRef.current = expanded; + onExpandedChange?.(expanded); + }, [expanded, onExpandedChange]); const menuNode = menu ? ( ( ) : null; return ( - - {menuNode && typeaheadPlacement === "above" ? ( + + + {menuNode && typeaheadPlacement === "above" ? ( + + {menuNode} + + ) : null} - {menuNode} - - ) : null} - - {header} - - setFocused(true)} - onBlur={() => setFocused(false)} - placeholder={placeholder} - editable={!disabled && !voiceBusy} - autoFocus={autoFocus} - minHeight={minInputHeight} - testID={`${testID}-input`} - /> - {menuNode && typeaheadPlacement === "below" ? ( - - {menuNode} - - ) : null} - {voiceBusy ? ( - - ) : ( - - - ); -} - -/** The section's display name from the sidebar cache (manual-organize mode). */ -function useSectionName(sectionId: string | null): string | null { - const { data } = useSidebarBootstrap({ enabled: sectionId !== null }); - if (sectionId === null) return null; - return ( - data?.sections.find((section) => section.id === sectionId)?.name ?? null - ); -} diff --git a/apps/mobile/src/screens/compose/ExecutionControlsRow.tsx b/apps/mobile/src/screens/compose/ExecutionControlsRow.tsx index 7cc9c3dd9e..6f9770218e 100644 --- a/apps/mobile/src/screens/compose/ExecutionControlsRow.tsx +++ b/apps/mobile/src/screens/compose/ExecutionControlsRow.tsx @@ -1,37 +1,18 @@ -import type { Host } from "@bb/domain"; -import { ScrollView } from "react-native"; import type { ExecutionControlsProps } from "@/composer"; -import { - BranchPicker, - EnvironmentPicker, - HostPicker, - PathPicker, - ProjectPicker, -} from "../pickers"; import type { ComposeController } from "./useComposeController"; /** - * The agent pills (project, provider, model + reasoning (+ Fast), - * permissions) as the shared composer's execution-controls props. The - * project picker rides in `leading` because it is a compose-screen concept. + * The agent pills (provider, model + reasoning (+ Fast), permissions) as the + * shared composer's execution-controls props. The where-it-runs pickers + * (project, environment, machine, branch, folder) sit on the composer's top + * row instead (see ComposeDock). */ export function composeExecutionControls( controller: ComposeController, - options: { onCreateProject?: () => void; disabled?: boolean } = {}, + options: { disabled?: boolean } = {}, ): ExecutionControlsProps { const c = controller; return { - leading: ( - - ), provider: { options: c.providerOptions, value: c.providerId, @@ -63,85 +44,3 @@ export function composeExecutionControls( testID: "compose-controls", }; } - -export interface EnvironmentControlsRowProps { - controller: ComposeController; - /** Opens the guided setup for a connected machine without a project source. */ - onRequestMachineSetup?: (host: Host) => void; - disabled?: boolean; -} - -/** - * The environment pill row under the composer, scrolling horizontally: where - * it runs, then the machine / branch / folder pickers the selected mode needs. - */ -export function EnvironmentControlsRow({ - controller, - onRequestMachineSetup, - disabled = false, -}: EnvironmentControlsRowProps) { - const c = controller; - const showHostPicker = c.hosts.length > 1 && c.environment.type !== "reuse"; - const hostName = c.selectedHost?.name ?? null; - return ( - - - {showHostPicker ? ( - - ) : null} - {c.branch ? ( - - ) : null} - {c.hostMode === "local" ? ( - - ) : null} - - ); -} diff --git a/apps/mobile/src/screens/compose/index.ts b/apps/mobile/src/screens/compose/index.ts index eafc12d5fd..c3da76a468 100644 --- a/apps/mobile/src/screens/compose/index.ts +++ b/apps/mobile/src/screens/compose/index.ts @@ -1,9 +1,5 @@ -export { ComposeScreen } from "./ComposeScreen"; -export { - composeExecutionControls, - EnvironmentControlsRow, - type EnvironmentControlsRowProps, -} from "./ExecutionControlsRow"; +export { ComposeDock, type ComposeDockProps } from "./ComposeDock"; +export { composeExecutionControls } from "./ExecutionControlsRow"; export { useComposeController, type ComposeBranchState, diff --git a/apps/mobile/src/screens/compose/useComposeController.ts b/apps/mobile/src/screens/compose/useComposeController.ts index 2aaa733e28..20739a19d7 100644 --- a/apps/mobile/src/screens/compose/useComposeController.ts +++ b/apps/mobile/src/screens/compose/useComposeController.ts @@ -78,7 +78,8 @@ import { useSystemConfig, useSystemExecutionOptions } from "@/data/system"; import { useCreateThread } from "@/data/threads"; /** - * State and derived options for the root compose screen. Mirrors the web's + * State and derived options for the new-thread composer (the home dock). + * Mirrors the web's * NewThreadComposer + useThreadCreationOptions (new-thread scope) on top of * the mobile data layer: stored preferences win over the project defaults, * every selection is resolved against the live catalog, and the environment @@ -114,8 +115,6 @@ export interface ComposeController { setAttachments: (attachments: PromptDraftAttachment[]) => void; /** `PromptInput[]` the request will carry (text + mentions, attachments). */ promptInput: PromptInput[]; - title: string; - setTitle: (title: string) => void; sectionId: string | null; /** Set when the screen was opened by "Fork from here" (see compose-seed-params). */ forkSeed: ComposeForkSeed | null; @@ -273,24 +272,36 @@ export function useComposeController(params: ComposeParams): ComposeController { // --- Prompt ------------------------------------------------------------- // The shared composer's draft (restored from the web-compatible // new-thread key). A routed `initialPrompt` or a handoff seed replaces - // whatever was stored, once per screen instance. + // whatever was stored, once per distinct seed: the home dock keeps one + // controller alive across many "new thread" requests, so the seed is keyed + // on its params rather than on the component instance. const draft = useComposerDraft(NEW_THREAD_DRAFT_SCOPE); - const seededRef = useRef(false); + const seedKey = params.initialPrompt + ? `prompt:${params.initialPrompt}` + : handoffDraft + ? `handoff:${params.handoffSourceThreadId ?? ""}` + : null; + const appliedSeedKeyRef = useRef(null); useEffect(() => { - if (seededRef.current) return; - seededRef.current = true; + if (seedKey === null || appliedSeedKeyRef.current === seedKey) return; + appliedSeedKeyRef.current = seedKey; if (params.initialPrompt) { draft.replace(createComposerValue(params.initialPrompt), []); } else if (handoffDraft) { const seeded = composerValueFromDraftState(handoffDraft); draft.replace(seeded.value, seeded.attachments); } - }, [draft, handoffDraft, params.initialPrompt]); + }, [ + draft, + handoffDraft, + params.handoffSourceThreadId, + params.initialPrompt, + seedKey, + ]); const promptInput = useMemo( () => composerValueToPromptInput(draft.value, draft.attachments), [draft.attachments, draft.value], ); - const [title, setTitle] = useState(""); const sectionId = params.sectionId?.trim() ? params.sectionId.trim() : null; // --- Project ------------------------------------------------------------ @@ -316,6 +327,12 @@ export function useComposeController(params: ComposeParams): ComposeController { [bootstrap.data], ); const [pickedProjectId, setPickedProjectId] = useState(null); + // A routed project (a project's "+", a deep link, a fork / handoff seed) + // wins over an earlier pick, also when it arrives on a live controller. + const routedProjectId = params.projectId; + useEffect(() => { + if (routedProjectId) setPickedProjectId(routedProjectId); + }, [routedProjectId]); const projectId = resolveComposeProjectId({ requestedProjectId: pickedProjectId ?? params.projectId, storedProjectId: prefs.lastProjectId, @@ -759,7 +776,6 @@ export function useComposeController(params: ComposeParams): ComposeController { const result = buildCreateThreadRequest({ projectId, input: promptInput, - title, providerId: providerId || null, model: modelSelection.selectedModel || null, reasoningLevel: reasoningOptions.length > 0 ? reasoningLevel : null, @@ -798,7 +814,6 @@ export function useComposeController(params: ComposeParams): ComposeController { } const thread = await createThread.mutateAsync(request); draft.clear(); - setTitle(""); return thread; }, [ branchesQuery.data?.defaultBranch, @@ -824,7 +839,6 @@ export function useComposeController(params: ComposeParams): ComposeController { storedProviderSelection.reasoningLevel, promptInput, supportsServiceTier, - title, ]); return { @@ -833,8 +847,6 @@ export function useComposeController(params: ComposeParams): ComposeController { attachments: draft.attachments, setAttachments: draft.setAttachments, promptInput, - title, - setTitle, sectionId, forkSeed, handoffSeed, diff --git a/apps/mobile/src/screens/home/HomeScreen.tsx b/apps/mobile/src/screens/home/HomeScreen.tsx index 5ed8833cfd..abafd7b9c2 100644 --- a/apps/mobile/src/screens/home/HomeScreen.tsx +++ b/apps/mobile/src/screens/home/HomeScreen.tsx @@ -1,11 +1,37 @@ -import { Redirect, useNavigation, useRouter } from "expo-router"; -import { useLayoutEffect } from "react"; -import { Pressable, View } from "react-native"; +import { + Redirect, + useLocalSearchParams, + useNavigation, + useRouter, +} from "expo-router"; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { Animated, Keyboard, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useProfiles } from "@/app-shell"; +import type { ComposerHandle } from "@/composer"; +import { blendOver, withAlpha } from "@/markdown/colors"; import { useTheme } from "@/theme"; -import { Button, EmptyStatePanel, Icon, Spinner, Text } from "@/ui"; -import { threadSearchHref } from "../shell/hrefs"; +import { + Button, + EmptyStatePanel, + Icon, + KeyboardPaddingView, + Spinner, + Text, +} from "@/ui"; +import { + ComposeDock, + useComposeController, + type ComposeParams, +} from "../compose"; +import { threadHref, threadSearchHref } from "../shell/hrefs"; import { Screen } from "../shell/Screen"; import { SidebarActionsProvider, @@ -13,85 +39,271 @@ import { useSidebarActions, } from "../sidebar"; -/** Search + display-options buttons in the drawer header (set from inside the provider). */ -function HomeHeaderActions() { +const SCRIM_DURATION_MS = 180; +/** Opacity of the ink scrim over the list while the dock is expanded. */ +const SCRIM_ALPHA = 0.35; + +/** + * Search + display-options buttons in the drawer header (set from inside + * the provider). While the dock is expanded the header is painted the same + * gray as the scrim (it is navigator chrome above the screen, so the scrim + * view cannot cover it) and its controls are muted. + */ +function HomeHeaderActions({ dimmed }: { dimmed: boolean }) { const navigation = useNavigation(); const router = useRouter(); - const { tokens } = useTheme(); + const { tokens, fonts } = useTheme(); const actions = useSidebarActions(); + const background = dimmed + ? blendOver(tokens.background, tokens.ink, SCRIM_ALPHA) + : tokens.background; + const foreground = dimmed + ? blendOver(tokens.foreground, tokens.ink, SCRIM_ALPHA) + : tokens.foreground; useLayoutEffect(() => { navigation.setOptions({ + headerStyle: { backgroundColor: background }, + headerTintColor: foreground, + headerTitleStyle: { + fontFamily: fonts.sans.semibold, + fontWeight: "600", + color: foreground, + }, headerRight: () => ( router.push(threadSearchHref())} - className="h-10 w-10 items-center justify-center rounded-md active:bg-state-hover" + className="h-10 w-10 items-center justify-center rounded-full active:bg-state-hover" testID="home-search" > - + - + ), }); - }, [actions, navigation, router, tokens.foreground]); + }, [actions, background, dimmed, fonts, foreground, navigation, router]); return null; } -function NewThreadFab() { +/** `/?newThread=1`: open the dock without other params (`bb://compose`). */ +const NEW_THREAD_FLAG = "newThread"; + +type NewThreadRouteParams = Record< + keyof ComposeParams | typeof NEW_THREAD_FLAG, + string | string[] +>; + +const NEW_THREAD_PARAM_KEYS = [ + "projectId", + "sectionId", + "initialPrompt", + "reuseEnvironmentId", + "forkSourceThreadId", + "forkSourceSeqEnd", + "forkSourceThreadTitle", + "handoffSourceThreadId", + "handoffSourceThreadTitle", +] as const satisfies readonly (keyof ComposeParams)[]; + +function firstParam(value: string | string[] | undefined): string | undefined { + const raw = Array.isArray(value) ? value[0] : value; + const trimmed = raw?.trim(); + return trimmed ? trimmed : undefined; +} + +/** + * `/?projectId=§ionId=&initialPrompt=&reuseEnvironmentId=…` (see + * `newThreadHref`): a project's "+", a deep link, or a fork / handoff seed + * land on home with the dock open on these params. + */ +function useNewThreadRouteParams(): { + params: ComposeParams; + /** Changes whenever a new request arrives (the dock opens on it). */ + requestKey: string | null; + clear: () => void; +} { + const router = useRouter(); + const raw = useLocalSearchParams>(); + const params = useMemo((): ComposeParams => { + const next: ComposeParams = {}; + for (const key of NEW_THREAD_PARAM_KEYS) { + const value = firstParam(raw[key]); + if (value !== undefined) next[key] = value; + } + return next; + }, [raw]); + const flagged = firstParam(raw[NEW_THREAD_FLAG]) !== undefined; + const requestKey = useMemo(() => { + const entries = NEW_THREAD_PARAM_KEYS.filter( + (key) => params[key] !== undefined, + ).map((key) => `${key}=${params[key] ?? ""}`); + if (flagged) entries.unshift(NEW_THREAD_FLAG); + return entries.length > 0 ? entries.join("&") : null; + }, [flagged, params]); + const clear = useCallback(() => { + if (requestKey === null) return; + router.setParams( + Object.fromEntries( + [...NEW_THREAD_PARAM_KEYS, NEW_THREAD_FLAG].map((key) => [ + key, + undefined, + ]), + ), + ); + }, [requestKey, router]); + return { params, requestKey, clear }; +} + +/** + * The home body: the thread list with the new-thread dock pinned under it. + * The dock is the collapsed "Plan, ask, build…" pill; focusing it (or a + * project's "+", or a routed new-thread request) expands it in place over a + * scrim that dims the list, with the where-it-runs pickers on top and the + * agent pickers below the prompt. Creating a thread collapses the dock and + * opens the thread. + */ +function HomeBody() { const insets = useSafeAreaInsets(); + const router = useRouter(); const { tokens } = useTheme(); - const actions = useSidebarActions(); + const route = useNewThreadRouteParams(); + const controller = useComposeController(route.params); + const composerRef = useRef(null); + const [expanded, setExpanded] = useState(false); + const [scrim] = useState(() => new Animated.Value(0)); + const [scrimMounted, setScrimMounted] = useState(false); + + const animateScrim = useCallback( + (open: boolean) => { + if (open) setScrimMounted(true); + Animated.timing(scrim, { + toValue: open ? 1 : 0, + duration: SCRIM_DURATION_MS, + useNativeDriver: true, + }).start(({ finished }) => { + if (finished && !open) setScrimMounted(false); + }); + }, + [scrim], + ); + const setDockExpanded = useCallback( + (next: boolean) => { + setExpanded((current) => { + if (current !== next) animateScrim(next); + return next; + }); + }, + [animateScrim], + ); + // A routed request (a project's "+" from the drawer, a deep link, a fork / + // handoff seed) opens the dock; the params stay on the route until the + // thread is created — or the dock is dismissed, which drops the request + // (a fork hint would otherwise pin the card open). + const clearRoute = route.clear; + const collapse = useCallback(() => { + Keyboard.dismiss(); + composerRef.current?.blur(); + clearRoute(); + }, [clearRoute]); + + const requestKey = route.requestKey; + useEffect(() => { + if (requestKey === null) return; + composerRef.current?.focus(); + }, [requestKey]); + + const createThreadInDock = useCallback( + (target: { projectId?: string; sectionId?: string } | undefined) => { + // Same as a routed request, without leaving the screen. + router.setParams({ + [NEW_THREAD_FLAG]: "1", + projectId: target?.projectId, + sectionId: target?.sectionId, + }); + composerRef.current?.focus(); + return true; + }, + [router], + ); + return ( - - actions.createThread()} - className="h-14 w-14 items-center justify-center rounded-full bg-foreground active:bg-foreground/90" - style={{ - shadowColor: tokens.ink, - shadowOpacity: 0.25, - shadowRadius: 8, - shadowOffset: { width: 0, height: 4 }, - elevation: 4, - }} - testID="home-new-thread" - > - - - + + + + + + + {/* The scrim dims everything under the card — the list and the + dock's own margins — so the expanded card floats over it. */} + {scrimMounted ? ( + + + + ) : null} + + { + collapse(); + if (controller.navigateAfterCreate) { + router.push(threadHref(thread.id)); + } + }} + /> + + + ); } /** * Home: the thread list for the active server (the same grouped list the - * drawer shows, full width), pull-to-refresh, a New-thread FAB, and search / - * display options in the header. With no saved server it hands off to the - * add-server flow (first run). + * drawer shows, full width), pull-to-refresh, the new-thread dock at the + * bottom, and search / display options in the header. With no saved server + * it hands off to the add-server flow (first run). */ export function HomeScreen() { - const insets = useSafeAreaInsets(); const { status, profiles, activeProfile, connection } = useProfiles(); const router = useRouter(); @@ -136,14 +348,7 @@ export function HomeScreen() { return ( - - - - - + ); } diff --git a/apps/mobile/src/screens/index.ts b/apps/mobile/src/screens/index.ts index 01c648a730..c459ae626a 100644 --- a/apps/mobile/src/screens/index.ts +++ b/apps/mobile/src/screens/index.ts @@ -1,4 +1,3 @@ -export { ComposeScreen } from "./compose/ComposeScreen"; export { ConnectEnrollScreen } from "./connect/ConnectEnrollScreen"; export { HomeScreen } from "./home/HomeScreen"; export { ServerInfoCard } from "./home/ServerInfoCard"; diff --git a/apps/mobile/src/screens/machines/ProviderCliRows.tsx b/apps/mobile/src/screens/machines/ProviderCliRows.tsx index b03d1b30dd..1db9f5e1b0 100644 --- a/apps/mobile/src/screens/machines/ProviderCliRows.tsx +++ b/apps/mobile/src/screens/machines/ProviderCliRows.tsx @@ -6,6 +6,7 @@ import type { } from "@bb/host-daemon-contract/local"; import { useEffect } from "react"; import { ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { hasProviderCliAction, PROVIDER_CLI_MANAGED_PROVIDERS, @@ -235,6 +236,7 @@ export function ProviderCliInstallLogSheet({ record, }: ProviderCliInstallLogSheetProps) { const verb = record?.actionKind === "update" ? "update" : "install"; + const insets = useSafeAreaInsets(); return ( - + {/* `custom` layout: the body owns the home-indicator inset. */} + {record?.message ? ( - + ); } diff --git a/apps/mobile/src/screens/panel/README.md b/apps/mobile/src/screens/panel/README.md index 2b413d6da3..d6dd196127 100644 --- a/apps/mobile/src/screens/panel/README.md +++ b/apps/mobile/src/screens/panel/README.md @@ -9,7 +9,7 @@ what each tab _shows_ is registered by the feature that owns it. ``` WorkspacePanelProvider state + controller + ThreadWorkspacePanelProvider thread scope (ThreadDetailScreen) - ProjectWorkspacePanelProvider project scope / root compose (ComposeScreen) + ProjectWorkspacePanelProvider project scope / root compose (home ComposeDock) usePanel() PanelController (open / close / openFile / …) registerPanelTabContent(kind, Component) tab kinds registerPanelLauncherContent("files"|"terminal") the two launcher pages @@ -142,10 +142,10 @@ launcher does the same with `filesParams`. ## Entry points -- `ThreadDetailHeader` → `PanelToggleButton` (`thread-panel-button`, icon - `PanelBottom`) → `panel.open()`. -- `ComposeScreen` → "Workspace" (`compose-workspace-button`) under the - composer. +- The thread screen's native header → `PanelToggleButton` + (`thread-panel-button`, icon `PanelBottom`) → `panel.open()`; also the + "Workspace" row of the "…" menu. +- Home `ComposeDock` → "Workspace" in the composer's "+" menu. - Info tab: changed files → `openDiff(path)`, storage row → `openFiles({ section: "storage" })`, parent / forks → thread route. diff --git a/apps/mobile/src/screens/pickers/PickerTrigger.tsx b/apps/mobile/src/screens/pickers/PickerTrigger.tsx index f5ab31f29c..8b8b7e790c 100644 --- a/apps/mobile/src/screens/pickers/PickerTrigger.tsx +++ b/apps/mobile/src/screens/pickers/PickerTrigger.tsx @@ -19,6 +19,11 @@ export interface PickerTriggerProps { tone?: "default" | "warning" | "destructive"; /** Hide the trailing chevron (read-only display). */ chevron?: boolean; + /** + * `ghost` (default): borderless, for the composer's pill rows. `outline`: + * the bordered pill for pickers that stand alone on a settings screen. + */ + variant?: "ghost" | "outline"; className?: string; testID?: string; /** The control's name; the spoken label becomes ": ) : null} - - {formatRelativeTime(timestamp, now)} - diff --git a/apps/mobile/src/screens/sidebar/SidebarThreadList.tsx b/apps/mobile/src/screens/sidebar/SidebarThreadList.tsx index 80d0e76da1..fc89c7c004 100644 --- a/apps/mobile/src/screens/sidebar/SidebarThreadList.tsx +++ b/apps/mobile/src/screens/sidebar/SidebarThreadList.tsx @@ -1,12 +1,6 @@ import { PERSONAL_PROJECT_ID } from "@bb/domain"; import { FlashList, type ListRenderItemInfo } from "@shopify/flash-list"; -import { - useCallback, - useEffect, - useMemo, - useState, - type ReactElement, -} from "react"; +import { useCallback, useMemo, useState, type ReactElement } from "react"; import { View, type StyleProp, type ViewStyle } from "react-native"; import { useHosts } from "@/data/hosts"; import { @@ -16,7 +10,6 @@ import { useSidebarPreferences, } from "@/data/sidebar"; import { Button, EmptyStatePanel, Skeleton, Text } from "@/ui"; -import { getRelativeTimeRefreshIntervalMs } from "./relative-time"; import { useSidebarActions } from "./SidebarActionsProvider"; import { SidebarEmptyRowView, @@ -32,19 +25,6 @@ import { type SidebarThreadRow, } from "./sidebar-list-rows"; -/** Ticks once a minute so relative-time labels stay fresh without per-row timers. */ -function useNow(): number { - const [now, setNow] = useState(() => Date.now()); - useEffect(() => { - const timer = setInterval( - () => setNow(Date.now()), - getRelativeTimeRefreshIntervalMs(), - ); - return () => clearInterval(timer); - }, []); - return now; -} - /** * FlashList keeps the first visible row anchored when rows are inserted above * it (chat-style). A sidebar wants the opposite: a thread that gets pinned or @@ -100,7 +80,6 @@ export function SidebarThreadList({ const bootstrap = useSidebarBootstrap(); const hosts = useHosts(); const actions = useSidebarActions(); - const now = useNow(); const [refreshing, setRefreshing] = useState(false); const rows = useMemo( @@ -172,7 +151,6 @@ export function SidebarThreadList({ ); const organize = preferences.organize; - const sort = preferences.sort; const projectNamesById = model.projectNamesById; const renderItem = useCallback( @@ -206,10 +184,6 @@ export function SidebarThreadList({ ? (projectNamesById.get(thread.projectId) ?? null) : null } - timestamp={ - sort === "created" ? thread.createdAt : thread.latestAttentionAt - } - now={now} onPress={onThreadPress} onLongPress={onThreadLongPress} onToggleCollapsed={onToggleThread} @@ -228,7 +202,6 @@ export function SidebarThreadList({ } }, [ - now, onHeaderCreateThread, onHeaderLongPress, onThreadLongPress, @@ -239,7 +212,6 @@ export function SidebarThreadList({ organize, projectNamesById, selectedThreadId, - sort, ], ); @@ -285,7 +257,7 @@ export function SidebarThreadList({ keyExtractor={keyExtractor} getItemType={getItemType} renderItem={renderItem} - extraData={{ selectedThreadId, now, organize, sort }} + extraData={{ selectedThreadId, organize }} maintainVisibleContentPosition={DISABLE_MAINTAIN_POSITION} refreshing={refreshing} onRefresh={onRefresh} diff --git a/apps/mobile/src/screens/sidebar/index.ts b/apps/mobile/src/screens/sidebar/index.ts index f631434f4c..7f5cf394c8 100644 --- a/apps/mobile/src/screens/sidebar/index.ts +++ b/apps/mobile/src/screens/sidebar/index.ts @@ -13,7 +13,6 @@ export { type SidebarThreadRowViewProps, } from "./SidebarRows"; export { ThreadStatusGlyph } from "./ThreadStatusGlyph"; -export { formatRelativeTime } from "./relative-time"; export { buildSidebarListRows, getThreadIndicatorState, diff --git a/apps/mobile/src/screens/sidebar/relative-time.test.ts b/apps/mobile/src/screens/sidebar/relative-time.test.ts deleted file mode 100644 index 96ec2821b2..0000000000 --- a/apps/mobile/src/screens/sidebar/relative-time.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { formatRelativeTime } from "./relative-time"; - -const NOW = Date.UTC(2026, 7, 19, 12, 0, 0); -const MINUTE = 60_000; -const HOUR = 60 * MINUTE; -const DAY = 24 * HOUR; - -describe("formatRelativeTime", () => { - it("rounds down through the minute/hour/day buckets", () => { - expect(formatRelativeTime(NOW - 30_000, NOW)).toBe("now"); - expect(formatRelativeTime(NOW - MINUTE, NOW)).toBe("1m"); - expect(formatRelativeTime(NOW - 59 * MINUTE - 59_000, NOW)).toBe("59m"); - expect(formatRelativeTime(NOW - HOUR, NOW)).toBe("1h"); - expect(formatRelativeTime(NOW - 23 * HOUR - 59 * MINUTE, NOW)).toBe("23h"); - expect(formatRelativeTime(NOW - DAY, NOW)).toBe("1d"); - expect(formatRelativeTime(NOW - 6 * DAY - 23 * HOUR, NOW)).toBe("6d"); - }); - - it("treats future timestamps as now (clock skew)", () => { - expect(formatRelativeTime(NOW + 5 * MINUTE, NOW)).toBe("now"); - }); - - it("falls back to a short date after a week, adding the year across years", () => { - const sameYear = new Date(2026, 2, 4, 9).getTime(); - expect(formatRelativeTime(sameYear, NOW)).toBe("Mar 4"); - const lastYear = new Date(2025, 11, 31, 9).getTime(); - expect(formatRelativeTime(lastYear, NOW)).toBe("Dec 31, 2025"); - }); -}); diff --git a/apps/mobile/src/screens/sidebar/relative-time.ts b/apps/mobile/src/screens/sidebar/relative-time.ts deleted file mode 100644 index f1a00307ed..0000000000 --- a/apps/mobile/src/screens/sidebar/relative-time.ts +++ /dev/null @@ -1,48 +0,0 @@ -const MINUTE_MS = 60_000; -const HOUR_MS = 60 * MINUTE_MS; -const DAY_MS = 24 * HOUR_MS; -const WEEK_MS = 7 * DAY_MS; - -const MONTH_ABBREVIATIONS = [ - "Jan", - "Feb", - "Mar", - "Apr", - "May", - "Jun", - "Jul", - "Aug", - "Sep", - "Oct", - "Nov", - "Dec", -] as const; - -/** - * Compact age label for list rows: `now`, `5m`, `3h`, `2d`, then a short - * date (`Mar 4`, or `Mar 4, 2024` outside the current year). Timestamps in - * the future (clock skew) read as `now`. No Intl dependency so the output is - * identical on every device and in tests. - */ -export function formatRelativeTime(timestampMs: number, nowMs: number): string { - const elapsed = nowMs - timestampMs; - if (elapsed < MINUTE_MS) return "now"; - if (elapsed < HOUR_MS) return `${Math.floor(elapsed / MINUTE_MS)}m`; - if (elapsed < DAY_MS) return `${Math.floor(elapsed / HOUR_MS)}h`; - if (elapsed < WEEK_MS) return `${Math.floor(elapsed / DAY_MS)}d`; - const date = new Date(timestampMs); - const now = new Date(nowMs); - const month = MONTH_ABBREVIATIONS[date.getMonth()]; - const day = date.getDate(); - return date.getFullYear() === now.getFullYear() - ? `${month} ${day}` - : `${month} ${day}, ${date.getFullYear()}`; -} - -/** - * How long a label computed at `nowMs` stays correct, so a list can schedule - * its next re-render instead of ticking every second. - */ -export function getRelativeTimeRefreshIntervalMs(): number { - return MINUTE_MS; -} diff --git a/apps/mobile/src/screens/thread/ThreadDetailHeader.tsx b/apps/mobile/src/screens/thread/ThreadDetailHeader.tsx index ddda5feb5f..75ebdbe3f1 100644 --- a/apps/mobile/src/screens/thread/ThreadDetailHeader.tsx +++ b/apps/mobile/src/screens/thread/ThreadDetailHeader.tsx @@ -1,217 +1,129 @@ import { Pressable, View } from "react-native"; import { useTheme } from "@/theme"; -import { cn, Icon, Pill, Spinner, Text, type PillVariant } from "@/ui"; -import type { ChildThreadSummary } from "@/data/thread-detail"; +import { cn, Icon, Spinner, Text } from "@/ui"; import { PanelToggleButton } from "../panel/PanelToggleButton"; import type { ThreadStatusPill } from "./thread-detail-header-model"; -export interface ThreadDetailHeaderGitAction { - /** Primary git action label ("Commit" / "Squash merge"). */ - label: string; - /** Opens the git sheet (commit / squash merge). */ - onPress: () => void; - pending: boolean; -} +/** + * The thread screen's native header pieces. There is one header only: the + * title (tap to rename) with a status subtitle while the thread needs + * attention or works, and two buttons on the right — the workspace panel + * and the "…" menu. Everything else the old two-layer header carried + * (environment line, child roll-up, git action) lives in the menu sheet. + */ -export interface ThreadDetailHeaderProps { +export interface ThreadHeaderTitleProps { title: string; statusPill: ThreadStatusPill; - /** "project · host · worktree · branch" parts (empty = hidden). */ - environmentParts: readonly string[]; - childSummary: ChildThreadSummary; /** Pill shown beside the title for side chats / child threads. */ childPillLabel: "child" | "side chat" | null; - onOpenTableOfContents: () => void; - tableOfContentsEnabled: boolean; /** Tap the title to rename (null while the thread is loading). */ onPressTitle: (() => void) | null; - /** Opens the thread actions menu (null while the thread is loading). */ - onOpenActions: (() => void) | null; - /** Git action button (null when the workspace has nothing to commit/merge). */ - gitAction: ThreadDetailHeaderGitAction | null; - /** Opens the workspace panel (Info / Diff / Files / Terminal); null while loading. */ - onOpenPanel: (() => void) | null; - /** The workspace panel is presented. */ - panelActive: boolean; } -function pillVariantForTone(tone: ThreadStatusPill["tone"]): PillVariant { - switch (tone) { - case "error": - return "destructive"; - case "attention": - return "emphasis"; - case "working": - case "idle": - case "muted": - return "secondary"; - } +/** Subtitle shown under the title; idle / archived threads show none. */ +export function headerSubtitle( + statusPill: ThreadStatusPill, + childPillLabel: ThreadHeaderTitleProps["childPillLabel"], +): string | null { + const parts: string[] = []; + if (statusPill.tone !== "idle") parts.push(statusPill.label); + if (childPillLabel) parts.push(childPillLabel); + return parts.length > 0 ? parts.join(" · ") : null; } -/** - * Thread header under the native bar: the title (tap to rename), the - * runtime status pill, the environment line, the child-thread roll-up, the - * git action button, the workspace panel button, the table-of-contents - * button, and the actions menu. - */ -export function ThreadDetailHeader({ +export function ThreadHeaderTitle({ title, statusPill, - environmentParts, - childSummary, childPillLabel, - onOpenTableOfContents, - tableOfContentsEnabled, onPressTitle, - onOpenActions, - gitAction, - onOpenPanel, - panelActive, -}: ThreadDetailHeaderProps) { +}: ThreadHeaderTitleProps) { const { tokens } = useTheme(); - const pillVariant = pillVariantForTone(statusPill.tone); + const subtitle = headerSubtitle(statusPill, childPillLabel); + const subtitleColor = + statusPill.tone === "error" + ? tokens.destructiveText + : statusPill.tone === "attention" + ? tokens.warningText + : tokens.mutedForeground; return ( - - - - {title} - - - - - - {statusPill.spinning ? ( - - ) : null} - - {statusPill.label} - - - - {childPillLabel ? ( - {childPillLabel} - ) : null} - {childSummary.count > 0 ? ( - - {`${childSummary.count} child thread${childSummary.count === 1 ? "" : "s"}${ - childSummary.activity.pending - ? " · needs input" - : childSummary.activity.working - ? " · working" - : "" - }`} - - ) : null} - - {/* Actions sit on the pill row, not the title row: the dev client's - floating gear covers the header's top-right corner on the - simulator. Icon-only (labels in accessibility) so the row holds - the status, the child pill, contents and the menu without - wrapping. */} - undefined)} - active={panelActive} - disabled={onOpenPanel === null} - /> - - - - + {subtitle ? ( + - - - - {environmentParts.length > 0 || gitAction ? ( - + {statusPill.spinning ? ( + + ) : null} - {environmentParts.join(" · ")} + {subtitle} - {/* The git action belongs to the workspace line. */} - {gitAction ? ( - - {gitAction.pending ? ( - - ) : ( - - )} - - {gitAction.label} - - - ) : null} ) : null} + + ); +} + +export interface ThreadHeaderActionsProps { + /** Opens the thread actions menu (null while the thread is loading). */ + onOpenActions: (() => void) | null; + /** Opens the workspace panel (Info / Diff / Files / Terminal); null while loading. */ + onOpenPanel: (() => void) | null; + /** The workspace panel is presented. */ + panelActive: boolean; +} + +export function ThreadHeaderActions({ + onOpenActions, + onOpenPanel, + panelActive, +}: ThreadHeaderActionsProps) { + const { tokens } = useTheme(); + return ( + + undefined)} + active={panelActive} + disabled={onOpenPanel === null} + /> + + + ); } diff --git a/apps/mobile/src/screens/thread/ThreadDetailScreen.tsx b/apps/mobile/src/screens/thread/ThreadDetailScreen.tsx index 82d0f799f0..96cc5484d8 100644 --- a/apps/mobile/src/screens/thread/ThreadDetailScreen.tsx +++ b/apps/mobile/src/screens/thread/ThreadDetailScreen.tsx @@ -49,19 +49,18 @@ import { useMessageActionHandlers, useThreadActionsSheet, useThreadGitActions, + type ThreadMenuAction, } from "./actions"; import { MergeBasePickerSheet, useThreadContextBanner } from "./banner"; import { ThreadPromptArea, useFollowUpComposer } from "./prompt-area"; -import { ThreadDetailHeader } from "./ThreadDetailHeader"; +import { ThreadHeaderActions, ThreadHeaderTitle } from "./ThreadDetailHeader"; import { describeThreadEnvironment, describeThreadStatusPill, isThreadRuntimeBusy, } from "./thread-detail-header-model"; -import { ThreadTableOfContentsSheet } from "./ThreadTableOfContentsSheet"; import { buildTimelineListEntries, - buildTimelineTableOfContents, renderTurnChildrenLoaders, TimelineList, TimelineRowHostProvider, @@ -69,7 +68,6 @@ import { useTurnChildrenMap, WorkingIndicatorRow, type TimelineListHandle, - type TimelineTableOfContentsEntry, } from "./timeline"; import { useThreadUnreadDividerState } from "./use-thread-unread-divider-state"; @@ -193,24 +191,13 @@ function ThreadDetailBody({ threadId }: { threadId: string }) { () => buildTimelineListEntries(items, unreadDivider.placement), [items, unreadDivider.placement], ); - const tableOfContents = useMemo( - () => buildTimelineTableOfContents(items), - [items], - ); const listRef = useRef(null); - const tocSheet = useSheet(); // The workspace panel (Info / Diff / Files / Terminal + synced file tabs): // the header button presents it. const panel = usePanel(); const openPanel = panel.open; const onOpenPanel = useCallback(() => openPanel(), [openPanel]); - const handleSelectTocEntry = useCallback( - (entry: TimelineTableOfContentsEntry) => { - listRef.current?.scrollToRow(entry.rowId); - }, - [], - ); // Context banner (git / PR / parent / children / archived) + the workspace // facts the header git sheet shares with it. @@ -295,6 +282,48 @@ function ThreadDetailBody({ threadId }: { threadId: string }) { host: bootstrap.data?.host ?? null, projectName: projectName ?? null, }); + // The "…" menu's first rows: what the old second header row carried. + const menuLeadingActions: ThreadMenuAction[] = [ + { + key: "workspace", + label: "Workspace", + icon: "PanelBottom", + onPress: () => { + threadActions.dismiss(); + onOpenPanel(); + }, + testID: "thread-panel-menu-button", + }, + ...(gitActions.primaryLabel !== null + ? [ + { + key: "git", + label: gitActions.primaryLabel, + icon: "GitBranch" as const, + pending: gitActions.pending, + onPress: () => { + threadActions.dismiss(); + gitSheet.present(); + }, + testID: "thread-git-button", + }, + ] + : []), + ]; + const menuDetail = [ + ...(childSummary.count > 0 + ? [ + `${childSummary.count} child thread${childSummary.count === 1 ? "" : "s"}${ + childSummary.activity.pending + ? " · needs input" + : childSummary.activity.working + ? " · working" + : "" + }`, + ] + : []), + ...environmentParts, + ].join(" · "); const childPillLabel = thread?.parentThreadId == null ? null @@ -373,30 +402,27 @@ function ThreadDetailBody({ threadId }: { threadId: string }) { threadOriginKind={thread?.originKind ?? null} messageActions={messageActions} > - - {turnLoaders} - 0} - onPressTitle={threadReady ? openRename : null} - onOpenActions={threadReady ? openThreadMenu : null} - onOpenPanel={threadReady ? onOpenPanel : null} - panelActive={panel.visible} - gitAction={ - gitActions.primaryLabel !== null - ? { - label: gitActions.primaryLabel, - onPress: gitSheet.present, - pending: gitActions.pending, - } - : null - } + ( + + ), + headerRight: () => ( + + ), + }} /> + {turnLoaders} {(timelineLoading && entries.length === 0) || !threadReady ? ( @@ -462,11 +488,6 @@ function ThreadDetailBody({ threadId }: { threadId: string }) { onHandoffToNewThread={contextBanner.handoffToNewThread} /> - {thread ? ( 0 ? menuDetail : null} /> ) : null} void; -} - -/** - * The user's messages in order; picking one scrolls the timeline to it. - * Lists the loaded window only (older pages join as they are scrolled in). - */ -export function ThreadTableOfContentsSheet({ - controller, - entries, - onSelect, -}: ThreadTableOfContentsSheetProps) { - const { height } = useWindowDimensions(); - return ( - - {entries.length === 0 ? ( - - - No messages yet. - - - ) : ( - entries.map((entry, index) => ( - - {index + 1} - - } - onPress={() => { - controller.dismiss(); - onSelect(entry); - }} - testID={`thread-toc-entry-${index}`} - /> - )) - )} - - ); -} diff --git a/apps/mobile/src/screens/thread/actions/ThreadActionsSheet.tsx b/apps/mobile/src/screens/thread/actions/ThreadActionsSheet.tsx index efd4c5c64d..733a4cd72a 100644 --- a/apps/mobile/src/screens/thread/actions/ThreadActionsSheet.tsx +++ b/apps/mobile/src/screens/thread/actions/ThreadActionsSheet.tsx @@ -25,6 +25,7 @@ import { ListRow, Separator, Sheet, + Spinner, Text, toast, useSheet, @@ -79,15 +80,20 @@ export function useThreadActionsSheet(): ThreadActionsSheetController { ); } -interface MenuAction { +export interface ThreadMenuAction { key: string; label: string; icon: IconName; destructive?: boolean; disabled?: boolean; + /** Replaces the icon with a spinner (action in flight). */ + pending?: boolean; onPress: () => void; + testID?: string; } +type MenuAction = ThreadMenuAction; + function SheetHeader({ title, message, @@ -117,18 +123,24 @@ function MenuRows({ actions }: { actions: readonly MenuAction[] }) { key={action.key} title={action.label} leading={ - + action.pending ? ( + + ) : ( + + ) } destructive={action.destructive} - disabled={action.disabled} + disabled={action.disabled || action.pending} onPress={action.onPress} - testID={`thread-action-${action.key}`} + testID={action.testID ?? `thread-action-${action.key}`} /> ))} @@ -201,14 +213,25 @@ export interface ThreadActionsSheetProps { onHandoffToNewThread: () => void; /** "New thread in this worktree"; null when the thread has no reusable worktree. */ onNewThreadInWorktree: (() => void) | null; + /** + * Screen-owned rows listed first (workspace panel, the + * git action): the thread screen has no second header, so these live here. + */ + leadingActions?: readonly ThreadMenuAction[]; + /** One-line detail under the title ("project · host · worktree · branch"). */ + headerDetail?: string | null; } +const EMPTY_LEADING_ACTIONS: readonly ThreadMenuAction[] = []; + export function ThreadActionsSheet({ controller, thread, onDeleted, onHandoffToNewThread, onNewThreadInWorktree, + leadingActions = EMPTY_LEADING_ACTIONS, + headerDetail = null, }: ThreadActionsSheetProps) { const { tokens } = useTheme(); const { serverUrl } = useProfileClient(); @@ -414,7 +437,13 @@ export function ThreadActionsSheet({ ]; return ( <> - + + {leadingActions.length > 0 ? ( + <> + + + + ) : null} ); diff --git a/apps/mobile/src/screens/thread/actions/index.ts b/apps/mobile/src/screens/thread/actions/index.ts index b4155bfb2d..6d57302c29 100644 --- a/apps/mobile/src/screens/thread/actions/index.ts +++ b/apps/mobile/src/screens/thread/actions/index.ts @@ -29,6 +29,7 @@ export { type ThreadActionsSheetController, type ThreadActionsSheetProps, type ThreadActionsSheetState, + type ThreadMenuAction, type ThreadActionsView, } from "./ThreadActionsSheet"; export { diff --git a/apps/mobile/src/screens/thread/actions/use-message-action-handlers.ts b/apps/mobile/src/screens/thread/actions/use-message-action-handlers.ts index 8c4c65f16b..337f1442b8 100644 --- a/apps/mobile/src/screens/thread/actions/use-message-action-handlers.ts +++ b/apps/mobile/src/screens/thread/actions/use-message-action-handlers.ts @@ -8,7 +8,7 @@ import { useSystemProviders } from "@/data/system"; import { getThreadDisplayTitle } from "@/data/threads"; import { SIDE_CHAT_PLUGIN_ID } from "@/data/thread-detail"; import { toast } from "@/ui"; -import { composeHref } from "../../shell/hrefs"; +import { newThreadHref } from "../../shell/hrefs"; import type { TimelineMessageActionHandlers } from "./message-actions-model"; import { useSendMessageToMainThread } from "./use-send-to-main-thread"; @@ -30,7 +30,7 @@ export interface UseMessageActionHandlersArgs { * * Forking mirrors the web `useForkThreadFromMessage`: the source thread's * resolved execution options become the compose picks (same preference - * store the compose controller reads), and `/compose` opens seeded with the + * store the compose controller reads), and home opens its dock seeded with the * fork source + reuse environment; the compose controller builds the fork * request on submit. */ @@ -78,8 +78,8 @@ export function useMessageActionHandlers({ prefStore.setPermissionMode(executionOptions.permissionMode); prefStore.setServiceTier(executionOptions.serviceTier); prefStore.setLastProjectId(thread.projectId); - router.push( - composeHref( + router.navigate( + newThreadHref( buildForkComposeParams({ environmentId: thread.environmentId, projectId: thread.projectId, diff --git a/apps/mobile/src/screens/thread/banner/use-thread-context-banner.ts b/apps/mobile/src/screens/thread/banner/use-thread-context-banner.ts index f02b9af2f9..bed2fdaf84 100644 --- a/apps/mobile/src/screens/thread/banner/use-thread-context-banner.ts +++ b/apps/mobile/src/screens/thread/banner/use-thread-context-banner.ts @@ -32,7 +32,7 @@ import { useUnarchiveThread, } from "@/data/threads"; import { toast, useSheet, type SheetController } from "@/ui"; -import { composeHref, threadHref } from "../../shell/hrefs"; +import { newThreadHref, threadHref } from "../../shell/hrefs"; import { buildChildThreadsSection, buildParentThreadSection, @@ -236,8 +236,8 @@ export function useThreadContextBanner({ const handoffToNewThread = useCallback(() => { if (!thread) return; - router.push( - composeHref( + router.navigate( + newThreadHref( buildHandoffComposeParams({ environmentId: thread.environmentId, projectId: thread.projectId, @@ -253,8 +253,8 @@ export function useThreadContextBanner({ isProvisionedWorktree(environment); const newThreadInWorktree = useCallback(() => { if (!thread || thread.environmentId === null) return; - router.push( - composeHref( + router.navigate( + newThreadHref( buildNewThreadInWorktreeComposeParams({ projectId: thread.projectId, environmentId: thread.environmentId, diff --git a/apps/mobile/src/screens/thread/cards/ThreadPromptStackCards.tsx b/apps/mobile/src/screens/thread/cards/ThreadPromptStackCards.tsx index ad8e50056d..be7e17a648 100644 --- a/apps/mobile/src/screens/thread/cards/ThreadPromptStackCards.tsx +++ b/apps/mobile/src/screens/thread/cards/ThreadPromptStackCards.tsx @@ -14,6 +14,7 @@ import type { import { durationToCompactString } from "@bb/thread-view"; import { useEffect, useState, type ReactNode } from "react"; import { Pressable, View } from "react-native"; +import Svg, { Circle } from "react-native-svg"; import { useTheme } from "@/theme"; import { cn, Icon, Spinner, Text, type IconName } from "@/ui"; import { @@ -454,7 +455,18 @@ export function ThreadModelFallbackCard({ ); } -/** Compact "used / window" readout for the bottom bar. */ +/** + * Threshold (percent of the window) above which the composer shows the + * context ring. Below it the readout stays out of the way; the full numbers + * are in the accessibility label and in the thread menu's workspace info. + */ +export const CONTEXT_WINDOW_RING_THRESHOLD_PERCENT = 60; + +/** + * Small ring in the composer footer: appears only when the context window is + * filling up (≥ CONTEXT_WINDOW_RING_THRESHOLD_PERCENT), tinted by the usage + * tone. The full "used / window" readout lives in the accessibility label. + */ export function ThreadContextWindowIndicator({ usage, }: { @@ -463,6 +475,7 @@ export function ThreadContextWindowIndicator({ const { tokens } = useTheme(); if (!usage) return null; const percent = calculateContextWindowUsagePercent(usage); + if (percent < CONTEXT_WINDOW_RING_THRESHOLD_PERCENT) return null; const tone = contextWindowTone(percent); const color = tone === "destructive" @@ -470,24 +483,57 @@ export function ThreadContextWindowIndicator({ : tone === "warning" ? tokens.warningText : tokens.mutedForeground; + const readout = `${formatCompactTokenCount(usage.usedTokens)} / ${formatCompactTokenCount(usage.modelContextWindow)}${usage.estimated ? " est." : ""}`; return ( - - - - - {`${formatCompactTokenCount(usage.usedTokens)} / ${formatCompactTokenCount(usage.modelContextWindow)}${usage.estimated ? " est." : ""}`} - + ); } + +const RING_SIZE = 18; +const RING_STROKE = 2.5; + +function ContextRing({ + percent, + color, + track, +}: { + percent: number; + color: string; + track: string; +}) { + const radius = (RING_SIZE - RING_STROKE) / 2; + const circumference = 2 * Math.PI * radius; + const clamped = Math.max(0, Math.min(100, percent)); + return ( + + + + + ); +} diff --git a/apps/mobile/src/screens/thread/index.ts b/apps/mobile/src/screens/thread/index.ts index 377db77e43..5ccf00aea1 100644 --- a/apps/mobile/src/screens/thread/index.ts +++ b/apps/mobile/src/screens/thread/index.ts @@ -1,8 +1,10 @@ export { ThreadDetailScreen } from "./ThreadDetailScreen"; export { - ThreadDetailHeader, - type ThreadDetailHeaderGitAction, - type ThreadDetailHeaderProps, + headerSubtitle, + ThreadHeaderActions, + ThreadHeaderTitle, + type ThreadHeaderActionsProps, + type ThreadHeaderTitleProps, } from "./ThreadDetailHeader"; export * from "./actions"; export * from "./banner"; diff --git a/apps/mobile/src/screens/thread/prompt-area/ThreadPromptArea.tsx b/apps/mobile/src/screens/thread/prompt-area/ThreadPromptArea.tsx index 3cfac3e314..bbc3644d5f 100644 --- a/apps/mobile/src/screens/thread/prompt-area/ThreadPromptArea.tsx +++ b/apps/mobile/src/screens/thread/prompt-area/ThreadPromptArea.tsx @@ -147,9 +147,9 @@ export function ThreadPromptArea({ (!composer.hidden && queuedMessages.length > 0); return ( } typeaheadPlacement="above" + collapsible testID="thread-composer" /> )} diff --git a/apps/mobile/src/screens/thread/prompt-area/follow-up-submission.ts b/apps/mobile/src/screens/thread/prompt-area/follow-up-submission.ts index 170706cd1b..5a75931792 100644 --- a/apps/mobile/src/screens/thread/prompt-area/follow-up-submission.ts +++ b/apps/mobile/src/screens/thread/prompt-area/follow-up-submission.ts @@ -164,10 +164,10 @@ export function followUpPlaceholder({ case "host-reconnecting": return "Reconnecting…"; case "error": - return "Send a follow-up"; + return "Follow up…"; case "idle": case "active": - return "Ask a follow-up"; + return "Follow up…"; } } diff --git a/apps/mobile/src/screens/thread/timeline/TimelineList.tsx b/apps/mobile/src/screens/thread/timeline/TimelineList.tsx index 0a7b486702..bdb7745b0e 100644 --- a/apps/mobile/src/screens/thread/timeline/TimelineList.tsx +++ b/apps/mobile/src/screens/thread/timeline/TimelineList.tsx @@ -24,7 +24,6 @@ import { import { useTheme } from "@/theme"; import { Button, Icon, Spinner, Text } from "@/ui"; import { - findTimelineEntryIndexByRowId, type TimelineListEntry, } from "./list-entries"; import { getTimelineRowRenderer } from "./renderers"; @@ -42,8 +41,6 @@ import { } from "./sticky-bottom"; export interface TimelineListHandle { - /** Scroll a top-level row into view (table of contents). */ - scrollToRow(rowId: string): void; scrollToEnd(): void; } @@ -309,22 +306,8 @@ export const TimelineList = forwardRef( useImperativeHandle( ref, - () => ({ - scrollToRow: (rowId) => { - const index = findTimelineEntryIndexByRowId(entries, rowId); - if (index < 0) return; - stickyRef.current = reduceStickyBottom(stickyRef.current, { - type: "detach", - }); - void listRef.current?.scrollToIndex({ - index, - animated: true, - viewPosition: 0, - }); - }, - scrollToEnd: jumpToLatest, - }), - [entries, jumpToLatest], + () => ({ scrollToEnd: jumpToLatest }), + [jumpToLatest], ); const renderItem = useCallback( diff --git a/apps/mobile/src/screens/thread/timeline/index.ts b/apps/mobile/src/screens/thread/timeline/index.ts index e0e86fa712..0f86f1b77c 100644 --- a/apps/mobile/src/screens/thread/timeline/index.ts +++ b/apps/mobile/src/screens/thread/timeline/index.ts @@ -1,6 +1,5 @@ export { buildTimelineListItems, - buildTimelineTableOfContents, createTimelineTitleCache, timelineRowKind, timelineRowTitleOptions, @@ -10,7 +9,6 @@ export { type TimelineListItemOfKind, type TimelineRowByKind, type TimelineRowKind, - type TimelineTableOfContentsEntry, type TimelineTitleCache, type TimelineTurnChildrenState, type TimelineWorkRowKind, @@ -45,7 +43,6 @@ export { } from "./TimelineList"; export { buildTimelineListEntries, - findTimelineEntryIndexByRowId, UNREAD_DIVIDER_ENTRY_KEY, type TimelineListEntries, type TimelineListEntry, diff --git a/apps/mobile/src/screens/thread/timeline/list-entries.ts b/apps/mobile/src/screens/thread/timeline/list-entries.ts index d0099971e9..e62c035b1f 100644 --- a/apps/mobile/src/screens/thread/timeline/list-entries.ts +++ b/apps/mobile/src/screens/thread/timeline/list-entries.ts @@ -56,16 +56,3 @@ export function buildTimelineListEntries( }); return { entries, unreadDividerIndex }; } - -/** Index of the top-level entry for `rowId`, or -1. */ -export function findTimelineEntryIndexByRowId( - entries: readonly TimelineListEntry[], - rowId: string, -): number { - return entries.findIndex( - (entry) => - entry.type === "row" && - entry.item.depth === 0 && - entry.item.viewRow.id === rowId, - ); -} diff --git a/apps/mobile/src/screens/thread/timeline/rows.test.ts b/apps/mobile/src/screens/thread/timeline/rows.test.ts index bd373cc0b2..28b0187773 100644 --- a/apps/mobile/src/screens/thread/timeline/rows.test.ts +++ b/apps/mobile/src/screens/thread/timeline/rows.test.ts @@ -3,7 +3,6 @@ import { beforeEach, describe, expect, it } from "vitest"; import { buildTimelineListEntries } from "./list-entries"; import { buildTimelineListItems, - buildTimelineTableOfContents, createTimelineListItemCache, createTimelineTitleCache, type TimelineTurnChildrenState, @@ -289,43 +288,6 @@ describe("buildTimelineListItems", () => { }); }); -describe("buildTimelineTableOfContents", () => { - beforeEach(() => { - resetFixtureSequence(); - }); - - it("lists top-level user messages with normalized previews", () => { - const items = buildTimelineListItems({ - rows: [ - userRow("u1", " first\n\nquestion "), - assistantRow("a1", "answer"), - delegationRow("d1", [userRow("d1u", "nested prompt")], { - status: "completed", - }), - userRow("u2", "x".repeat(200)), - userRow("u3", "", { - attachments: { - webImages: 1, - localImages: 1, - localFiles: 0, - imageUrls: [], - localImagePaths: [], - localFilePaths: [], - }, - }), - ], - scopeActive: false, - isExpanded: (rowId) => rowId === "d1", - }); - const toc = buildTimelineTableOfContents(items); - expect(toc.map((entry) => entry.rowId)).toEqual(["u1", "u2", "u3"]); - expect(toc[0]?.preview).toBe("first question"); - expect(toc[1]?.preview.length).toBe(120); - expect(toc[1]?.preview.endsWith("…")).toBe(true); - expect(toc[2]?.preview).toBe("2 images"); - }); -}); - describe("buildTimelineListEntries", () => { beforeEach(() => { resetFixtureSequence(); diff --git a/apps/mobile/src/screens/thread/timeline/rows.ts b/apps/mobile/src/screens/thread/timeline/rows.ts index ea672913f0..457e434792 100644 --- a/apps/mobile/src/screens/thread/timeline/rows.ts +++ b/apps/mobile/src/screens/thread/timeline/rows.ts @@ -373,49 +373,3 @@ export function buildTimelineListItems({ if (itemCache && nextItems) itemCache.current = nextItems; return items; } - -/** User-authored messages in list order, for the table-of-contents sheet. */ -export interface TimelineTableOfContentsEntry { - key: string; - rowId: string; - preview: string; - createdAt: number; -} - -const TOC_PREVIEW_MAX_CHARS = 120; - -export function buildTimelineTableOfContents( - items: readonly TimelineListItem[], -): TimelineTableOfContentsEntry[] { - const entries: TimelineTableOfContentsEntry[] = []; - for (const item of items) { - if (item.kind !== "conversation:user" || item.depth !== 0) continue; - const preview = item.row.text.replace(/\s+/g, " ").trim(); - entries.push({ - key: item.key, - rowId: item.row.id, - preview: - preview.length > TOC_PREVIEW_MAX_CHARS - ? `${preview.slice(0, TOC_PREVIEW_MAX_CHARS - 1)}…` - : preview.length > 0 - ? preview - : attachmentOnlyPreview(item.row), - createdAt: item.row.createdAt, - }); - } - return entries; -} - -function attachmentOnlyPreview(row: TimelineUserConversationRow): string { - const attachments = row.attachments; - if (!attachments) return "(empty message)"; - const images = attachments.webImages + attachments.localImages; - const parts: string[] = []; - if (images > 0) parts.push(`${images} image${images === 1 ? "" : "s"}`); - if (attachments.localFiles > 0) { - parts.push( - `${attachments.localFiles} file${attachments.localFiles === 1 ? "" : "s"}`, - ); - } - return parts.length > 0 ? parts.join(", ") : "(empty message)"; -} diff --git a/apps/mobile/src/screens/threads/ArchivedThreadsScreen.tsx b/apps/mobile/src/screens/threads/ArchivedThreadsScreen.tsx index fc7850f559..3a27ed03a6 100644 --- a/apps/mobile/src/screens/threads/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/screens/threads/ArchivedThreadsScreen.tsx @@ -57,7 +57,6 @@ function toThreadRow(thread: ThreadListEntry): SidebarThreadRow { function ArchivedRow({ row, subtitle, - now, onPress, onLongPress, onUnarchive, @@ -65,7 +64,6 @@ function ArchivedRow({ }: { row: SidebarThreadRow; subtitle: string | null; - now: number; onPress: (row: SidebarThreadRow) => void; onLongPress: (row: SidebarThreadRow) => void; onUnarchive: (thread: ThreadListEntry) => void; @@ -80,8 +78,6 @@ function ArchivedRow({ row={row} selected={false} subtitle={subtitle} - timestamp={row.thread.archivedAt ?? row.thread.latestAttentionAt} - now={now} onPress={onPress} onLongPress={onLongPress} onToggleCollapsed={noop} @@ -116,7 +112,6 @@ function ArchivedBody({ const archived = useArchivedThreads(projectId ? { projectId } : {}); const unarchive = useUnarchiveThread(); const filterSheet = useSheet(); - const [now] = useState(() => Date.now()); const bootstrapData = bootstrap.data; const projects = useMemo( @@ -171,7 +166,6 @@ function ArchivedBody({ ? (projectNamesById.get(item.thread.projectId) ?? null) : null } - now={now} onPress={onPress} onLongPress={onLongPress} onUnarchive={onUnarchive} @@ -179,7 +173,6 @@ function ArchivedBody({ /> ), [ - now, onLongPress, onPress, onUnarchive, @@ -272,7 +265,6 @@ function ArchivedBody({ keyExtractor={keyExtractor} renderItem={renderItem} extraData={{ - now, projectId, pendingIds, isPending: unarchive.isPending, diff --git a/apps/mobile/src/screens/threads/ThreadSearchScreen.tsx b/apps/mobile/src/screens/threads/ThreadSearchScreen.tsx index 6d38819616..d4d08a965e 100644 --- a/apps/mobile/src/screens/threads/ThreadSearchScreen.tsx +++ b/apps/mobile/src/screens/threads/ThreadSearchScreen.tsx @@ -121,8 +121,6 @@ function SearchBody() { organize: preferences.organize, sort: preferences.sort, }); - // Age labels read from one timestamp per mount; the screen is short-lived. - const [now] = useState(() => Date.now()); const rows = useMemo( () => @@ -166,15 +164,13 @@ function SearchBody() { row={item.row} selected={false} subtitle={subtitle} - timestamp={item.row.thread.latestAttentionAt} - now={now} onPress={onPress} onLongPress={onLongPress} onToggleCollapsed={noop} /> ); }, - [noop, now, onLongPress, onPress, projectNamesById], + [noop, onLongPress, onPress, projectNamesById], ); const trimmed = query.trim(); @@ -237,7 +233,7 @@ function SearchBody() { keyExtractor={keyExtractor} getItemType={getItemType} renderItem={renderItem} - extraData={{ now, projectNamesById }} + extraData={{ projectNamesById }} maintainVisibleContentPosition={DISABLE_MAINTAIN_POSITION} keyboardShouldPersistTaps="handled" keyboardDismissMode="on-drag" diff --git a/apps/mobile/src/ui/Sheet.tsx b/apps/mobile/src/ui/Sheet.tsx index e6dbfc62f1..37d2857fdb 100644 --- a/apps/mobile/src/ui/Sheet.tsx +++ b/apps/mobile/src/ui/Sheet.tsx @@ -10,7 +10,9 @@ import { type BottomSheetModalProps, } from "@gorhom/bottom-sheet"; import { + createContext, useCallback, + useContext, useEffect, useMemo, useRef, @@ -59,6 +61,15 @@ export function useSheet(): SheetController { /** Wrap the app root (inside `GestureHandlerRootView`) once. */ export const SheetProvider = BottomSheetModalProvider; +/** + * Lets an ancestor (the composer) learn when a sheet mounted in its subtree + * presents or dismisses: presenting dismisses the keyboard, and the composer + * wants to stay expanded through a picker and refocus afterwards. + */ +export const SheetPresenceContext = createContext<{ + onPresenceChange: (open: boolean) => void; +} | null>(null); + export interface SheetProps extends Pick< BottomSheetModalProps, | "snapPoints" @@ -112,6 +123,16 @@ export function Sheet({ const insets = useSafeAreaInsets(); const [presented, setPresented] = useState(false); const realized = useDeferredRealization(presented); + const presence = useContext(SheetPresenceContext); + const onPresenceChange = presence?.onPresenceChange; + useEffect(() => { + if (!onPresenceChange) return; + onPresenceChange(presented); + return () => { + // Unmounting while presented counts as dismissed. + if (presented) onPresenceChange(false); + }; + }, [onPresenceChange, presented]); useEffect(() => { controller.attach({ @@ -157,7 +178,10 @@ export function Sheet({ ); const header = title ? ( - + {title} @@ -199,17 +223,20 @@ export function Sheet({ {body} ) : layout === "scroll" ? ( - <> + // The header rides inside the scroll view (pinned) so dynamic sizing + // measures it: outside, the sheet came up short by the header height + // and the last row plus the bottom inset slid under the home + // indicator. + {header} - - {body} - - + {body} + ) : ( {header} diff --git a/apps/mobile/src/ui/index.ts b/apps/mobile/src/ui/index.ts index 830eb8e3ba..5eb2f2b3a0 100644 --- a/apps/mobile/src/ui/index.ts +++ b/apps/mobile/src/ui/index.ts @@ -45,6 +45,7 @@ export { Separator, type SeparatorProps } from "./Separator"; export { Sheet, SheetFlatList, + SheetPresenceContext, SheetProvider, SheetScrollView, SheetTextInput, From 1f92fda448e6894c19393bb99b7911c04d49b3b2 Mon Sep 17 00:00:00 2001 From: Michael Yong <610102+ymichael@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:01:58 -0700 Subject: [PATCH 005/232] Stabilize TipTap editor test teardown (#2010) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What was wrong The [failing main CI run](https://github.com/get-bb/bb/actions/runs/32336675998) passed all 1,089 app-shard assertions, then reported an unhandled `scrollContainer.removeEventListener is not a function` exception from `PromptBoxInternal.test.tsx`. TipTap React intentionally destroys editor instances on a 1 ms timer after unmount so Strict Mode can reuse them. The suite returned from `afterEach` immediately after React cleanup, allowing the final delayed editor destruction to race Vitest jsdom environment shutdown on a loaded CI worker. ## What changed Wait for TipTap deferred editor destruction after cleaning up each `PromptBoxInternal` test. This keeps editor and scroll-listener teardown inside the jsdom environment that created them and prevents state from leaking across tests or into environment shutdown. There are no product, wire-protocol, CLI, guide, or documentation changes. ## How you verified - Fail-before evidence: main CI app shard completed 132 files and 1,089 assertions, then failed during delayed TipTap destruction. - `pnpm exec turbo run test --filter=@bb/app --force -- src/components/promptbox/PromptBoxInternal.test.tsx` — 99 tests passed. - `pnpm exec turbo run test --filter=@bb/app --force -- --shard=3/3` under concurrent typecheck load — 132 files and 1,089 tests passed. - `pnpm exec turbo run typecheck --filter=@bb/app` — passed. - `git diff --check` — passed. Fixes: N/A — CI teardown flake. > AGENT GENERATED: by GPT-5 --- .../src/components/promptbox/PromptBoxInternal.test.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx index 5aaf90be23..4fa3b27999 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx @@ -501,8 +501,13 @@ function mockIPadOSWebKit(): () => void { }); } -afterEach(() => { +afterEach(async () => { cleanup(); + // TipTap's React hook defers editor destruction by 1 ms so a Strict Mode + // remount can reuse the instance. Let that teardown finish while this + // test's jsdom window is still alive instead of leaking it into the next + // test (or the environment shutdown after the final test). + await new Promise((resolve) => setTimeout(resolve, 2)); resetPluginLogoStoreForTest(); resetPluginSlotStoreForTest(); resetAllCrashedPluginSlotsForTest(); From ce4e64dd5ad2395cb97df5ddedb4c4b272d11752 Mon Sep 17 00:00:00 2001 From: Michael Yong <610102+ymichael@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:38:04 -0700 Subject: [PATCH 006/232] Fix Claude resumed-input turn correlation (#2013) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What was wrong When Claude Code resumed a session that had orphaned background work, the SDK could drain a zero-work `result` with `origin.kind=task-notification` immediately before processing the newly queued human prompt. The Claude bridge emitted `input.accepted` as soon as it queued that prompt, and the translator allowed any result to claim a pending accepted input. The recovered notification therefore manufactured and completed an empty turn for the new message; the real answer continued later under a second, unaccepted turn. This is the mechanism independently reproduced in [#1718](https://github.com/get-bb/bb/issues/1718) and observed again in [the affected production thread](https://ymichael.getbb.app/projects/proj_qrv2s45kmq/threads/thr_gcuc46ug4j). ## What changed - Parse Claude result provenance and allow an idle result to claim pending input only when its origin is human (or omitted, which the SDK defines as human). A non-human result can still settle work when a turn is already open, preserving live background-notification loops and human zero-work commands such as `/clear`. - Make Claude `turn/start` match `turn/steer`: emit acceptance and answer the command only after the SDK prompt iterator consumes the queued input. - Add regressions for the recovered-task-notification sequence and for turn/start consumption ordering. Existing bridge tests now follow the same consumed-before-accepted contract. - Bump `HOST_DAEMON_PROTOCOL_VERSION` from 138 to 139 because older daemons emit the incorrect lifecycle semantics. The uniform provider rule is: `input.accepted` means the provider consumed the input, never merely that bb queued it. Codex already follows that rule. Pi's prompt promise currently reports settlement rather than consumption, and ACP exposes no equivalent provider acknowledgement, so those bridges need provider-specific correlation work rather than a timing heuristic in this focused Claude fix. The Pi/ACP cross-provider follow-up is tracked in [#2014](https://github.com/get-bb/bb/issues/2014). There are no CLI, guide, configuration, or user-facing documentation changes. ## How you verified - Before the implementation, the new provenance regression received `turn/started` + `turn/input/accepted` + `turn/completed` instead of no events, and the new turn/start ordering regression observed a response before SDK consumption. - `pnpm exec turbo run test --filter=bb-plugin-provider-claude-code --force` — 259 tests passed, including `/clear` zero-work conformance. - `pnpm exec turbo run typecheck --filter=bb-plugin-provider-claude-code --force` — passed. - `pnpm exec turbo run test typecheck --filter=@bb/host-daemon-contract --force` — 52 tests passed; typecheck passed. - `pnpm exec turbo run typecheck --filter=@bb/host-daemon --force` — passed. - `pnpm exec turbo run typecheck --filter=@bb/server --force` — passed. - `git diff --check` — passed. Fixes #1718 > AGENT GENERATED: by GPT-5 --- packages/host-daemon-contract/src/protocol.ts | 8 +- .../test/contract.test.ts | 2 +- .../src/bridge/__tests__/bridge.test.ts | 105 ++++++++++-------- .../provider-claude-code/src/bridge/bridge.ts | 43 +++---- .../src/delta-translation.test.ts | 70 ++++++++++++ .../src/delta-translation.ts | 15 ++- plugins/provider-claude-code/src/schemas.ts | 9 ++ 7 files changed, 173 insertions(+), 79 deletions(-) diff --git a/packages/host-daemon-contract/src/protocol.ts b/packages/host-daemon-contract/src/protocol.ts index 4bc39f147a..c221b0914a 100644 --- a/packages/host-daemon-contract/src/protocol.ts +++ b/packages/host-daemon-contract/src/protocol.ts @@ -1,3 +1,9 @@ +// Version 139 keeps a resumed Claude session's provider-owned task-notification +// result from claiming a newly accepted human input, and delays turn/start +// acceptance until Claude's SDK prompt iterator consumes the input. Older +// daemons can still make a sent message appear to complete immediately while +// its real response continues under a second, unaccepted turn. +// // Version 138 removes the `workspace.discover_repos` command. It existed only // for the first-run onboarding flow's project step, which is deleted; no server // sends it any more. A newer daemon no longer answers it, so an older server @@ -60,7 +66,7 @@ // // The version mismatch is what triggers the enrolled daemon's automatic update // instead of an `invalid-message` reconnect loop. -export const HOST_DAEMON_PROTOCOL_VERSION = 138 as const; +export const HOST_DAEMON_PROTOCOL_VERSION = 139 as const; /** * Absolute ceiling for any executable artifact delivered to a host daemon — diff --git a/packages/host-daemon-contract/test/contract.test.ts b/packages/host-daemon-contract/test/contract.test.ts index dd30f92ac4..51ad6ca7dd 100644 --- a/packages/host-daemon-contract/test/contract.test.ts +++ b/packages/host-daemon-contract/test/contract.test.ts @@ -1134,7 +1134,7 @@ describe("host-daemon command schemas", () => { // mixed version. Version 113 carried the Devin Desktop open target rename // and remains part of the protocol lineage. it("uses the current host-daemon protocol version", () => { - expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(138); + expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(139); expect(HOST_ARTIFACT_MAX_BYTES).toBe(256 * 1024 * 1024); }); diff --git a/plugins/provider-claude-code/src/bridge/__tests__/bridge.test.ts b/plugins/provider-claude-code/src/bridge/__tests__/bridge.test.ts index 50fcab3e2e..a5c5a89dc2 100644 --- a/plugins/provider-claude-code/src/bridge/__tests__/bridge.test.ts +++ b/plugins/provider-claude-code/src/bridge/__tests__/bridge.test.ts @@ -2815,8 +2815,8 @@ describe("bridge", () => { }, }, }); - await bridge.waitForResponse(2); await readNextPrompt(call); + await bridge.waitForResponse(2); expect(queries).toHaveLength(1); expect(query.close).not.toHaveBeenCalled(); @@ -2891,8 +2891,8 @@ describe("bridge", () => { }, }, }); - await bridge.waitForResponse(3); await readNextPrompt(call); + await bridge.waitForResponse(3); expect(queries).toHaveLength(1); expect(query.applyFlagSettings).toHaveBeenLastCalledWith({ @@ -3004,8 +3004,8 @@ describe("bridge", () => { providerOptions: {}, }, }); - await bridge.waitForResponse(2); const deniedPrompt = await readNextPrompt(call); + await bridge.waitForResponse(2); if (!deniedPrompt.uuid) { throw new Error("Expected denied prompt UUID"); } @@ -3034,8 +3034,8 @@ describe("bridge", () => { providerOptions: {}, }, }); - await bridge.waitForResponse(3); const askPrompt = await readNextPrompt(call); + await bridge.waitForResponse(3); if (!askPrompt.uuid) { throw new Error("Expected ask prompt UUID"); } @@ -3088,8 +3088,8 @@ describe("bridge", () => { providerOptions: {}, }, }); - await bridge.waitForResponse(4); const latestPrompt = await readNextPrompt(call); + await bridge.waitForResponse(4); if (!latestPrompt.uuid) { throw new Error("Expected latest prompt UUID"); } @@ -3419,7 +3419,7 @@ describe("bridge", () => { providerOptions: {}, }, }); - await bridge.waitForResponse(2); + await bridge.flushWork(); expect(queries).toHaveLength(2); expect(getLatestQueryOptions()).toMatchObject({ @@ -3428,6 +3428,7 @@ describe("bridge", () => { await expect(readNextPromptText(getLatestQueryCall())).resolves.toBe( inputText, ); + await bridge.waitForResponse(2); bridge.sendRequest(3, "thread/stop", { threadId, @@ -3559,6 +3560,9 @@ describe("bridge", () => { providerOptions: {}, }, }); + await expect(readNextPromptText(getLatestQueryCall())).resolves.toBe( + inputText, + ); await bridge.waitForResponse(2); queries[0]?.emit( @@ -3720,49 +3724,57 @@ describe("bridge", () => { } }); - it("delays turn steer responses until the SDK prompt consumes the input", async () => { - const threadId = "thread-steer-consumed"; - const bridge = createBridgeJsonRpcTestHarness(handleLine); - const queries: ControlledClaudeQuery[] = []; - queryMock.mockImplementation(() => { - const query = createControlledClaudeQuery(); - queries.push(query); - return query; - }); + it.each([ + { method: "turn/start", name: "turn start" }, + { method: "turn/steer", name: "turn steer" }, + ] as const)( + "delays $name responses until the SDK prompt consumes the input", + async (testCase) => { + const threadId = `thread-${testCase.method.replace("/", "-")}-consumed`; + const bridge = createBridgeJsonRpcTestHarness(handleLine); + const queries: ControlledClaudeQuery[] = []; + queryMock.mockImplementation(() => { + const query = createControlledClaudeQuery(); + queries.push(query); + return query; + }); - try { - await startBridgeThread({ bridge, threadId }); + try { + await startBridgeThread({ bridge, threadId }); - bridge.sendRequest(2, "turn/steer", { - threadId, - providerThreadId: threadId, - expectedTurnId: "turn-1", - input: [{ type: "text", text: "Please account for the restart" }], - clientRequestId: "creq_abcdefghjk", - options: { - permissionMode: "accept-edits", - permissionScope: "workspace", - approvalReviewer: "user", - permissionEscalation: "ask", - providerOptions: {}, - }, - }); - await bridge.flushWork(); + bridge.sendRequest(2, testCase.method, { + threadId, + providerThreadId: threadId, + ...(testCase.method === "turn/steer" + ? { expectedTurnId: "turn-1" } + : {}), + input: [{ type: "text", text: "Please account for the restart" }], + clientRequestId: "creq_abcdefghjk", + options: { + permissionMode: "accept-edits", + permissionScope: "workspace", + approvalReviewer: "user", + permissionEscalation: "ask", + providerOptions: {}, + }, + }); + await bridge.flushWork(); - expect(bridge.hasResponse(2)).toBe(false); - await expect(readNextPromptText(getLatestQueryCall())).resolves.toBe( - "Please account for the restart", - ); - await expect(bridge.waitForResponse(2)).resolves.toMatchObject({ - result: { threadId }, - }); + expect(bridge.hasResponse(2)).toBe(false); + await expect(readNextPromptText(getLatestQueryCall())).resolves.toBe( + "Please account for the restart", + ); + await expect(bridge.waitForResponse(2)).resolves.toMatchObject({ + result: { threadId }, + }); - await stopBridgeThread({ bridge, queries, threadId }); - } finally { - queries[0]?.finish(); - bridge.restore(); - } - }); + await stopBridgeThread({ bridge, queries, threadId }); + } finally { + queries[0]?.finish(); + bridge.restore(); + } + }, + ); it.each([ { method: "turn/start", name: "turn start" }, @@ -3861,8 +3873,8 @@ describe("bridge", () => { providerOptions: {}, }, }); - await bridge.waitForResponse(2); const text = await readNextPromptText(getLatestQueryCall()); + await bridge.waitForResponse(2); await stopBridgeThread({ bridge, queries, threadId }); return text; } @@ -4130,6 +4142,7 @@ describe("canonical model context-window hint", () => { input: [{ type: "text", text: "hello", mentions: [] }], options: { ...canonicalOptions, model: "claude-opus-4-7[1m]" }, }); + await readNextPrompt(getLatestQueryCall()); await bridge.waitForResponse(2); // A result with token usage but no `modelUsage`: the only capacity diff --git a/plugins/provider-claude-code/src/bridge/bridge.ts b/plugins/provider-claude-code/src/bridge/bridge.ts index bdbc233e7f..ed894c4711 100644 --- a/plugins/provider-claude-code/src/bridge/bridge.ts +++ b/plugins/provider-claude-code/src/bridge/bridge.ts @@ -563,10 +563,6 @@ function logBridgeError(message: string): void { process.stderr.write(`claude-code bridge: ${message}\n`); } -function ignoreInputConsumption(promise: Promise): void { - void promise.catch(() => {}); -} - function pushPromptInput( threadSession: ThreadSession, input: string, @@ -583,22 +579,6 @@ function pushPromptInput( }); } -function queuePromptInputs( - threadSession: ThreadSession, - inputs: readonly string[], - permissionEscalation: PermissionEscalation | null, -): boolean { - if (!threadSession.session.canPushInput()) { - return false; - } - for (const input of inputs) { - ignoreInputConsumption( - pushPromptInput(threadSession, input, permissionEscalation), - ); - } - return true; -} - async function applyLiveSessionSettings( threadSession: ThreadSession, threadId: string, @@ -2222,15 +2202,22 @@ async function runTurnStart( return; } - if ( - !queuePromptInputs(threadSession, [promptText], params.permissionEscalation) - ) { - sendError(id, -32000, "Claude SDK input stream is closed"); - return; + try { + await pushPromptInput( + threadSession, + promptText, + params.permissionEscalation, + ); + // Like steer, a new turn is accepted only after the SDK prompt iterator + // consumes it. Queueing alone cannot prove which provider-owned segment a + // concurrently drained result belongs to. + emitCanonicalTurnInputAccepted(threadSession, acceptance, params.threadId); + threadSession.permissionEscalation = params.permissionEscalation; + sendResult(id, { threadId: params.threadId }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + sendError(id, -32000, message); } - emitCanonicalTurnInputAccepted(threadSession, acceptance, params.threadId); - threadSession.permissionEscalation = params.permissionEscalation; - sendResult(id, { threadId: params.threadId }); } async function handleTurnStart( diff --git a/plugins/provider-claude-code/src/delta-translation.test.ts b/plugins/provider-claude-code/src/delta-translation.test.ts index 18134a4ae9..22a67a7644 100644 --- a/plugins/provider-claude-code/src/delta-translation.test.ts +++ b/plugins/provider-claude-code/src/delta-translation.test.ts @@ -681,6 +681,76 @@ describe("claude synthetic no-response handling", () => { ); }); + it("does not let a recovered task notification settle pending human input", () => { + const harness = createClaudeDeltaHarness(); + harness.acceptInput("creq_23456789af", "bb-thread-1"); + + // On resume the Claude SDK can drain a provider-owned task notification + // immediately before the queued human prompt. Its zero-work result is a + // different root segment and must not claim the pending bb input. + expect( + harness.translate( + { + type: "result", + subtype: "success", + is_error: false, + num_turns: 0, + result: "", + origin: { kind: "task-notification" }, + session_id: "claude-session-1", + }, + { threadId: "bb-thread-1" }, + ), + ).toEqual([]); + + const assistantEvents = harness.translate( + { + type: "assistant", + message: { + id: "human-response", + role: "assistant", + content: [{ type: "text", text: "I am working on it." }], + }, + session_id: "claude-session-1", + }, + { threadId: "bb-thread-1" }, + ); + + expect(assistantEvents).toContainEqual( + expect.objectContaining({ + type: "turn/input/accepted", + scope: turnScope(TURN_1), + clientRequestId: "creq_23456789af", + }), + ); + expect(assistantEvents).toContainEqual( + expect.objectContaining({ + type: "item/completed", + scope: turnScope(TURN_1), + item: expect.objectContaining({ text: "I am working on it." }), + }), + ); + + expect( + harness.translate( + { + type: "result", + subtype: "success", + is_error: false, + origin: { kind: "human" }, + session_id: "claude-session-1", + }, + { threadId: "bb-thread-1" }, + ), + ).toContainEqual( + expect.objectContaining({ + type: "turn/completed", + scope: turnScope(TURN_1), + status: "completed", + }), + ); + }); + it("ignores a trailing result once the turn has closed", () => { const harness = createClaudeDeltaHarness(); harness.acceptInput("creq_23456789af", "bb-thread-1"); diff --git a/plugins/provider-claude-code/src/delta-translation.ts b/plugins/provider-claude-code/src/delta-translation.ts index b95bdfe127..be2cea41c6 100644 --- a/plugins/provider-claude-code/src/delta-translation.ts +++ b/plugins/provider-claude-code/src/delta-translation.ts @@ -1206,9 +1206,18 @@ export function createClaudeDeltaTranslator() { return unexpectedSdkEventDeltas(event, context); } const message = parsedMessage.data; - // The terminal-turn rule: the result owns the open turn, or claims one - // proven by pending accepted input; on an idle thread it emits nothing. - if (!state.mirror.turnOpen && state.mirror.pendingInputs === 0) { + // The terminal-turn rule: the result owns the open turn, or a human result + // claims one proven by pending accepted input. On resume, Claude can drain + // a recovered task notification immediately before the queued human + // prompt. Its result belongs to a provider-owned root segment and must not + // steal that prompt's pending input. The SDK defines absent origin as + // human, preserving local zero-work commands such as /clear. + const resultCanClaimPendingInput = + message.origin === undefined || message.origin.kind === "human"; + if ( + !state.mirror.turnOpen && + (state.mirror.pendingInputs === 0 || !resultCanClaimPendingInput) + ) { return []; } // Claiming through pending input opens the turn first (clearing the diff --git a/plugins/provider-claude-code/src/schemas.ts b/plugins/provider-claude-code/src/schemas.ts index e98b880788..3ec1e7b470 100644 --- a/plugins/provider-claude-code/src/schemas.ts +++ b/plugins/provider-claude-code/src/schemas.ts @@ -392,6 +392,14 @@ const claudeResultSubtypeSchema = z.enum([ ]); export type ClaudeResultSubtype = z.infer; +const claudeMessageOriginSchema = z + .object({ + // The SDK treats an absent origin as human and can add new non-human + // provenance kinds over time. The translator only needs that distinction. + kind: z.string().min(1), + }) + .passthrough(); + export const claudeResultMessageSchema = z .object({ type: z.literal("result"), @@ -402,6 +410,7 @@ export const claudeResultMessageSchema = z result: z.unknown().optional(), usage: z.unknown().optional(), modelUsage: z.unknown().optional(), + origin: claudeMessageOriginSchema.optional(), }) .passthrough(); export type ClaudeResultMessage = z.infer; From 2260f522b67c3274d4ca25db82501f0dff6ff0bc Mon Sep 17 00:00:00 2001 From: Michael Yong <610102+ymichael@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:48:16 -0700 Subject: [PATCH 007/232] Fix sent-message editing across Codex and older browsers (#2012) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What was wrong Sent-message editing had three related compatibility assumptions: - After the narrow provider-bridge grammar moved canonical timeline assembly into the runtime, bb turn IDs and native Codex turn IDs became intentionally different, but the server still sent the bb timeline ID as the Codex rewind checkpoint. - The Codex bridge only persisted checkpoints for successfully completed turns, even though Codex also persists and accepts interrupted turn IDs as fork boundaries. Editing after a stopped turn therefore fell back to a bb ID such as `da2f291120-t3`, which Codex correctly rejected as absent from its source thread. - The web client called `crypto.randomUUID` directly even though some supported browser contexts expose Web Crypto `getRandomValues` without `randomUUID`, causing the edit action to throw before the editor opened. The failures were reproduced from the originally reported thread and from the interrupted-turn repro in `thr_kfzu8tqu2d`. ## What changed Codex rewinds now use the persisted native `providerCheckpointId`. The compatibility fallback is restricted to UUID-shaped turn IDs from legacy Codex timelines, so a runtime-minted bb ID can never be forwarded to Codex. The Codex bridge now persists the native checkpoint for completed and interrupted `turn/completed` statuses. This makes edits after a stopped Codex turn use the fork point Codex actually emitted. Failed turns remain unstamped because older Codex rollouts may omit them and no equivalent fork proof exists for that status. Web edit sessions now create operation IDs through `nanoid`, which is already an app dependency and works when `crypto.randomUUID` is unavailable. ID generation remains inside the edit click handler. These changes populate and validate the existing `providerCheckpointId` / `retainThroughProviderCheckpoint` fields; they do not change the server/daemon wire contract, so `HOST_DAEMON_PROTOCOL_VERSION` is unchanged. There are no CLI, guide, or configuration changes. ## How you verified - Direct `codex app-server` `thread/read` showed the interrupted native turn ID, and an ephemeral `thread/fork` using it as `lastTurnId` succeeded. - Before the original server implementation change, the focused suite failed 8 Codex cases with `checkpoint-first` expected and `turn-first` received. - `pnpm exec turbo run test --filter=@bb/server -- --run test/threads/thread-edit-message.test.ts` — 42 passed. - `pnpm exec turbo run test --filter=bb-plugin-provider-codex -- --run src/delta-translation.test.ts src/bridge/bridge.zero-work-turn.test.ts` — 61 passed, including the full `thread/stop` → interrupted completion checkpoint path. - `pnpm exec turbo run typecheck --filter=@bb/server` — passed. - `pnpm exec turbo run typecheck --filter=bb-plugin-provider-codex` — passed. - Before the web implementation change, the compatibility test failed with `TypeError: crypto.randomUUID is not a function`. - `pnpm exec turbo run test --filter=@bb/app --force -- --run src/views/thread-detail/sent-message-edit-operation-id.test.ts` — passed. - `pnpm exec turbo run typecheck --filter=@bb/app` — passed. - `pnpm exec turbo run lint --filter=@bb/app` — passed with 0 errors and 144 existing warnings. Fixes the reported sent-message edit failures. > AGENT GENERATED: by GPT-5 --- .../views/thread-detail/ThreadDetailView.tsx | 3 +- .../sent-message-edit-operation-id.test.ts | 23 ++++++ .../sent-message-edit-operation-id.ts | 5 ++ .../services/threads/thread-edit-message.ts | 14 +++- .../test/threads/thread-edit-message.test.ts | 76 +++++++++++++++++-- plugins/provider-codex/src/bridge/bridge.ts | 4 +- .../src/bridge/bridge.zero-work-turn.test.ts | 37 +++++++++ .../src/bridge/fake-codex-app-server.mjs | 14 ++++ .../src/delta-translation.test.ts | 6 +- .../provider-codex/src/delta-translation.ts | 7 +- 10 files changed, 172 insertions(+), 17 deletions(-) create mode 100644 apps/app/src/views/thread-detail/sent-message-edit-operation-id.test.ts create mode 100644 apps/app/src/views/thread-detail/sent-message-edit-operation-id.ts diff --git a/apps/app/src/views/thread-detail/ThreadDetailView.tsx b/apps/app/src/views/thread-detail/ThreadDetailView.tsx index dbc65dee11..f08886f1fd 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailView.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailView.tsx @@ -6,6 +6,7 @@ import { useState, type ReactNode, } from "react"; +import { createSentMessageEditOperationId } from "./sent-message-edit-operation-id"; import { useCachedProviderInfo } from "@/hooks/queries/system-queries"; import { useNavigate } from "react-router-dom"; import { useAtom } from "jotai"; @@ -1055,7 +1056,7 @@ function ThreadDetailViewInternal(props: ThreadDetailViewInternalProps) { setSentMessageEditHostElement(null); setSentMessageEditSession({ draft: editDraft, - operationId: crypto.randomUUID(), + operationId: createSentMessageEditOperationId(), target, threadId: current.thread.id, }); diff --git a/apps/app/src/views/thread-detail/sent-message-edit-operation-id.test.ts b/apps/app/src/views/thread-detail/sent-message-edit-operation-id.test.ts new file mode 100644 index 0000000000..c677dc1e2c --- /dev/null +++ b/apps/app/src/views/thread-detail/sent-message-edit-operation-id.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from "vitest"; +import { createSentMessageEditOperationId } from "./sent-message-edit-operation-id"; + +describe("createSentMessageEditOperationId", () => { + it("does not require crypto.randomUUID", () => { + const originalCrypto = globalThis.crypto; + vi.stubGlobal("crypto", { + getRandomValues: originalCrypto.getRandomValues.bind(originalCrypto), + randomUUID: undefined, + subtle: originalCrypto.subtle, + }); + + try { + const first = createSentMessageEditOperationId(); + const second = createSentMessageEditOperationId(); + + expect(first).not.toBe(second); + expect(first.length).toBeGreaterThan(0); + } finally { + vi.stubGlobal("crypto", originalCrypto); + } + }); +}); diff --git a/apps/app/src/views/thread-detail/sent-message-edit-operation-id.ts b/apps/app/src/views/thread-detail/sent-message-edit-operation-id.ts new file mode 100644 index 0000000000..52704eb349 --- /dev/null +++ b/apps/app/src/views/thread-detail/sent-message-edit-operation-id.ts @@ -0,0 +1,5 @@ +import { nanoid } from "nanoid"; + +export function createSentMessageEditOperationId(): string { + return nanoid(); +} diff --git a/apps/server/src/services/threads/thread-edit-message.ts b/apps/server/src/services/threads/thread-edit-message.ts index c7fd655ba0..e234733559 100644 --- a/apps/server/src/services/threads/thread-edit-message.ts +++ b/apps/server/src/services/threads/thread-edit-message.ts @@ -260,11 +260,23 @@ function resolveEditableTurnCandidate( ) { conflict("This earlier turn has no provider history"); } + // Runtime-assembled Codex timelines have bb-minted turn ids and persist the + // native Codex turn id as the checkpoint. Older timelines used the native + // UUID directly and have no checkpoint, so retain that compatibility + // fallback without ever forwarding a bb-minted id to Codex. + const legacyCodexCheckpoint = + thread.providerId === "codex" && + precedingTurnId !== null && + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + precedingTurnId, + ) + ? precedingTurnId + : null; const precedingProviderCheckpoint = precedingTurnId === null ? null : thread.providerId === "codex" - ? precedingTurnId + ? (precedingCompletion?.providerCheckpointId ?? legacyCodexCheckpoint) : (precedingCompletion?.providerCheckpointId ?? null); if (precedingTurnId !== null && precedingProviderCheckpoint === null) { conflict("This earlier provider turn has no editable history checkpoint"); diff --git a/apps/server/test/threads/thread-edit-message.test.ts b/apps/server/test/threads/thread-edit-message.test.ts index 2dc9aab578..75598dbea4 100644 --- a/apps/server/test/threads/thread-edit-message.test.ts +++ b/apps/server/test/threads/thread-edit-message.test.ts @@ -192,6 +192,7 @@ function seedEditableThread( firstCompletionStatus?: ThreadEventTurnStatus | null; firstProviderCheckpoint?: string | null; firstProviderThreadId?: string; + firstTurnId?: string; includeIdentity?: boolean; includeSecondTurn?: boolean; providerId?: string; @@ -238,7 +239,7 @@ function seedEditableThread( requestSequence: 2, text: "First message", threadId: thread.id, - turnId: "turn-first", + turnId: args.firstTurnId ?? "turn-first", ...(args.firstCompletionStatus !== undefined ? { completionStatus: args.firstCompletionStatus } : {}), @@ -383,7 +384,7 @@ describe("editThreadMessage", () => { (queued) => queued.command.type === "thread.rewind.prepare", ); expect(rewind.command).toMatchObject({ - retainThroughProviderCheckpoint: "turn-first", + retainThroughProviderCheckpoint: "checkpoint-first", }); if (rewind.command.type !== "thread.rewind.prepare") { throw new Error("Expected a thread.rewind.prepare command"); @@ -532,7 +533,7 @@ describe("editThreadMessage", () => { ); expect(rewind.command).toMatchObject({ sourceProviderThreadId: "provider-original", - retainThroughProviderCheckpoint: "turn-first", + retainThroughProviderCheckpoint: "checkpoint-first", threadId: thread.id, }); expect(listEvents(harness.db, { threadId: thread.id })).toHaveLength(11); @@ -724,8 +725,7 @@ describe("editThreadMessage", () => { ); expect(rewind.command).toMatchObject({ providerId, - retainThroughProviderCheckpoint: - providerId === "codex" ? "turn-first" : "checkpoint-first", + retainThroughProviderCheckpoint: "checkpoint-first", sourceProviderThreadId: "provider-original", }); if (rewind.command.type !== "thread.rewind.prepare") { @@ -907,9 +907,9 @@ describe("editThreadMessage", () => { (queued) => queued.command.type === "thread.rewind.prepare", ); // Resolving skips the ineligible grouped candidate and lands on - // sequence 7, whose preceding root turn is turn-first. + // sequence 7, whose preceding root turn has checkpoint-first. expect(rewind.command).toMatchObject({ - retainThroughProviderCheckpoint: "turn-first", + retainThroughProviderCheckpoint: "checkpoint-first", }); await reportQueuedCommandError(harness, rewind, { errorCode: "provider_error", @@ -1194,6 +1194,68 @@ describe("editThreadMessage", () => { }, ); + it("falls back to a legacy Codex turn id when history has no checkpoint", async () => { + await withTestHarness(async (harness) => { + const legacyCodexTurnId = "019f1234-5678-7abc-8def-0123456789ab"; + const { environment, thread } = seedEditableThread(harness, { + firstProviderCheckpoint: null, + firstTurnId: legacyCodexTurnId, + }); + const editPromise = editThreadMessage(harness.deps, { + environment, + thread, + payload: { + operationId: "edit-op-legacy-codex", + expectedRequestSequence: 7, + input: [{ type: "text", text: "Replacement", mentions: [] }], + }, + }); + + const rewind = await waitForQueuedCommand( + harness, + (queued) => queued.command.type === "thread.rewind.prepare", + ); + expect(rewind.command).toMatchObject({ + retainThroughProviderCheckpoint: legacyCodexTurnId, + }); + if (rewind.command.type !== "thread.rewind.prepare") { + throw new Error("Expected a thread.rewind.prepare command"); + } + await reportQueuedCommandSuccess(harness, rewind, { + providerThreadId: "provider-staged-legacy-codex", + }); + await expect(editPromise).resolves.toMatchObject({ ok: true }); + }); + }); + + it.each(["completed", "failed", "interrupted"] as const)( + "does not send a bb turn id to Codex when a %s turn has no checkpoint", + async (firstCompletionStatus) => { + await withTestHarness(async (harness) => { + const { environment, thread } = seedEditableThread(harness, { + firstCompletionStatus, + firstProviderCheckpoint: null, + firstTurnId: "da2f291120-t3", + }); + + await expect( + editThreadMessage(harness.deps, { + environment, + thread, + payload: { + operationId: `edit-op-missing-${firstCompletionStatus}-codex-checkpoint`, + expectedRequestSequence: 7, + input: [{ type: "text", text: "Replacement", mentions: [] }], + }, + }), + ).rejects.toThrow("no editable history checkpoint"); + expect( + listQueuedThreadCommands(harness, "thread.rewind.prepare", thread.id), + ).toHaveLength(0); + }); + }, + ); + it("leaves the original suffix untouched when Codex cannot stage the rewind", async () => { await withTestHarness(async (harness) => { const { environment, thread } = seedEditableThread(harness); diff --git a/plugins/provider-codex/src/bridge/bridge.ts b/plugins/provider-codex/src/bridge/bridge.ts index 96d0622a59..142360180f 100644 --- a/plugins/provider-codex/src/bridge/bridge.ts +++ b/plugins/provider-codex/src/bridge/bridge.ts @@ -18,8 +18,8 @@ * delta assembler mints the bb ids and reverse-maps command-plane ids, so * steer's expectedTurnId and interrupt's activeTurnId arrive here already * provider-native and the bridge does zero id translation. `turn.boundary` - * carries the Codex turn id as `providerCheckpointId` on completed turns so - * checkpoint forks survive bridge and runtime restarts. + * carries the Codex turn id as `providerCheckpointId` on completed and + * interrupted turns so checkpoint forks survive bridge and runtime restarts. * - Canonical → codex method mapping: `thread/stop {intent: "interrupt"}` → * `turn/interrupt`; `{intent: "release"}` → kill that thread's child (no * fabricated interruption — the rollout stays resumable, #1584); diff --git a/plugins/provider-codex/src/bridge/bridge.zero-work-turn.test.ts b/plugins/provider-codex/src/bridge/bridge.zero-work-turn.test.ts index 4d500f490c..63faea412a 100644 --- a/plugins/provider-codex/src/bridge/bridge.zero-work-turn.test.ts +++ b/plugins/provider-codex/src/bridge/bridge.zero-work-turn.test.ts @@ -132,6 +132,43 @@ it("settles a prompt the app-server accepts without any turn activity", async () ]); }, 30_000); +it("preserves the native checkpoint when thread/stop interrupts a turn", async () => { + const providerThreadId = await startSession(); + harness.sendRequest(2, "turn/start", { + threadId: THREAD_ID, + providerThreadId, + input: [{ type: "text", text: "/wait-for-interrupt", mentions: [] }], + clientRequestId: "creq_a2b3c4d5e6", + options: { ...sessionOptions }, + }); + await harness.waitForResponse(2); + await waitForEvents((events) => + events.some((event) => event.type === "turn/started"), + ); + + harness.sendRequest(3, "thread/stop", { + threadId: THREAD_ID, + providerThreadId, + intent: "interrupt", + activeTurnId: "turn-fx-1", + }); + await harness.waitForResponse(3); + + const events = await waitForEvents((all) => + all.some( + (event) => + event.type === "turn/completed" && event.status === "interrupted", + ), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "turn/completed", + status: "interrupted", + providerCheckpointId: "turn-fx-1", + }), + ); +}, 30_000); + it("lets a turn/started that lands after the turn/start response win the race", async () => { const providerThreadId = await startSession(); harness.sendRequest(2, "turn/start", { diff --git a/plugins/provider-codex/src/bridge/fake-codex-app-server.mjs b/plugins/provider-codex/src/bridge/fake-codex-app-server.mjs index 796eef01a8..3f679aa418 100644 --- a/plugins/provider-codex/src/bridge/fake-codex-app-server.mjs +++ b/plugins/provider-codex/src/bridge/fake-codex-app-server.mjs @@ -60,6 +60,9 @@ const ZERO_WORK_PROMPT_TEXT = "/clear"; const LATE_TURN_START_PROMPT_TEXT = "/late-start"; const LATE_TURN_START_DELAY_MS = 60; +/** A prompt that stays open until the client sends turn/interrupt. */ +const INTERRUPTIBLE_PROMPT_TEXT = "/wait-for-interrupt"; + /** * A prompt that spawns a native subagent (open thread work) and then dies with * the subagent still running — the crash/OOM shape. The bridge has to settle @@ -329,6 +332,17 @@ async function handleRequest(message) { ); return; } + if (firstInputText(params.input) === INTERRUPTIBLE_PROMPT_TEXT) { + turnCounter += 1; + const turnId = `turn-fx-${turnCounter}`; + openTurnIdsByThreadId.set(params.threadId, turnId); + notify("turn/started", { + threadId: params.threadId, + turn: { id: turnId, status: "inProgress" }, + }); + respond(id, {}); + return; + } if (scriptedTurns) { await runScriptFileTurn(params.threadId); } else { diff --git a/plugins/provider-codex/src/delta-translation.test.ts b/plugins/provider-codex/src/delta-translation.test.ts index 5a5600be44..f8c57b6a91 100644 --- a/plugins/provider-codex/src/delta-translation.test.ts +++ b/plugins/provider-codex/src/delta-translation.test.ts @@ -185,7 +185,7 @@ describe("codex turn lifecycle translation", () => { } }); - it("translates turn/completed with status and error", () => { + it("translates a failed turn/completed without claiming a fork checkpoint", () => { const harness = createHarness(); const events = harness.translate( codexEvent("turn/completed", { @@ -209,7 +209,6 @@ describe("codex turn lifecycle translation", () => { error: { message: "rate limited" }, }), ); - // Only completed turns are fork points. expect(events[0]).not.toHaveProperty("providerCheckpointId"); }); @@ -230,7 +229,7 @@ describe("codex turn lifecycle translation", () => { ]); }); - it("maps interrupted turn status", () => { + it("maps interrupted turn status with its fork checkpoint", () => { const harness = createHarness(); const events = harness.translate( codexEvent("turn/completed", { @@ -242,6 +241,7 @@ describe("codex turn lifecycle translation", () => { expect.objectContaining({ type: "turn/completed", status: "interrupted", + providerCheckpointId: "turn-1", }), ); }); diff --git a/plugins/provider-codex/src/delta-translation.ts b/plugins/provider-codex/src/delta-translation.ts index a9e6acd5f4..694c4ce1b6 100644 --- a/plugins/provider-codex/src/delta-translation.ts +++ b/plugins/provider-codex/src/delta-translation.ts @@ -765,9 +765,10 @@ export function translateCodexEventToDeltas( : {}), // The Codex turn id is the value codex thread/fork accepts as // lastTurnId, and unlike any in-memory map it survives bridge and - // runtime restarts. Only completed turns are fork points — a failed - // or interrupted turn may be absent from the rollout. - ...(status === "completed" + // runtime restarts. Completed and interrupted turns are persisted + // fork points. Failed turns can be absent from older Codex rollouts, + // so do not claim a checkpoint for those without equivalent proof. + ...(status === "completed" || status === "interrupted" ? { providerCheckpointId: handledEvent.params.turn.id } : {}), }, From 654d749f17c0120a2de6c7eaf486d01151092333 Mon Sep 17 00:00:00 2001 From: Michael Yong <610102+ymichael@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:50:25 -0700 Subject: [PATCH 008/232] Fix plugin diff highlighting in development (#2007) ## What was wrong React Strict Mode in the development app replays Pierre's ref callback against the retained diff custom element. The replacement renderer hydrates the existing plain `
`, records it as already highlighted,
and starts a worker task. Its normal render then early-returns because
the file is unchanged. When the worker returns the highlighted AST,
Pierre suppresses the repaint because the hydrated cache says it is
already highlighted. Production does not perform this simulated remount,
which is why `pnpm start` worked while `pnpm dev` did not.

The packages CI job also exposed a separate Connect test race: the
connected status renders before the asynchronous mobile-pairing
capability response, but three tests synchronously queried the
capability-gated button.

## What changed

Added a development-only host adapter around Pierre's public
`onPostRender` and `rerender()` APIs. A mount schedules one microtask
after React's ref replay: the discarded renderer has already been
disabled and safely no-ops, while the retained renderer takes Pierre's
normal forced-render path so its worker completion can repaint. The
adapter preserves plugin callbacks and stable option identities, covers
the plugin `File`, `FileDiff`, `MultiFileDiff`, `PatchDiff`, and
`UnresolvedFile` surfaces, and also covers the built-in diff card.
Production receives the original options object unchanged.

Removed the `pnpm` patched dependency and its renderer-internals
regression; the install now uses stock `@pierre/diffs` 1.2.9. Added a
host-level regression for the Strict Mode replay ordering. The Connect
tests now await the mobile-pairing button before interacting with it.
This is frontend/test-only; there are no host daemon protocol, CLI, or
documentation changes.

## How you verified

- The new recovery regression models the discarded and retained
renderers and verifies that repaint waits until after ref replay.
- `pnpm install --frozen-lockfile --ignore-scripts` passed with
unmodified `@pierre/diffs` 1.2.9.
- `pnpm exec turbo run test --filter=@bb/app --force`: 399 files / 3,041
tests passed (3 skipped).
- `pnpm exec turbo run test --filter=@bb/app --filter=bb-plugin-github
--filter=bb-plugin-connect --force`: app, GitHub (21 tests), and Connect
(90 tests) passed.
- `pnpm exec turbo run typecheck --filter=@bb/app
--filter=bb-plugin-github --filter=bb-plugin-connect --force`: passed.
- `pnpm exec turbo run lint --filter=@bb/app --force`: passed with 0
errors (pre-existing warnings only).
- `pnpm exec turbo run build --filter=@bb/app --force`: passed.
- Sawyer Hood's dev-browser against a real GitHub pull request under
`pnpm dev` found 39 syntax-token spans with 6 distinct token styles in
the original reproduction file.
- Reproduced the Connect failure locally before awaiting the
capability-gated button; its complete 90-test suite passes afterward.
- `git diff --check`: passed.

Fixes: development-only GitHub plugin diff syntax highlighting

> AGENT GENERATED: by GPT-5
---
 .../components/git-diff/GitDiffCardBody.tsx   |  4 +-
 .../lib/pierre-strict-mode-recovery.test.tsx  | 82 +++++++++++++++++++
 .../src/lib/pierre-strict-mode-recovery.ts    | 50 +++++++++++
 .../app/src/lib/plugin-pierre-diffs-react.tsx | 51 +++++++++---
 plugins/connect/app.test.tsx                  | 12 ++-
 5 files changed, 182 insertions(+), 17 deletions(-)
 create mode 100644 apps/app/src/lib/pierre-strict-mode-recovery.test.tsx
 create mode 100644 apps/app/src/lib/pierre-strict-mode-recovery.ts

diff --git a/apps/app/src/components/git-diff/GitDiffCardBody.tsx b/apps/app/src/components/git-diff/GitDiffCardBody.tsx
index fbe2001799..3796c98cb7 100644
--- a/apps/app/src/components/git-diff/GitDiffCardBody.tsx
+++ b/apps/app/src/components/git-diff/GitDiffCardBody.tsx
@@ -16,6 +16,7 @@ import type {
 import { useResolvedCodeThemePair } from "@/lib/code-theme";
 import { PierreWorkerPoolBoundary } from "@/lib/pierre-worker-pool-boundary";
 import { useRequirePierreWorkerPool } from "@/lib/pierre-worker-pool-gate";
+import { usePierreStrictModeRecoveryOptions } from "@/lib/pierre-strict-mode-recovery";
 import { FileDiff as DiffView } from "@pierre/diffs/react";
 import { useIntersectionObserver } from "usehooks-ts";
 import { Button } from "@bb/shared-ui/button";
@@ -1303,7 +1304,7 @@ function GitDiffCardRawDiffBody({
     enabled: onSelectionAddToChat !== undefined,
     onSelectionAddToChat,
   });
-  const options = useMemo>(
+  const baseOptions = useMemo>(
     () => ({
       ...fileDiffOptions,
       enableGutterUtility: onSelectionAddToChat !== undefined,
@@ -1327,6 +1328,7 @@ function GitDiffCardRawDiffBody({
       onSelectionAddToChat,
     ],
   );
+  const options = usePierreStrictModeRecoveryOptions(baseOptions);
   // `DiffView` captures the worker pool when it creates its instance, so wait
   // for the workspace to build the pool before the first render.
   const isWorkerPoolReady = useRequirePierreWorkerPool();
diff --git a/apps/app/src/lib/pierre-strict-mode-recovery.test.tsx b/apps/app/src/lib/pierre-strict-mode-recovery.test.tsx
new file mode 100644
index 0000000000..c82ea4bdcb
--- /dev/null
+++ b/apps/app/src/lib/pierre-strict-mode-recovery.test.tsx
@@ -0,0 +1,82 @@
+// @vitest-environment jsdom
+import { act, renderHook } from "@testing-library/react";
+import type { PostRenderPhase } from "@pierre/diffs";
+import { describe, expect, it, vi } from "vitest";
+import { usePierreStrictModeRecoveryOptions } from "./pierre-strict-mode-recovery";
+
+interface TestPierreInstance {
+  rerender(): void;
+}
+
+interface TestPierreOptions {
+  label: string;
+  onPostRender?(
+    node: HTMLElement,
+    instance: TestPierreInstance,
+    phase: PostRenderPhase,
+  ): unknown;
+}
+
+describe("Pierre Strict Mode recovery", () => {
+  it("waits for ref replay before repainting the retained instance", async () => {
+    const onPostRender = vi.fn();
+    const repaint = vi.fn();
+    let discardedIsActive = true;
+    const discardedRerender = vi.fn(() => {
+      if (discardedIsActive) repaint();
+    });
+    const retainedRerender = vi.fn(repaint);
+    const discardedInstance: TestPierreInstance = {
+      rerender: discardedRerender,
+    };
+    const retainedInstance: TestPierreInstance = { rerender: retainedRerender };
+    const options: TestPierreOptions = {
+      label: "plugin-diff",
+      onPostRender,
+    };
+    const { result, rerender } = renderHook(() =>
+      usePierreStrictModeRecoveryOptions(
+        options,
+      ),
+    );
+    const recoveredOptions = result.current;
+    const node = document.createElement("diffs-container");
+
+    recoveredOptions?.onPostRender?.(node, discardedInstance, "mount");
+    discardedIsActive = false;
+    recoveredOptions?.onPostRender?.(node, retainedInstance, "mount");
+
+    expect(onPostRender).toHaveBeenNthCalledWith(
+      1,
+      node,
+      discardedInstance,
+      "mount",
+    );
+    expect(onPostRender).toHaveBeenNthCalledWith(
+      2,
+      node,
+      retainedInstance,
+      "mount",
+    );
+    expect(repaint).not.toHaveBeenCalled();
+
+    await act(async () => {
+      await Promise.resolve();
+    });
+
+    expect(discardedRerender).toHaveBeenCalledOnce();
+    expect(retainedRerender).toHaveBeenCalledOnce();
+    expect(repaint).toHaveBeenCalledOnce();
+
+    rerender();
+    expect(result.current).toBe(recoveredOptions);
+
+    result.current?.onPostRender?.(node, retainedInstance, "update");
+    result.current?.onPostRender?.(node, retainedInstance, "unmount");
+    await act(async () => {
+      await Promise.resolve();
+    });
+
+    expect(retainedRerender).toHaveBeenCalledOnce();
+  });
+});
diff --git a/apps/app/src/lib/pierre-strict-mode-recovery.ts b/apps/app/src/lib/pierre-strict-mode-recovery.ts
new file mode 100644
index 0000000000..eaf7970da7
--- /dev/null
+++ b/apps/app/src/lib/pierre-strict-mode-recovery.ts
@@ -0,0 +1,50 @@
+import type { PostRenderPhase } from "@pierre/diffs";
+import { useMemo } from "react";
+
+interface RerenderablePierreInstance {
+  rerender(): void;
+}
+
+interface PierrePostRenderOptions<
+  TInstance extends RerenderablePierreInstance,
+> {
+  onPostRender?(
+    node: HTMLElement,
+    instance: TInstance,
+    phase: PostRenderPhase,
+  ): unknown;
+}
+
+/**
+ * Recovers a Pierre renderer after React replays its ref in development.
+ *
+ * Strict Mode can attach a replacement renderer to the first renderer's
+ * retained plain DOM. Pierre's public `rerender()` method moves that replacement
+ * through the normal render path, where its pending worker result is allowed to
+ * repaint. A microtask runs after the replay: the discarded instance is already
+ * disabled and safely no-ops, while the retained instance performs one forced
+ * render. Production returns the original options object unchanged.
+ */
+export function usePierreStrictModeRecoveryOptions<
+  TInstance extends RerenderablePierreInstance,
+  TOptions extends PierrePostRenderOptions,
+>(options: TOptions | undefined) {
+  return useMemo(() => {
+    if (!import.meta.env.DEV) return options;
+
+    const onPostRender = options?.onPostRender;
+    return {
+      ...options,
+      onPostRender(
+        node: HTMLElement,
+        instance: TInstance,
+        phase: PostRenderPhase,
+      ) {
+        onPostRender?.(node, instance, phase);
+        if (phase === "mount") {
+          queueMicrotask(() => instance.rerender());
+        }
+      },
+    };
+  }, [options]);
+}
diff --git a/apps/app/src/lib/plugin-pierre-diffs-react.tsx b/apps/app/src/lib/plugin-pierre-diffs-react.tsx
index df814a8b6e..5c338f2e91 100644
--- a/apps/app/src/lib/plugin-pierre-diffs-react.tsx
+++ b/apps/app/src/lib/plugin-pierre-diffs-react.tsx
@@ -32,6 +32,7 @@ import {
   usePierreWorkerPool,
   useRequirePierreWorkerPool,
 } from "./pierre-worker-pool-gate";
+import { usePierreStrictModeRecoveryOptions } from "./pierre-strict-mode-recovery";
 
 /**
  * The `@pierre/diffs/react` surface handed to plugin bundles through the
@@ -54,24 +55,48 @@ import {
  * mirrors `RUNTIME_EXPORT_MANIFEST["@pierre/diffs/react"]` in
  * packages/plugin-build; the plugin-frontend test keeps them in sync.
  */
-function gatePierreComponent

( - Component: ComponentType

, - name: string, -) { - function PierreWorkerPoolGated(props: P) { +function gatePierreDiffComponent< + P extends { + options?: ComponentPropsWithoutRef["options"]; + }, +>(Component: ComponentType

, name: string) { + function PierreWorkerPoolGatedDiff(props: P) { const ready = useRequirePierreWorkerPool(); + const options = usePierreStrictModeRecoveryOptions(props.options); return ( - {ready ? : null} + {ready ? : null} ); } - PierreWorkerPoolGated.displayName = `PierreWorkerPoolGated(${name})`; - return PierreWorkerPoolGated; + PierreWorkerPoolGatedDiff.displayName = `PierreWorkerPoolGated(${name})`; + return PierreWorkerPoolGatedDiff; } type CodeViewProps = ComponentPropsWithoutRef; type CodeViewHandle = ElementRef; +type FileProps = ComponentPropsWithoutRef; +type UnresolvedFileProps = ComponentPropsWithoutRef; + +function GatedFile(props: FileProps) { + const ready = useRequirePierreWorkerPool(); + const options = usePierreStrictModeRecoveryOptions(props.options); + return ( + + {ready ? : null} + + ); +} + +function GatedUnresolvedFile(props: UnresolvedFileProps) { + const ready = useRequirePierreWorkerPool(); + const options = usePierreStrictModeRecoveryOptions(props.options); + return ( + + {ready ? : null} + + ); +} /** `CodeView` forwards an imperative handle, so its gate must forward too. */ const GatedCodeView = forwardRef( @@ -106,13 +131,13 @@ function useHostWorkerPool(): ReturnType { export function createGatedPierreDiffsReact(): Record { return { CodeView: GatedCodeView, - File: gatePierreComponent(File, "File"), - FileDiff: gatePierreComponent(FileDiff, "FileDiff"), + File: GatedFile, + FileDiff: gatePierreDiffComponent(FileDiff, "FileDiff"), GutterUtilitySlotStyles, MergeConflictSlotStyles, - MultiFileDiff: gatePierreComponent(MultiFileDiff, "MultiFileDiff"), - PatchDiff: gatePierreComponent(PatchDiff, "PatchDiff"), - UnresolvedFile: gatePierreComponent(UnresolvedFile, "UnresolvedFile"), + MultiFileDiff: gatePierreDiffComponent(MultiFileDiff, "MultiFileDiff"), + PatchDiff: gatePierreDiffComponent(PatchDiff, "PatchDiff"), + UnresolvedFile: GatedUnresolvedFile, Virtualizer, VirtualizerContext, WorkerPoolContext, diff --git a/plugins/connect/app.test.tsx b/plugins/connect/app.test.tsx index 5a5366b19d..47d4efcb21 100644 --- a/plugins/connect/app.test.tsx +++ b/plugins/connect/app.test.tsx @@ -405,7 +405,9 @@ describe("connect settings section", () => { await slot.findByText("Connected"); expect(slot.queryByText("K7QP-2M4X")).toBeNull(); - fireEvent.click(slot.getByRole("button", { name: "Add mobile device" })); + fireEvent.click( + await slot.findByRole("button", { name: "Add mobile device" }), + ); await waitFor(() => expect(slot.rpcCalls).toContainEqual({ @@ -448,7 +450,9 @@ describe("connect settings section", () => { ); await slot.findByText("Connected"); - fireEvent.click(slot.getByRole("button", { name: "Add mobile device" })); + fireEvent.click( + await slot.findByRole("button", { name: "Add mobile device" }), + ); await slot.findByText("AAAA-1111"); await slot.findByText("Code expired", undefined, { timeout: 4_000 }); @@ -478,7 +482,9 @@ describe("connect settings section", () => { ); await slot.findByText("Connected"); - fireEvent.click(slot.getByRole("button", { name: "Add mobile device" })); + fireEvent.click( + await slot.findByRole("button", { name: "Add mobile device" }), + ); await slot.findByText(/reached its machine limit/); const link = slot.getByRole("link", { From 8a50f6a0654dd580e6e67af7be92425572a5e607 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Wed, 19 Aug 2026 23:51:08 -0700 Subject: [PATCH 009/232] Mobile: dark-safe scrims, keyboard gap, project folder icon, round stop button (#2015) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What was wrong Four mobile polish issues from dogfooding on a phone: - Every dimming overlay (home compose scrim, sheet backdrops) used `tokens.ink` at 35%. Dark palettes have a light `ink`, so the overlay lightened the screen to gray instead of dimming it. - `KeyboardPaddingView` subtracted the full home-indicator inset when the keyboard opened, so composers sat flush against the keyboard. - In the flat thread list the project name under a title was plain text and read like a second title. - The collapsed composer's Stop control was a square `secondary` button with a stroked square icon, flush with the pill edge. ## What changed - `apps/mobile/src/theme/scrim.ts`: `scrimBaseColor(mode, tokens)` — `ink` in light mode, black in dark mode. Used by the home compose scrim + dimmed header (`HomeScreen.tsx`), the bottom-sheet backdrop (`ui/Sheet.tsx`), and the navigation drawer overlay (`app/(drawer)/_layout.tsx`). - `ui/KeyboardPaddingView.tsx`: new `keyboardGap` prop and `COMPOSER_KEYBOARD_GAP = 8`; applied to the home dock, thread composer, and composer showcase. - `screens/sidebar/SidebarRows.tsx`: thread-row subtitle is now `{kind:"project"} | {kind:"snippet"}`; project subtitles render a `Folder` icon. Search snippets stay plain. Archived/search screens updated. - `composer/Composer.tsx`: `StopButton` — a 36pt round `secondary` circle with a filled square, used in both the collapsed pill and the expanded footer. ## How you verified - `pnpm exec turbo run typecheck lint --filter=@bb/mobile` pass. - New `scrim.test.ts` asserts the scrim darkens `background` for every palette × mode (fails with the old `ink` scrim in dark mode). - Simulator (iPhone 17 Pro, dark mode) against the local dev server: home compose scrim, display-options sheet backdrop, keyboard gap, project folder subtitle, and collapsed Stop button during a live turn. - Release build installed on a physical iPhone for the scrim/gap/folder changes. > AGENT GENERATED: by Claude Opus 5 Co-authored-by: Claude --- apps/mobile/app/(drawer)/_layout.tsx | 6 +- apps/mobile/src/composer/Composer.tsx | 67 +++++++++++++++---- .../screens/dev/ComposerShowcaseScreen.tsx | 6 +- apps/mobile/src/screens/home/HomeScreen.tsx | 24 ++++--- .../src/screens/sidebar/SidebarRows.tsx | 42 ++++++++++-- .../src/screens/sidebar/SidebarThreadList.tsx | 7 +- apps/mobile/src/screens/sidebar/index.ts | 2 + .../src/screens/thread/ThreadDetailScreen.tsx | 6 +- .../screens/threads/ArchivedThreadsScreen.tsx | 10 +-- .../screens/threads/ThreadSearchScreen.tsx | 10 ++- apps/mobile/src/theme/index.ts | 1 + apps/mobile/src/theme/scrim.test.ts | 28 ++++++++ apps/mobile/src/theme/scrim.ts | 17 +++++ apps/mobile/src/ui/KeyboardPaddingView.tsx | 20 +++++- apps/mobile/src/ui/Sheet.tsx | 7 +- apps/mobile/src/ui/index.ts | 1 + 16 files changed, 210 insertions(+), 44 deletions(-) create mode 100644 apps/mobile/src/theme/scrim.test.ts create mode 100644 apps/mobile/src/theme/scrim.ts diff --git a/apps/mobile/app/(drawer)/_layout.tsx b/apps/mobile/app/(drawer)/_layout.tsx index ef227164fb..734c3f7d8b 100644 --- a/apps/mobile/app/(drawer)/_layout.tsx +++ b/apps/mobile/app/(drawer)/_layout.tsx @@ -1,10 +1,11 @@ import { Drawer } from "expo-router/drawer"; import { useProfiles } from "@/app-shell"; import { DrawerContent } from "@/screens"; -import { useTheme } from "@/theme"; +import { withAlpha } from "@/markdown/colors"; +import { scrimBaseColor, useTheme } from "@/theme"; export default function DrawerLayout() { - const { tokens, fonts } = useTheme(); + const { tokens, fonts, mode } = useTheme(); const { activeProfile } = useProfiles(); return ( ( {/* Collapsed right slot: Stop while a turn runs (it must stay reachable without expanding), else the mic, else a spacer. */} {collapsed && affordance.stop ? ( - + + ); +} + +export default BbSourceCode; diff --git a/apps/app/src/components/code/DiffHost.test.tsx b/apps/app/src/components/code/DiffHost.test.tsx new file mode 100644 index 0000000000..ad0f229a22 --- /dev/null +++ b/apps/app/src/components/code/DiffHost.test.tsx @@ -0,0 +1,306 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from "@testing-library/react"; +import { createStore, Provider as JotaiProvider } from "jotai"; +import { act } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { PluginDiffRendererProps } from "@get-bb/plugin-sdk"; +import { defaultResolvedCodeTheme } from "@bb/domain"; +import { applyResolvedCodeTheme } from "@/lib/code-theme"; +import { + resetPluginSlotStoreForTest, + setPluginSlotRegistrations, +} from "@/lib/plugin-slots"; +import { resetAllCrashedPluginSlotsForTest } from "@/components/plugin/PluginSlotMount"; +import { parseGitDiffFiles } from "@/components/git-diff/git-diff-parsing"; +import { PluginDiff } from "@/components/plugin/PluginDiff"; +import { + BUILT_IN_REPLACEMENT_PROVIDER, + replacementProviderKey, +} from "@/lib/plugin-replacement-preference"; +import { diffRendererProviderAtom } from "./codeRendererProvider"; +import { DiffHost } from "./DiffHost"; + +/** + * Records whether BB's default renderer chunk was ever pulled. `vi.mock` + * factories run on first import of the specifier, and `DiffHost` only reaches + * `./BbDiff` through `lazy(() => import(...))`, so a flag set here is exactly + * "the default renderer chunk loaded". + */ +const bbDiff = vi.hoisted(() => ({ + loaded: false, + lastProps: null as Record | null, +})); + +vi.mock("./BbDiff", async () => { + const React = await import("react"); + bbDiff.loaded = true; + return { + default: (props: Record) => { + bbDiff.lastProps = props; + return React.createElement( + "div", + { "data-testid": "bb-diff" }, + `bb diff ${String(props.view)}/${String(props.overflow)}`, + ); + }, + }; +}); + +const PATCH = [ + "diff --git a/src/app.ts b/src/app.ts", + "--- a/src/app.ts", + "+++ b/src/app.ts", + "@@ -1,3 +1,3 @@", + " const a = 1;", + "-const b = 2;", + "+const b = 3;", + " const c = 4;", + "", +].join("\n"); + +function parseFixture() { + const file = parseGitDiffFiles(PATCH)[0]; + if (file === undefined) throw new Error("fixture patch did not parse"); + return file; +} + +const receivedProps: PluginDiffRendererProps[] = []; + +function registerDiffRenderer( + component: (props: PluginDiffRendererProps) => React.ReactNode, +) { + setPluginSlotRegistrations("demo", { + homepageSections: [], + settingsSections: [], + navPanels: [], + threadPanelActions: [], + sidebarFooterActions: [], + fileOpeners: [], + messageDirectives: [], + diffRenderers: [ + { id: "diffs", title: "Demo diffs", component }, + ], + }); +} + +beforeEach(() => { + bbDiff.loaded = false; + bbDiff.lastProps = null; + receivedProps.length = 0; + resetPluginSlotStoreForTest(); + applyResolvedCodeTheme(defaultResolvedCodeTheme); +}); + +afterEach(() => { + cleanup(); + resetAllCrashedPluginSlotsForTest(); + resetPluginSlotStoreForTest(); + vi.restoreAllMocks(); +}); + +describe("DiffHost", () => { + it("keeps BB's renderer chunk unloaded when a replacement never delegates", async () => { + registerDiffRenderer((props) => { + receivedProps.push(props); + return

plugin diff
; + }); + + render( + , + ); + + expect(await screen.findByTestId("plugin-diff")).toBeDefined(); + // A microtask/frame is enough for a lazy() import to settle if one were + // requested; assert after letting the queue drain. + await act(async () => { + await Promise.resolve(); + }); + expect(bbDiff.loaded).toBe(false); + }); + + it("hands the replacement resolved semantic props, not BB's host-only inputs", async () => { + registerDiffRenderer((props) => { + receivedProps.push(props); + return
plugin diff
; + }); + + render( + {}} + />, + ); + + await screen.findByTestId("plugin-diff"); + const props = receivedProps.at(-1); + expect(props?.patch).toBe(PATCH); + expect(props?.path).toBe("src/app.ts"); + expect(props?.view).toBe("split"); + expect(props?.overflow).toBe("wrap"); + expect(props?.showLineNumbers).toBe(false); + expect(Object.keys(props ?? {})).not.toContain("onSelectionAddToChat"); + expect(Object.keys(props ?? {})).not.toContain("file"); + }); + + it("reconstructs a complete single-file patch when the caller has no patch text", async () => { + registerDiffRenderer((props) => { + receivedProps.push(props); + return
plugin diff
; + }); + + render(); + + await screen.findByTestId("plugin-diff"); + const patch = receivedProps.at(-1)?.patch ?? ""; + expect(patch).toContain("diff --git a/src/app.ts b/src/app.ts"); + expect(patch).toContain("--- a/src/app.ts"); + expect(patch).toContain("+++ b/src/app.ts"); + expect(patch).toContain("-const b = 2;"); + expect(patch).toContain("+const b = 3;"); + // The reconstruction must re-parse to the same rendered file, or a + // replacement would draw something the caller never asked for. + const reparsed = parseGitDiffFiles(patch)[0]; + expect(reparsed?.name).toBe("src/app.ts"); + expect(reparsed?.hunks).toHaveLength(1); + }); + + it("loads BB's renderer only when the replacement delegates", async () => { + registerDiffRenderer(({ path, experimental_Original: Original }) => + path.endsWith(".ts") ? :
plugin diff
, + ); + + render(); + + expect(await screen.findByTestId("bb-diff")).toBeDefined(); + expect(bbDiff.loaded).toBe(true); + // Delegation must reach BB's renderer with the host-only inputs intact. + expect(bbDiff.lastProps?.file).toBeDefined(); + }); + + it("honours a pin to BB's renderer without disabling the plugin", async () => { + registerDiffRenderer((props) => { + receivedProps.push(props); + return
plugin diff
; + }); + const store = createStore(); + store.set(diffRendererProviderAtom, BUILT_IN_REPLACEMENT_PROVIDER); + + render( + + + , + ); + + expect(await screen.findByTestId("bb-diff")).toBeDefined(); + expect(receivedProps).toHaveLength(0); + }); + + it("keeps a pinned provider selected once another plugin sorts ahead of it", async () => { + registerDiffRenderer((props) => { + receivedProps.push(props); + return
first plugin
; + }); + setPluginSlotRegistrations("aardvark", { + homepageSections: [], + settingsSections: [], + navPanels: [], + threadPanelActions: [], + sidebarFooterActions: [], + fileOpeners: [], + messageDirectives: [], + diffRenderers: [ + { + id: "diffs", + title: "Aardvark diffs", + component: () =>
aardvark
, + }, + ], + }); + const store = createStore(); + // "aardvark" sorts before "demo", so automatic would switch the user's + // renderer out from under them; an explicit pin must not. + store.set( + diffRendererProviderAtom, + replacementProviderKey({ pluginId: "demo", id: "diffs" }), + ); + + render( + + + , + ); + + expect(await screen.findByTestId("plugin-diff")).toBeDefined(); + expect(screen.queryByTestId("aardvark-diff")).toBeNull(); + }); + + it("falls back to BB's renderer when the replacement crashes", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + registerDiffRenderer(() => { + throw new Error("replacement exploded"); + }); + + render(); + + expect(await screen.findByTestId("bb-diff")).toBeDefined(); + }); + + it("uses BB's renderer with resolved presentation defaults when nothing is registered", async () => { + render(); + + await screen.findByTestId("bb-diff"); + expect(bbDiff.lastProps?.view).toBe("unified"); + expect(bbDiff.lastProps?.overflow).toBe("scroll"); + expect(bbDiff.lastProps?.showLineNumbers).toBe(true); + }); +}); + +describe("experimental_Diff", () => { + it("shares the replacement with BB's own surfaces", async () => { + registerDiffRenderer((props) => { + receivedProps.push(props); + return
plugin diff
; + }); + + render(); + + await screen.findByTestId("plugin-diff"); + expect(receivedProps.at(-1)?.path).toBe("src/app.ts"); + expect(bbDiff.loaded).toBe(false); + }); + + it("completes a header-less patch before handing it to a replacement", async () => { + registerDiffRenderer((props) => { + receivedProps.push(props); + return
plugin diff
; + }); + + // The shape GitHub's REST API returns: hunks with no `diff --git` header. + render( + , + ); + + await screen.findByTestId("plugin-diff"); + const patch = receivedProps.at(-1)?.patch ?? ""; + expect(patch.startsWith("diff --git a/src/app.ts b/src/app.ts\n")).toBe( + true, + ); + expect(patch).not.toContain("\r"); + }); + + it("degrades to plain text instead of an empty diff when the patch will not parse", () => { + render(); + + expect(screen.getByText("not a patch at all")).toBeDefined(); + expect(screen.queryByTestId("bb-diff")).toBeNull(); + expect(bbDiff.loaded).toBe(false); + }); +}); diff --git a/apps/app/src/components/code/DiffHost.tsx b/apps/app/src/components/code/DiffHost.tsx new file mode 100644 index 0000000000..fcbd885048 --- /dev/null +++ b/apps/app/src/components/code/DiffHost.tsx @@ -0,0 +1,108 @@ +import { Suspense, lazy, useMemo, type ReactNode } from "react"; +import { PluginReplacementSlot } from "@/components/plugin/PluginReplacementSlot"; +import type { ParsedGitDiffFile } from "@/components/git-diff/git-diff-parsing"; +import { buildFileDiffPatchText } from "@/components/git-diff/git-diff-patch-text"; +import { useDiffRendererReplacement } from "./codeRendererProvider"; +import { + DEFAULT_CODE_OVERFLOW, + DEFAULT_DIFF_VIEW, + type DiffPresentation, +} from "./code-rendering"; + +/** Shared by the mount and the host's crash check. */ +export const DIFF_RENDERER_SLOT_KIND = "diffRenderer"; + +const BbDiff = lazy(() => import("./BbDiff")); + +export interface DiffHostProps extends Partial { + /** + * The parsed diff to render. Callers parse it anyway for their own header, + * and the diff panel additionally enriches it with full file contents so the + * renderer can expand context between hunks. + */ + file: ParsedGitDiffFile; + /** + * The patch text `file` was parsed from, when the caller still has it. A + * plugin replacement is handed this verbatim; without it the host + * reconstructs an equivalent single-file patch from `file`. + */ + patchText?: string; + className?: string; + /** + * Forwarded to BB's renderer; see {@link BbDiffProps.expansionLineCount}. + * Never reaches a plugin replacement — context expansion is a BB renderer + * capability, not part of the semantic contract. + */ + expansionLineCount?: number; + /** Rendered while BB's renderer chunk loads. */ + fallback?: ReactNode; + onSelectionAddToChat?: (text: string) => void; +} + +/** + * The host boundary for diff rendering (plugin design: exclusive replacement + * surfaces). Every BB surface that draws a text diff — timeline file changes, + * the environment diff panel's file bodies — and every plugin that calls + * `experimental_Diff` renders through here, so one + * `experimental_diffRenderer` registration replaces them all at once. + * + * BB's own renderer sits behind `lazy()`. A plugin replacement that never + * delegates therefore never downloads it, and `experimental_Original` costs + * nothing until it is actually rendered. + */ +export function DiffHost({ + file, + patchText, + view = DEFAULT_DIFF_VIEW, + overflow = DEFAULT_CODE_OVERFLOW, + showLineNumbers = true, + className, + expansionLineCount, + fallback = null, + onSelectionAddToChat, +}: DiffHostProps) { + const replacement = useDiffRendererReplacement(); + const isReplaced = replacement.kind === "plugin"; + // Only reconstructed when a replacement will actually read it: the walk is + // proportional to the rendered hunks, and BB's own renderer never needs it. + const semanticPatch = useMemo( + () => + isReplaced ? (patchText ?? buildFileDiffPatchText(file)) : "", + [file, isReplaced, patchText], + ); + + const original = ( + + + + ); + + return ( + + {(slot, BoundOriginal) => ( +
+ +
+ )} +
+ ); +} diff --git a/apps/app/src/components/code/SourceCodeHost.test.tsx b/apps/app/src/components/code/SourceCodeHost.test.tsx new file mode 100644 index 0000000000..2a010a2213 --- /dev/null +++ b/apps/app/src/components/code/SourceCodeHost.test.tsx @@ -0,0 +1,167 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from "@testing-library/react"; +import { act } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { PluginSourceCodeRendererProps } from "@get-bb/plugin-sdk"; +import { + resetPluginSlotStoreForTest, + setPluginSlotRegistrations, +} from "@/lib/plugin-slots"; +import { resetAllCrashedPluginSlotsForTest } from "@/components/plugin/PluginSlotMount"; +import { PluginSourceCode } from "@/components/plugin/PluginSourceCode"; +import { SourceCodeHost } from "./SourceCodeHost"; + +const bbSourceCode = vi.hoisted(() => ({ + loaded: false, + lastProps: null as Record | null, +})); + +vi.mock("./BbSourceCode", async () => { + const React = await import("react"); + bbSourceCode.loaded = true; + return { + default: (props: Record) => { + bbSourceCode.lastProps = props; + return React.createElement( + "div", + { "data-testid": "bb-source-code" }, + "bb source", + ); + }, + }; +}); + +const CONTENT = "const a = 1;\nconst b = 2;\n"; +const received: PluginSourceCodeRendererProps[] = []; + +function registerSourceCodeRenderer( + component: (props: PluginSourceCodeRendererProps) => React.ReactNode, +) { + setPluginSlotRegistrations("demo", { + homepageSections: [], + settingsSections: [], + navPanels: [], + threadPanelActions: [], + sidebarFooterActions: [], + fileOpeners: [], + messageDirectives: [], + sourceCodeRenderers: [{ id: "source", title: "Demo source", component }], + }); +} + +beforeEach(() => { + bbSourceCode.loaded = false; + bbSourceCode.lastProps = null; + received.length = 0; + resetPluginSlotStoreForTest(); +}); + +afterEach(() => { + cleanup(); + resetAllCrashedPluginSlotsForTest(); + resetPluginSlotStoreForTest(); + vi.restoreAllMocks(); +}); + +describe("SourceCodeHost", () => { + it("keeps BB's renderer chunk unloaded when a replacement never delegates", async () => { + registerSourceCodeRenderer((props) => { + received.push(props); + return
plugin source
; + }); + + render(); + + await screen.findByTestId("plugin-source"); + await act(async () => { + await Promise.resolve(); + }); + expect(bbSourceCode.loaded).toBe(false); + }); + + it("hands the replacement resolved semantic props, not BB's host-only inputs", async () => { + registerSourceCodeRenderer((props) => { + received.push(props); + return
plugin source
; + }); + + render( + {}} + />, + ); + + await screen.findByTestId("plugin-source"); + const props = received.at(-1); + expect(props?.content).toBe(CONTENT); + expect(props?.path).toBe("src/app.ts"); + expect(props?.overflow).toBe("wrap"); + expect(props?.highlightedLines).toEqual({ start: 2, end: 2 }); + expect(Object.keys(props ?? {})).not.toContain("cacheKey"); + expect(Object.keys(props ?? {})).not.toContain("onSelectionAddToChat"); + expect(Object.keys(props ?? {})).not.toContain("scrollToHighlightedLines"); + }); + + it("loads BB's renderer only when the replacement delegates", async () => { + registerSourceCodeRenderer(({ path, experimental_Original: Original }) => + path.endsWith(".md") ?
plugin source
: , + ); + + render( + , + ); + + expect(await screen.findByTestId("bb-source-code")).toBeDefined(); + expect(bbSourceCode.loaded).toBe(true); + // Delegation keeps the host-only inputs BB's own file preview depends on. + expect(bbSourceCode.lastProps?.cacheKey).toBe("rev-2:src/app.ts"); + expect(bbSourceCode.lastProps?.scrollToHighlightedLines).toBe(true); + }); + + it("falls back to BB's renderer when the replacement crashes", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + registerSourceCodeRenderer(() => { + throw new Error("replacement exploded"); + }); + + render(); + + expect(await screen.findByTestId("bb-source-code")).toBeDefined(); + }); + + it("resolves presentation defaults for BB's renderer", async () => { + render(); + + await screen.findByTestId("bb-source-code"); + expect(bbSourceCode.lastProps?.overflow).toBe("scroll"); + expect(bbSourceCode.lastProps?.highlightedLines).toBeNull(); + }); +}); + +describe("experimental_SourceCode", () => { + it("shares the replacement with BB's own surfaces", async () => { + registerSourceCodeRenderer((props) => { + received.push(props); + return
plugin source
; + }); + + render(); + + await screen.findByTestId("plugin-source"); + expect(received.at(-1)?.content).toBe(CONTENT); + expect(received.at(-1)?.highlightedLines).toBeNull(); + expect(bbSourceCode.loaded).toBe(false); + }); +}); diff --git a/apps/app/src/components/code/SourceCodeHost.tsx b/apps/app/src/components/code/SourceCodeHost.tsx new file mode 100644 index 0000000000..cee364fbdf --- /dev/null +++ b/apps/app/src/components/code/SourceCodeHost.tsx @@ -0,0 +1,78 @@ +import { Suspense, lazy, type ReactNode } from "react"; +import { PluginReplacementSlot } from "@/components/plugin/PluginReplacementSlot"; +import { useSourceCodeRendererReplacement } from "./codeRendererProvider"; +import { + DEFAULT_CODE_OVERFLOW, + type BbSourceCodeProps, +} from "./code-rendering"; + +/** Shared by the mount and the host's crash check. */ +export const SOURCE_CODE_RENDERER_SLOT_KIND = "sourceCodeRenderer"; + +const BbSourceCode = lazy(() => import("./BbSourceCode")); + +export interface SourceCodeHostProps + extends Omit { + overflow?: BbSourceCodeProps["overflow"]; + highlightedLines?: BbSourceCodeProps["highlightedLines"]; + /** Rendered while BB's renderer chunk loads. */ + fallback?: ReactNode; +} + +/** + * The host boundary for source rendering (plugin design: exclusive replacement + * surfaces). BB's native file preview and every plugin that calls + * `experimental_SourceCode` render through here, so one + * `experimental_sourceCodeRenderer` registration replaces them all at once. + * + * BB's own renderer sits behind `lazy()`; a replacement that never delegates + * never downloads it. + */ +export function SourceCodeHost({ + content, + path, + cacheKey, + overflow = DEFAULT_CODE_OVERFLOW, + highlightedLines = null, + className, + fallback = null, + scrollToHighlightedLines, + onSelectionAddToChat, +}: SourceCodeHostProps) { + const replacement = useSourceCodeRendererReplacement(); + + const original = ( + + + + ); + + return ( + + {(slot, BoundOriginal) => ( +
+ +
+ )} +
+ ); +} diff --git a/apps/app/src/components/code/code-rendering.ts b/apps/app/src/components/code/code-rendering.ts new file mode 100644 index 0000000000..fb5c0e8c18 --- /dev/null +++ b/apps/app/src/components/code/code-rendering.ts @@ -0,0 +1,74 @@ +import type { + CodeOverflowMode, + DiffViewMode, + SourceCodeLineRange, +} from "@get-bb/plugin-sdk"; +import type { ParsedGitDiffFile } from "@/components/git-diff/git-diff-parsing"; + +/** + * Internal contracts for the two host-owned code renderers. + * + * The host boundary splits every render into two halves. The *semantic* half + * (`SourceCodePresentation` / `DiffPresentation` plus the content) is what a + * plugin replacement receives — fully resolved, with no BB implementation + * types in it. The *host-only* half (pre-parsed diff files, selection-to-chat, + * layout classes) never leaves BB, so replacing a renderer can never make a + * plugin responsible for BB product behavior it cannot implement. + * + * This module is types plus two literals: importing it must never pull the + * renderer graph (`@pierre/diffs` and Shiki behind it) onto a caller's chunk. + */ + +export const DEFAULT_CODE_OVERFLOW: CodeOverflowMode = "scroll"; +export const DEFAULT_DIFF_VIEW: DiffViewMode = "unified"; + +/** Presentation the host resolved for one source render. */ +export interface SourceCodePresentation { + overflow: CodeOverflowMode; + highlightedLines: SourceCodeLineRange | null; +} + +/** Presentation the host resolved for one diff render. */ +export interface DiffPresentation { + view: DiffViewMode; + overflow: CodeOverflowMode; + showLineNumbers: boolean; +} + +/** Props BB's default source renderer receives from {@link SourceCodeHost}. */ +export interface BbSourceCodeProps extends SourceCodePresentation { + content: string; + path: string; + /** + * Stable identity for the highlighter's result cache. Defaults to `path`; + * callers that re-render the same path with different bytes (a file reloaded + * at a new revision) pass their own. + */ + cacheKey?: string; + className?: string; + /** + * Scroll the first highlighted line into view once it renders. The file + * preview wants it for `?L12` deep links; an inline snippet does not. + */ + scrollToHighlightedLines?: boolean; + onSelectionAddToChat?: (text: string) => void; +} + +/** Props BB's default diff renderer receives from {@link DiffHost}. */ +export interface BbDiffProps extends DiffPresentation { + /** + * The diff to draw. Already parsed — and possibly enriched with full file + * contents for context expansion — by the caller, which also needs it for + * its own header. + */ + file: ParsedGitDiffFile; + className?: string; + /** + * How many unchanged lines each expand-context click reveals. Set ONLY by a + * caller that can attach full file contents to `file`: pierre renders an + * empty diff when it is given an expansion budget for a hunk-only patch, + * which is what the timeline supplies. + */ + expansionLineCount?: number; + onSelectionAddToChat?: (text: string) => void; +} diff --git a/apps/app/src/components/code/codeRendererProvider.ts b/apps/app/src/components/code/codeRendererProvider.ts new file mode 100644 index 0000000000..1a01fa6497 --- /dev/null +++ b/apps/app/src/components/code/codeRendererProvider.ts @@ -0,0 +1,42 @@ +import { useAtomValue } from "jotai"; +import { + createReplacementPreferenceAtom, + resolvePreferredReplacement, +} from "@/lib/plugin-replacement-preference"; +import type { ResolvedReplacement } from "@/lib/plugin-slot-resolvers"; +import { + usePluginSlots, + type PluginDiffRendererSlot, + type PluginSourceCodeRendererSlot, +} from "@/lib/plugin-slots"; + +const SOURCE_CODE_RENDERER_STORAGE_KEY = "bb.appearance.sourceCodeRenderer"; +const DIFF_RENDERER_STORAGE_KEY = "bb.appearance.diffRenderer"; + +/** + * Automatic by default, with an explicit per-client override in Appearance — + * the same pin the sidebar thread list offers. A renderer replaces a surface + * the user cannot otherwise get back without disabling the whole plugin, so + * the pin is what keeps "installing activates it" reversible. + */ +export const sourceCodeRendererProviderAtom = createReplacementPreferenceAtom( + SOURCE_CODE_RENDERER_STORAGE_KEY, +); + +export const diffRendererProviderAtom = createReplacementPreferenceAtom( + DIFF_RENDERER_STORAGE_KEY, +); + +/** The active source renderer, or the owner when none applies. */ +export function useSourceCodeRendererReplacement(): ResolvedReplacement { + const { sourceCodeRenderers } = usePluginSlots(); + const preference = useAtomValue(sourceCodeRendererProviderAtom); + return resolvePreferredReplacement(sourceCodeRenderers, preference); +} + +/** The active diff renderer, or the owner when none applies. */ +export function useDiffRendererReplacement(): ResolvedReplacement { + const { diffRenderers } = usePluginSlots(); + const preference = useAtomValue(diffRendererProviderAtom); + return resolvePreferredReplacement(diffRenderers, preference); +} diff --git a/apps/app/src/components/code/source-code-budget.ts b/apps/app/src/components/code/source-code-budget.ts new file mode 100644 index 0000000000..2d1ee6a8b2 --- /dev/null +++ b/apps/app/src/components/code/source-code-budget.ts @@ -0,0 +1,78 @@ +/** + * Rendering budget for BB's source renderer. + * + * Tokenizing and laying out a 20k-line file is what stalls iOS Safari, so the + * renderer paints a leading prefix until the reader asks for the whole file. + * The rule lives here, apart from the renderer itself, because it is pure and + * the file preview's tests assert it directly — importing it must never pull + * the `@pierre/diffs` chunk. + */ + +export const SOURCE_CODE_MAX_LINES = 5_000; +export const SOURCE_CODE_MAX_CHARS = 512 * 1024; + +export interface SourceCodeTruncation { + /** The rendered prefix, cut at a line boundary. */ + contents: string; + renderedLineCount: number; + totalLineCount: number; +} + +// FNV-1a over the contents; only used to derive a mount key for renders +// whose caller did not supply a `cacheKey`. +export function hashSourceContents(contents: string): string { + let hash = 0x811c9dc5; + for (let index = 0; index < contents.length; index += 1) { + hash ^= contents.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return `${contents.length}:${(hash >>> 0).toString(36)}`; +} + +function countLines(contents: string): number { + if (contents.length === 0) return 0; + let count = 1; + for (let index = contents.indexOf("\n"); index !== -1; ) { + count += 1; + index = contents.indexOf("\n", index + 1); + } + return contents.endsWith("\n") ? count - 1 : count; +} + +/** + * Decide whether a source render exceeds {@link SOURCE_CODE_MAX_LINES} or + * {@link SOURCE_CODE_MAX_CHARS} and, if so, return the leading prefix + * that fits both budgets. Returns `null` when the whole file fits. + */ +export function truncateSourceCode( + contents: string, +): SourceCodeTruncation | null { + const totalLineCount = countLines(contents); + if ( + contents.length <= SOURCE_CODE_MAX_CHARS && + totalLineCount <= SOURCE_CODE_MAX_LINES + ) { + return null; + } + let renderedLineCount = 0; + let cutIndex = 0; + for ( + let lineStart = 0; + lineStart < contents.length && + renderedLineCount < SOURCE_CODE_MAX_LINES; + ) { + const newlineIndex = contents.indexOf("\n", lineStart); + const lineEnd = newlineIndex === -1 ? contents.length : newlineIndex; + if (lineEnd > SOURCE_CODE_MAX_CHARS && renderedLineCount > 0) { + break; + } + renderedLineCount += 1; + cutIndex = lineEnd; + lineStart = lineEnd + 1; + } + return { + contents: contents.slice(0, cutIndex), + renderedLineCount, + totalLineCount, + }; +} diff --git a/apps/app/src/components/git-diff/GitDiffCard.stories.tsx b/apps/app/src/components/git-diff/GitDiffCard.stories.tsx index 554a1dc3af..af73c9a104 100644 --- a/apps/app/src/components/git-diff/GitDiffCard.stories.tsx +++ b/apps/app/src/components/git-diff/GitDiffCard.stories.tsx @@ -3,10 +3,8 @@ import { builtInThemes, defaultAppTheme, type BuiltInThemeId } from "@bb/domain" import { cn } from "@bb/shared-ui/lib/utils"; import { Button } from "@bb/shared-ui/button"; import { resolveAppThemeCss } from "@/lib/themes"; -import { - GitDiffCard, - GIT_DIFF_VIEW_BASE_OPTIONS, -} from "@/components/git-diff/GitDiffCard"; +import { GitDiffCard } from "@/components/git-diff/GitDiffCard"; +import type { DiffPresentation } from "@/components/code/code-rendering"; import { parseGitDiffFiles } from "@/components/git-diff/git-diff-parsing"; /** @@ -85,19 +83,25 @@ function usePaletteCss(themeId: BuiltInThemeId) { ); } -function DiffStack({ themeType }: { themeType: "light" | "dark" }) { +const DIFF_PRESENTATION: DiffPresentation = { + view: "unified", + overflow: "scroll", + showLineNumbers: true, +}; + +// Syntax-highlight token colors follow the app's own light/dark preference +// (Layer 2), so both panes below tokenize the same way. What each pane proves +// is Layer 1: the surface, gutter, and +/- tints re-resolving through the +// forced `.light` / `.dark` class. +function DiffStack() { const files = useMemo(() => parseGitDiffFiles(SAMPLE_DIFF), []); - const diffViewOptions = useMemo>( - () => ({ ...GIT_DIFF_VIEW_BASE_OPTIONS, themeType }), - [themeType], - ); return (
{files.map((file, index) => ( ))}
@@ -117,7 +121,7 @@ function ModePane({ mode }: { mode: "light" | "dark" }) { {mode} - + ); } diff --git a/apps/app/src/components/git-diff/GitDiffCard.tsx b/apps/app/src/components/git-diff/GitDiffCard.tsx index a85d01af57..49ad505ad9 100644 --- a/apps/app/src/components/git-diff/GitDiffCard.tsx +++ b/apps/app/src/components/git-diff/GitDiffCard.tsx @@ -1,6 +1,7 @@ import { memo, useEffect, useMemo, useState } from "react"; import { useIntersectionObserver } from "usehooks-ts"; import { cn } from "@bb/shared-ui/lib/utils"; +import type { DiffPresentation } from "@/components/code/code-rendering"; import { GitDiffCardBody, useGitDiffCardBody, @@ -28,17 +29,11 @@ export type { RequestDiffFileContents, } from "./GitDiffCardBody"; -export const GIT_DIFF_VIEW_BASE_OPTIONS = { - overflow: "scroll", - disableFileHeader: false, - // Reveal 30 unchanged lines per expand-up / expand-down click. Library - // default is 100 — too aggressive for our compact diff cards. - expansionLineCount: 30, -} as const; - export interface GitDiffCardProps { fileDiff: ParsedGitDiffFile; - diffViewOptions: Record; + presentation: DiffPresentation; + /** Raw per-file patch text, when the caller still has it. */ + patchText?: string; filePathRoot?: string | null; onOpenFileInEditor?: (path: string) => void; onOpenFilePreview?: (path: string) => void; @@ -99,7 +94,8 @@ function buildGitDiffCardHeaderModel( export const GitDiffCard = memo(function GitDiffCard({ fileDiff, - diffViewOptions, + presentation, + patchText, filePathRoot, onOpenFileInEditor, onOpenFilePreview, @@ -124,6 +120,7 @@ export const GitDiffCard = memo(function GitDiffCard({ changeKind: headerModel.changeKind, isRendering, onRequestFileContents, + patchText, }); const [svgDisplayMode, setSvgDisplayMode] = useState("preview"); @@ -202,7 +199,7 @@ export const GitDiffCard = memo(function GitDiffCard({ {!isBodyHidden ? ( diff --git a/apps/app/src/components/git-diff/GitDiffCardBody.tsx b/apps/app/src/components/git-diff/GitDiffCardBody.tsx index 3796c98cb7..1975de406c 100644 --- a/apps/app/src/components/git-diff/GitDiffCardBody.tsx +++ b/apps/app/src/components/git-diff/GitDiffCardBody.tsx @@ -7,21 +7,12 @@ import { useRef, useState, } from "react"; -import type { - FileContents, - FileDiffOptions, - SelectedLineRange, - SelectionSide, -} from "@pierre/diffs"; -import { useResolvedCodeThemePair } from "@/lib/code-theme"; -import { PierreWorkerPoolBoundary } from "@/lib/pierre-worker-pool-boundary"; -import { useRequirePierreWorkerPool } from "@/lib/pierre-worker-pool-gate"; -import { usePierreStrictModeRecoveryOptions } from "@/lib/pierre-strict-mode-recovery"; -import { FileDiff as DiffView } from "@pierre/diffs/react"; +import type { FileContents } from "@pierre/diffs"; import { useIntersectionObserver } from "usehooks-ts"; import { Button } from "@bb/shared-ui/button"; import { usePointerCoarse } from "@bb/shared-ui/hooks/use-pointer-coarse"; -import { usePierreLineSelectionActions } from "./PierreLineSelectionActions.js"; +import { DiffHost } from "@/components/code/DiffHost"; +import type { DiffPresentation } from "@/components/code/code-rendering"; import { getWrappedImageIndex, ImageLightbox, @@ -66,10 +57,13 @@ export interface DiffImageSizeStat { export type GitDiffCardSvgDisplayMode = "preview" | "raw"; -const GIT_DIFF_CARD_VIEW_STYLE = { - "--diffs-font-size": "12px", - "--diffs-line-height": "18px", -} as CSSProperties; +/** + * Unchanged lines revealed per expand-up / expand-down click; the library + * default of 100 is too aggressive for our compact cards. Only sent for a + * card that can actually fetch full file contents — see + * {@link BbDiffProps.expansionLineCount}. + */ +const DIFF_EXPANSION_LINE_COUNT = 30; const GIT_DIFF_CARD_BODY_STYLE: CSSProperties = { contain: "layout paint style", @@ -293,6 +287,12 @@ export interface GitDiffCardBodyState { imageSizeStat: DiffImageSizeStat | null; /** On-demand full-file context for text cards; see {@link DiffContextExpansionState}. */ contextExpansion: DiffContextExpansionState; + /** + * The raw per-file patch the caller supplied, forwarded to the host diff + * boundary so a plugin replacement gets the caller's own bytes instead of a + * reconstruction. + */ + patchText: string | undefined; } /** @@ -530,6 +530,7 @@ export function useGitDiffCardBody({ loadDeletedDiff, imageSizeStat, contextExpansion, + patchText, }; } @@ -768,601 +769,14 @@ function GitDiffCardImageBody({ ); } -type DiffViewOptions = FileDiffOptions; - -interface GitDiffCardRawDiffBodyProps { - fileDiff: ParsedGitDiffFile; - fileDiffOptions: DiffViewOptions; - onSelectionAddToChat?: (text: string) => void; -} - -type DiffPatchDisplayStyle = "unified" | "split"; -type DiffPatchLinePrefix = " " | "+" | "-"; - -interface DiffPatchLine { - hunkIndex: number; - newLineNumber: number | null; - oldLineNumber: number | null; - prefix: DiffPatchLinePrefix; - selectionSide: SelectionSide | null; - splitLineIndex: number; - text: string; - unifiedLineIndex: number; -} - -function getDiffPatchDisplayStyle( - fileDiffOptions: DiffViewOptions, -): DiffPatchDisplayStyle { - return fileDiffOptions.diffStyle === "split" ? "split" : "unified"; -} - -function trimDiffLineEnding(line: string) { - return line.replace(/(?:\r\n|\n|\r)$/u, ""); -} - -function getDiffLineNumberFromIndex({ - hunkLineIndex, - hunkStart, - lineIndex, -}: { - hunkLineIndex: number; - hunkStart: number; - lineIndex: number; -}) { - return hunkStart + (lineIndex - hunkLineIndex); -} - -function formatPrefixedDiffPath(path: string, prefix: "a" | "b") { - if (path === "/dev/null") { - return path; - } - return path.startsWith(`${prefix}/`) ? path : `${prefix}/${path}`; -} - -function getDiffPatchPaths(fileDiff: ParsedGitDiffFile) { - const currentPath = normalizeGitDiffPath(fileDiff.name) ?? fileDiff.name; - const previousPath = normalizeGitDiffPath(fileDiff.prevName) ?? currentPath; - const gitOldPath = previousPath === "/dev/null" ? currentPath : previousPath; - const gitNewPath = currentPath === "/dev/null" ? previousPath : currentPath; - return { - diffGitOldPath: formatPrefixedDiffPath(gitOldPath, "a"), - diffGitNewPath: formatPrefixedDiffPath(gitNewPath, "b"), - oldHeaderPath: - fileDiff.type === "new" - ? "/dev/null" - : formatPrefixedDiffPath(previousPath, "a"), - newHeaderPath: - fileDiff.type === "deleted" - ? "/dev/null" - : formatPrefixedDiffPath(currentPath, "b"), - }; -} - -function collectDiffPatchLines(fileDiff: ParsedGitDiffFile): DiffPatchLine[] { - const patchLines: DiffPatchLine[] = []; - - fileDiff.hunks.forEach((hunk, hunkIndex) => { - let unifiedLineIndex = hunk.unifiedLineStart; - let splitLineIndex = hunk.splitLineStart; - let deletionLineIndex = hunk.deletionLineIndex; - let additionLineIndex = hunk.additionLineIndex; - - for (const content of hunk.hunkContent) { - if (content.type === "context") { - for (let offset = 0; offset < content.lines; offset += 1) { - const oldLineIndex = deletionLineIndex + offset; - const newLineIndex = additionLineIndex + offset; - const lineText = - fileDiff.additionLines[newLineIndex] ?? - fileDiff.deletionLines[oldLineIndex]; - if (lineText === undefined) { - continue; - } - patchLines.push({ - hunkIndex, - newLineNumber: getDiffLineNumberFromIndex({ - hunkLineIndex: hunk.additionLineIndex, - hunkStart: hunk.additionStart, - lineIndex: newLineIndex, - }), - oldLineNumber: getDiffLineNumberFromIndex({ - hunkLineIndex: hunk.deletionLineIndex, - hunkStart: hunk.deletionStart, - lineIndex: oldLineIndex, - }), - prefix: " ", - selectionSide: null, - splitLineIndex: splitLineIndex + offset, - text: trimDiffLineEnding(lineText), - unifiedLineIndex: unifiedLineIndex + offset, - }); - } - unifiedLineIndex += content.lines; - splitLineIndex += content.lines; - deletionLineIndex += content.lines; - additionLineIndex += content.lines; - continue; - } - - const splitCount = Math.max(content.deletions, content.additions); - const unifiedCount = content.deletions + content.additions; - for (let offset = 0; offset < content.deletions; offset += 1) { - const oldLineIndex = deletionLineIndex + offset; - const lineText = fileDiff.deletionLines[oldLineIndex]; - if (lineText === undefined) { - continue; - } - patchLines.push({ - hunkIndex, - newLineNumber: null, - oldLineNumber: getDiffLineNumberFromIndex({ - hunkLineIndex: hunk.deletionLineIndex, - hunkStart: hunk.deletionStart, - lineIndex: oldLineIndex, - }), - prefix: "-", - selectionSide: "deletions", - splitLineIndex: splitLineIndex + offset, - text: trimDiffLineEnding(lineText), - unifiedLineIndex: unifiedLineIndex + offset, - }); - } - for (let offset = 0; offset < content.additions; offset += 1) { - const newLineIndex = additionLineIndex + offset; - const lineText = fileDiff.additionLines[newLineIndex]; - if (lineText === undefined) { - continue; - } - patchLines.push({ - hunkIndex, - newLineNumber: getDiffLineNumberFromIndex({ - hunkLineIndex: hunk.additionLineIndex, - hunkStart: hunk.additionStart, - lineIndex: newLineIndex, - }), - oldLineNumber: null, - prefix: "+", - selectionSide: "additions", - splitLineIndex: splitLineIndex + offset, - text: trimDiffLineEnding(lineText), - unifiedLineIndex: unifiedLineIndex + content.deletions + offset, - }); - } - unifiedLineIndex += unifiedCount; - splitLineIndex += splitCount; - deletionLineIndex += content.deletions; - additionLineIndex += content.additions; - } - }); - - return patchLines; -} - -function getDiffPatchLineSelectionIndex( - line: DiffPatchLine, - displayStyle: DiffPatchDisplayStyle, -) { - return displayStyle === "split" ? line.splitLineIndex : line.unifiedLineIndex; -} - -function getDiffPatchSelectionPointIndex({ - displayStyle, - lineNumber, - patchLines, - side, -}: { - displayStyle: DiffPatchDisplayStyle; - lineNumber: number; - patchLines: DiffPatchLine[]; - side: SelectionSide | undefined; -}) { - const sides: SelectionSide[] = - side === undefined ? ["additions", "deletions"] : [side]; - for (const candidateSide of sides) { - const line = patchLines.find((patchLine) => - candidateSide === "additions" - ? patchLine.newLineNumber === lineNumber - : patchLine.oldLineNumber === lineNumber, - ); - if (line !== undefined) { - return getDiffPatchLineSelectionIndex(line, displayStyle); - } - } - return null; -} - -function isDiffPatchLineSelectedInSplitView({ - line, - range, -}: { - line: DiffPatchLine; - range: SelectedLineRange; -}) { - const startSide = range.side ?? range.endSide ?? "additions"; - const endSide = range.endSide ?? startSide; - if (startSide !== endSide || line.selectionSide === null) { - return true; - } - return line.selectionSide === startSide; -} - -function getSelectedDiffPatchLines({ - displayStyle, - patchLines, - range, -}: { - displayStyle: DiffPatchDisplayStyle; - patchLines: DiffPatchLine[]; - range: SelectedLineRange; -}) { - const startIndex = getDiffPatchSelectionPointIndex({ - displayStyle, - lineNumber: range.start, - patchLines, - side: range.side, - }); - const endIndex = getDiffPatchSelectionPointIndex({ - displayStyle, - lineNumber: range.end, - patchLines, - side: range.endSide ?? range.side, - }); - if (startIndex === null || endIndex === null) { - return []; - } - - const firstIndex = Math.min(startIndex, endIndex); - const lastIndex = Math.max(startIndex, endIndex); - return patchLines.filter((line) => { - const lineIndex = getDiffPatchLineSelectionIndex(line, displayStyle); - if (lineIndex < firstIndex || lineIndex > lastIndex) { - return false; - } - if (displayStyle === "unified") { - return true; - } - return isDiffPatchLineSelectedInSplitView({ line, range }); - }); -} - -function formatUnifiedDiffRange(start: number, count: number) { - return count === 1 ? String(start) : `${start},${count}`; -} - -function getMinimumLineNumber(lineNumbers: number[]) { - return lineNumbers.length > 0 ? Math.min(...lineNumbers) : null; -} - -function buildDiffPatchHunkHeader(lines: DiffPatchLine[]) { - const oldLineNumbers = lines - .map((line) => line.oldLineNumber) - .filter((lineNumber) => lineNumber !== null); - const newLineNumbers = lines - .map((line) => line.newLineNumber) - .filter((lineNumber) => lineNumber !== null); - const oldStart = - getMinimumLineNumber(oldLineNumbers) ?? - Math.max(0, (getMinimumLineNumber(newLineNumbers) ?? 1) - 1); - const newStart = - getMinimumLineNumber(newLineNumbers) ?? - Math.max(0, (getMinimumLineNumber(oldLineNumbers) ?? 1) - 1); - return `@@ -${formatUnifiedDiffRange( - oldStart, - oldLineNumbers.length, - )} +${formatUnifiedDiffRange(newStart, newLineNumbers.length)} @@`; -} - -function groupDiffPatchLinesByHunk(lines: DiffPatchLine[]) { - const groups: DiffPatchLine[][] = []; - for (const line of lines) { - const previousGroup = groups.at(-1); - if (previousGroup?.at(-1)?.hunkIndex === line.hunkIndex) { - previousGroup.push(line); - } else { - groups.push([line]); - } - } - return groups; -} - -function buildUnifiedDiffPatchText({ - fileDiff, - lines, -}: { - fileDiff: ParsedGitDiffFile; - lines: DiffPatchLine[]; -}) { - const paths = getDiffPatchPaths(fileDiff); - const patchTextLines = [ - `diff --git ${paths.diffGitOldPath} ${paths.diffGitNewPath}`, - `--- ${paths.oldHeaderPath}`, - `+++ ${paths.newHeaderPath}`, - ]; - for (const group of groupDiffPatchLinesByHunk(lines)) { - patchTextLines.push( - buildDiffPatchHunkHeader(group), - ...group.map((line) => `${line.prefix}${line.text}`), - ); - } - return patchTextLines.join("\n"); -} - -function buildDiffLineSelectionText({ - displayStyle, - fileDiff, - range, -}: { - displayStyle: DiffPatchDisplayStyle; - fileDiff: ParsedGitDiffFile; - range: SelectedLineRange; -}): string | null { - const patchLines = collectDiffPatchLines(fileDiff); - const selectedLines = getSelectedDiffPatchLines({ - displayStyle, - patchLines, - range, - }); - if (selectedLines.length === 0) { - return null; - } - return buildUnifiedDiffPatchText({ fileDiff, lines: selectedLines }); -} - -function getDiffShadowRoots(containerElement: HTMLElement | null) { - if (containerElement === null) { - return []; - } - return Array.from(containerElement.querySelectorAll("diffs-container")) - .map((container) => container.shadowRoot) - .filter((root) => root !== null); -} - -function getDiffDomLineSide(lineElement: HTMLElement): SelectionSide { - const codeElement = lineElement.closest("[data-deletions],[data-additions]"); - if (codeElement?.hasAttribute("data-deletions")) { - return "deletions"; - } - if (codeElement?.hasAttribute("data-additions")) { - return "additions"; - } - return lineElement.dataset.lineType === "change-deletion" - ? "deletions" - : "additions"; -} - -function getDiffDomLineNumber(lineElement: HTMLElement): number | null { - const lineNumber = Number.parseInt(lineElement.dataset.line ?? "", 10); - return Number.isFinite(lineNumber) ? lineNumber : null; -} - -function getDiffDomLineText(lineElement: HTMLElement): string { - return (lineElement.textContent ?? "").trimEnd(); -} - -function getDiffDomLineIndex(lineElement: HTMLElement) { - const [unifiedLineIndex, splitLineIndex] = ( - lineElement.dataset.lineIndex ?? "" - ) - .split(",") - .map((value) => Number.parseInt(value, 10)); - if ( - unifiedLineIndex === undefined || - splitLineIndex === undefined || - !Number.isFinite(unifiedLineIndex) || - !Number.isFinite(splitLineIndex) - ) { - return null; - } - return { splitLineIndex, unifiedLineIndex }; -} - -function getDiffDomPatchPrefix(lineElement: HTMLElement): DiffPatchLinePrefix { - switch (lineElement.dataset.lineType) { - case "change-deletion": - return "-"; - case "change-addition": - return "+"; - default: - return " "; - } -} - -function getDiffDomPatchLine({ - hunkIndex, - lineElement, -}: { - hunkIndex: number; - lineElement: HTMLElement; -}): DiffPatchLine | null { - const lineIndex = getDiffDomLineIndex(lineElement); - if (lineIndex === null) { - return null; - } - const lineNumber = getDiffDomLineNumber(lineElement); - const prefix = getDiffDomPatchPrefix(lineElement); - const side = getDiffDomLineSide(lineElement); - return { - hunkIndex, - newLineNumber: - lineNumber !== null && - (prefix === "+" || (prefix === " " && side === "additions")) - ? lineNumber - : null, - oldLineNumber: - lineNumber !== null && - (prefix === "-" || (prefix === " " && side === "deletions")) - ? lineNumber - : null, - prefix, - selectionSide: prefix === " " ? null : side, - splitLineIndex: lineIndex.splitLineIndex, - text: getDiffDomLineText(lineElement), - unifiedLineIndex: lineIndex.unifiedLineIndex, - }; -} - -function mergeDiffDomContextLine( - existingLine: DiffPatchLine, - nextLine: DiffPatchLine, -) { - return { - ...existingLine, - newLineNumber: existingLine.newLineNumber ?? nextLine.newLineNumber, - oldLineNumber: existingLine.oldLineNumber ?? nextLine.oldLineNumber, - }; -} - -function buildDiffDomSelectionText({ - containerElement, - fileDiff, -}: { - containerElement: HTMLElement | null; - fileDiff: ParsedGitDiffFile; -}): string | null { - if (containerElement === null) { - return null; - } - - const selectedRows: HTMLElement[] = []; - const seenRows = new Set(); - for (const root of getDiffShadowRoots(containerElement)) { - for (const row of root.querySelectorAll( - "[data-selected-line][data-line]", - )) { - const text = getDiffDomLineText(row); - const lineIndex = row.dataset.lineIndex ?? ""; - const side = getDiffDomLineSide(row); - const key = `${lineIndex}:${side}:${text}`; - if (seenRows.has(key)) { - continue; - } - seenRows.add(key); - selectedRows.push(row); - } - } - - if (selectedRows.length === 0) { - return null; - } - - const patchLineMap = new Map(); - for (const row of selectedRows) { - const patchLine = getDiffDomPatchLine({ hunkIndex: 0, lineElement: row }); - if (patchLine === null) { - continue; - } - const key = [ - patchLine.unifiedLineIndex, - patchLine.splitLineIndex, - patchLine.prefix, - patchLine.text, - ].join(":"); - const existingLine = patchLineMap.get(key); - patchLineMap.set( - key, - existingLine !== undefined && patchLine.prefix === " " - ? mergeDiffDomContextLine(existingLine, patchLine) - : (existingLine ?? patchLine), - ); - } - const patchLines = Array.from(patchLineMap.values()).sort((lineA, lineB) => { - if (lineA.unifiedLineIndex !== lineB.unifiedLineIndex) { - return lineA.unifiedLineIndex - lineB.unifiedLineIndex; - } - return lineA.prefix.localeCompare(lineB.prefix); - }); - return patchLines.length > 0 - ? buildUnifiedDiffPatchText({ fileDiff, lines: patchLines }) - : null; -} - -function GitDiffCardRawDiffBody({ - fileDiff, - fileDiffOptions, - onSelectionAddToChat, -}: GitDiffCardRawDiffBodyProps) { - const containerRef = useRef(null); - const displayStyle = getDiffPatchDisplayStyle(fileDiffOptions); - const buildSelectionText = useCallback( - (range: SelectedLineRange) => - buildDiffLineSelectionText({ displayStyle, fileDiff, range }), - [displayStyle, fileDiff], - ); - const buildFallbackSelectionText = useCallback( - ({ - containerElement, - }: { - containerElement: HTMLElement | null; - range: SelectedLineRange; - }) => buildDiffDomSelectionText({ containerElement, fileDiff }), - [fileDiff], - ); - const lineSelectionActions = usePierreLineSelectionActions({ - buildFallbackSelectionText, - buildSelectionText, - containerRef, - enabled: onSelectionAddToChat !== undefined, - onSelectionAddToChat, - }); - const baseOptions = useMemo>( - () => ({ - ...fileDiffOptions, - enableGutterUtility: onSelectionAddToChat !== undefined, - enableLineSelection: onSelectionAddToChat !== undefined, - lineHoverHighlight: - onSelectionAddToChat === undefined ? "disabled" : "number", - onGutterUtilityClick: - onSelectionAddToChat === undefined - ? undefined - : lineSelectionActions.onGutterUtilityClick, - onLineSelectionChange: lineSelectionActions.onLineSelectionChange, - onLineSelectionEnd: lineSelectionActions.onLineSelectionEnd, - onLineSelectionStart: lineSelectionActions.onLineSelectionStart, - }), - [ - fileDiffOptions, - lineSelectionActions.onGutterUtilityClick, - lineSelectionActions.onLineSelectionChange, - lineSelectionActions.onLineSelectionEnd, - lineSelectionActions.onLineSelectionStart, - onSelectionAddToChat, - ], - ); - const options = usePierreStrictModeRecoveryOptions(baseOptions); - // `DiffView` captures the worker pool when it creates its instance, so wait - // for the workspace to build the pool before the first render. - const isWorkerPoolReady = useRequirePierreWorkerPool(); - if (!isWorkerPoolReady) { - return ; - } - return ( -
-
- - - -
- {lineSelectionActions.menu} -
- ); -} - interface GitDiffCardSvgBodyProps { displayMode: GitDiffCardSvgDisplayMode; enrichment: DiffFileEnrichmentState; fileDiff: ParsedGitDiffFile; fileDiffLabel: string; - fileDiffOptions: DiffViewOptions; + patchText: string | undefined; + presentation: DiffPresentation; + expansionLineCount: number | undefined; onSelectionAddToChat?: (text: string) => void; } @@ -1371,7 +785,9 @@ function GitDiffCardSvgBody({ enrichment, fileDiff, fileDiffLabel, - fileDiffOptions, + patchText, + presentation, + expansionLineCount, onSelectionAddToChat, }: GitDiffCardSvgBodyProps) { return displayMode === "preview" ? ( @@ -1381,9 +797,11 @@ function GitDiffCardSvgBody({ fitToFrame /> ) : ( - ); @@ -1391,7 +809,7 @@ function GitDiffCardSvgBody({ export interface GitDiffCardBodyProps { state: GitDiffCardBodyState; - diffViewOptions: Record; + presentation: DiffPresentation; svgDisplayMode: GitDiffCardSvgDisplayMode; /** * Whether the surrounding card reserves a collapse-chevron gutter. The deleted @@ -1403,16 +821,18 @@ export interface GitDiffCardBodyProps { /** * The single shared diff-card body for both the timeline ({@link GitDiffCard}) - * and the diff tab (`DiffFileCard`). It renders the lazily-enriched - * `@pierre/diffs` `FileDiff` (with context expansion), the deleted-file load + * and the diff tab (`DiffFileCard`). It owns everything around the text diff — + * the lazy enrichment that unlocks context expansion, the deleted-file load * gate, the in-viewport render skeleton, and inline `` previews for binary - * image changes or SVGs. The data layer lives in {@link useGitDiffCardBody}; - * both callers feed its result in as `state` so the card can also read the image - * header stat synchronously. + * image changes or SVGs — and hands the text diff itself to + * {@link DiffHost}, so an `experimental_diffRenderer` replacement covers this + * surface too. The data layer lives in {@link useGitDiffCardBody}; both callers + * feed its result in as `state` so the card can also read the image header stat + * synchronously. */ export function GitDiffCardBody({ state, - diffViewOptions, + presentation, svgDisplayMode, reservesCollapseGutter, onSelectionAddToChat, @@ -1428,16 +848,15 @@ export function GitDiffCardBody({ shouldRenderDiffView, loadDeletedDiff, contextExpansion, + patchText, } = state; - const codeTheme = useResolvedCodeThemePair(); - const fileDiffOptions = useMemo( - () => ({ - ...diffViewOptions, - disableFileHeader: true, - theme: codeTheme, - }), - [codeTheme, diffViewOptions], - ); + // pierre renders an empty diff when it gets an expansion budget for a + // hunk-only patch, so only a card that can fetch full contents sends one. + // The timeline never can; the diff panel can, through its fetcher. + const expansionLineCount = + contextExpansion.status === "unavailable" + ? undefined + : DIFF_EXPANSION_LINE_COUNT; return (
) : ( <> - } onSelectionAddToChat={onSelectionAddToChat} /> { + let unifiedLineIndex = hunk.unifiedLineStart; + let splitLineIndex = hunk.splitLineStart; + let deletionLineIndex = hunk.deletionLineIndex; + let additionLineIndex = hunk.additionLineIndex; + + for (const content of hunk.hunkContent) { + if (content.type === "context") { + for (let offset = 0; offset < content.lines; offset += 1) { + const oldLineIndex = deletionLineIndex + offset; + const newLineIndex = additionLineIndex + offset; + const lineText = + fileDiff.additionLines[newLineIndex] ?? + fileDiff.deletionLines[oldLineIndex]; + if (lineText === undefined) { + continue; + } + patchLines.push({ + hunkIndex, + newLineNumber: getDiffLineNumberFromIndex({ + hunkLineIndex: hunk.additionLineIndex, + hunkStart: hunk.additionStart, + lineIndex: newLineIndex, + }), + oldLineNumber: getDiffLineNumberFromIndex({ + hunkLineIndex: hunk.deletionLineIndex, + hunkStart: hunk.deletionStart, + lineIndex: oldLineIndex, + }), + prefix: " ", + selectionSide: null, + splitLineIndex: splitLineIndex + offset, + text: trimDiffLineEnding(lineText), + unifiedLineIndex: unifiedLineIndex + offset, + }); + } + unifiedLineIndex += content.lines; + splitLineIndex += content.lines; + deletionLineIndex += content.lines; + additionLineIndex += content.lines; + continue; + } + + const splitCount = Math.max(content.deletions, content.additions); + const unifiedCount = content.deletions + content.additions; + for (let offset = 0; offset < content.deletions; offset += 1) { + const oldLineIndex = deletionLineIndex + offset; + const lineText = fileDiff.deletionLines[oldLineIndex]; + if (lineText === undefined) { + continue; + } + patchLines.push({ + hunkIndex, + newLineNumber: null, + oldLineNumber: getDiffLineNumberFromIndex({ + hunkLineIndex: hunk.deletionLineIndex, + hunkStart: hunk.deletionStart, + lineIndex: oldLineIndex, + }), + prefix: "-", + selectionSide: "deletions", + splitLineIndex: splitLineIndex + offset, + text: trimDiffLineEnding(lineText), + unifiedLineIndex: unifiedLineIndex + offset, + }); + } + for (let offset = 0; offset < content.additions; offset += 1) { + const newLineIndex = additionLineIndex + offset; + const lineText = fileDiff.additionLines[newLineIndex]; + if (lineText === undefined) { + continue; + } + patchLines.push({ + hunkIndex, + newLineNumber: getDiffLineNumberFromIndex({ + hunkLineIndex: hunk.additionLineIndex, + hunkStart: hunk.additionStart, + lineIndex: newLineIndex, + }), + oldLineNumber: null, + prefix: "+", + selectionSide: "additions", + splitLineIndex: splitLineIndex + offset, + text: trimDiffLineEnding(lineText), + unifiedLineIndex: unifiedLineIndex + content.deletions + offset, + }); + } + unifiedLineIndex += unifiedCount; + splitLineIndex += splitCount; + deletionLineIndex += content.deletions; + additionLineIndex += content.additions; + } + }); + + return patchLines; +} + +function getDiffPatchLineSelectionIndex( + line: DiffPatchLine, + displayStyle: DiffPatchDisplayStyle, +) { + return displayStyle === "split" ? line.splitLineIndex : line.unifiedLineIndex; +} + +function getDiffPatchSelectionPointIndex({ + displayStyle, + lineNumber, + patchLines, + side, +}: { + displayStyle: DiffPatchDisplayStyle; + lineNumber: number; + patchLines: DiffPatchLine[]; + side: SelectionSide | undefined; +}) { + const sides: SelectionSide[] = + side === undefined ? ["additions", "deletions"] : [side]; + for (const candidateSide of sides) { + const line = patchLines.find((patchLine) => + candidateSide === "additions" + ? patchLine.newLineNumber === lineNumber + : patchLine.oldLineNumber === lineNumber, + ); + if (line !== undefined) { + return getDiffPatchLineSelectionIndex(line, displayStyle); + } + } + return null; +} + +function isDiffPatchLineSelectedInSplitView({ + line, + range, +}: { + line: DiffPatchLine; + range: SelectedLineRange; +}) { + const startSide = range.side ?? range.endSide ?? "additions"; + const endSide = range.endSide ?? startSide; + if (startSide !== endSide || line.selectionSide === null) { + return true; + } + return line.selectionSide === startSide; +} + +function getSelectedDiffPatchLines({ + displayStyle, + patchLines, + range, +}: { + displayStyle: DiffPatchDisplayStyle; + patchLines: DiffPatchLine[]; + range: SelectedLineRange; +}) { + const startIndex = getDiffPatchSelectionPointIndex({ + displayStyle, + lineNumber: range.start, + patchLines, + side: range.side, + }); + const endIndex = getDiffPatchSelectionPointIndex({ + displayStyle, + lineNumber: range.end, + patchLines, + side: range.endSide ?? range.side, + }); + if (startIndex === null || endIndex === null) { + return []; + } + + const firstIndex = Math.min(startIndex, endIndex); + const lastIndex = Math.max(startIndex, endIndex); + return patchLines.filter((line) => { + const lineIndex = getDiffPatchLineSelectionIndex(line, displayStyle); + if (lineIndex < firstIndex || lineIndex > lastIndex) { + return false; + } + if (displayStyle === "unified") { + return true; + } + return isDiffPatchLineSelectedInSplitView({ line, range }); + }); +} + +function formatUnifiedDiffRange(start: number, count: number) { + return count === 1 ? String(start) : `${start},${count}`; +} + +function getMinimumLineNumber(lineNumbers: number[]) { + return lineNumbers.length > 0 ? Math.min(...lineNumbers) : null; +} + +function buildDiffPatchHunkHeader(lines: DiffPatchLine[]) { + const oldLineNumbers = lines + .map((line) => line.oldLineNumber) + .filter((lineNumber) => lineNumber !== null); + const newLineNumbers = lines + .map((line) => line.newLineNumber) + .filter((lineNumber) => lineNumber !== null); + const oldStart = + getMinimumLineNumber(oldLineNumbers) ?? + Math.max(0, (getMinimumLineNumber(newLineNumbers) ?? 1) - 1); + const newStart = + getMinimumLineNumber(newLineNumbers) ?? + Math.max(0, (getMinimumLineNumber(oldLineNumbers) ?? 1) - 1); + return `@@ -${formatUnifiedDiffRange( + oldStart, + oldLineNumbers.length, + )} +${formatUnifiedDiffRange(newStart, newLineNumbers.length)} @@`; +} + +function groupDiffPatchLinesByHunk(lines: DiffPatchLine[]) { + const groups: DiffPatchLine[][] = []; + for (const line of lines) { + const previousGroup = groups.at(-1); + if (previousGroup?.at(-1)?.hunkIndex === line.hunkIndex) { + previousGroup.push(line); + } else { + groups.push([line]); + } + } + return groups; +} + +export function buildUnifiedDiffPatchText({ + fileDiff, + lines, +}: { + fileDiff: ParsedGitDiffFile; + lines: DiffPatchLine[]; +}) { + const paths = getDiffPatchPaths(fileDiff); + const patchTextLines = [ + `diff --git ${paths.diffGitOldPath} ${paths.diffGitNewPath}`, + `--- ${paths.oldHeaderPath}`, + `+++ ${paths.newHeaderPath}`, + ]; + for (const group of groupDiffPatchLinesByHunk(lines)) { + patchTextLines.push( + buildDiffPatchHunkHeader(group), + ...group.map((line) => `${line.prefix}${line.text}`), + ); + } + return patchTextLines.join("\n"); +} + +/** + * Reconstruct a complete single-file unified patch from a parsed diff. The + * host diff boundary uses it to hand a plugin replacement real patch text when + * the caller no longer has the bytes the file was parsed from. + */ +export function buildFileDiffPatchText(fileDiff: ParsedGitDiffFile): string { + return buildUnifiedDiffPatchText({ + fileDiff, + lines: collectDiffPatchLines(fileDiff), + }); +} + +export function buildDiffLineSelectionText({ + displayStyle, + fileDiff, + range, +}: { + displayStyle: DiffPatchDisplayStyle; + fileDiff: ParsedGitDiffFile; + range: SelectedLineRange; +}): string | null { + const patchLines = collectDiffPatchLines(fileDiff); + const selectedLines = getSelectedDiffPatchLines({ + displayStyle, + patchLines, + range, + }); + if (selectedLines.length === 0) { + return null; + } + return buildUnifiedDiffPatchText({ fileDiff, lines: selectedLines }); +} + +function getDiffShadowRoots(containerElement: HTMLElement | null) { + if (containerElement === null) { + return []; + } + return Array.from(containerElement.querySelectorAll("diffs-container")) + .map((container) => container.shadowRoot) + .filter((root) => root !== null); +} + +function getDiffDomLineSide(lineElement: HTMLElement): SelectionSide { + const codeElement = lineElement.closest("[data-deletions],[data-additions]"); + if (codeElement?.hasAttribute("data-deletions")) { + return "deletions"; + } + if (codeElement?.hasAttribute("data-additions")) { + return "additions"; + } + return lineElement.dataset.lineType === "change-deletion" + ? "deletions" + : "additions"; +} + +function getDiffDomLineNumber(lineElement: HTMLElement): number | null { + const lineNumber = Number.parseInt(lineElement.dataset.line ?? "", 10); + return Number.isFinite(lineNumber) ? lineNumber : null; +} + +function getDiffDomLineText(lineElement: HTMLElement): string { + return (lineElement.textContent ?? "").trimEnd(); +} + +function getDiffDomLineIndex(lineElement: HTMLElement) { + const [unifiedLineIndex, splitLineIndex] = ( + lineElement.dataset.lineIndex ?? "" + ) + .split(",") + .map((value) => Number.parseInt(value, 10)); + if ( + unifiedLineIndex === undefined || + splitLineIndex === undefined || + !Number.isFinite(unifiedLineIndex) || + !Number.isFinite(splitLineIndex) + ) { + return null; + } + return { splitLineIndex, unifiedLineIndex }; +} + +function getDiffDomPatchPrefix(lineElement: HTMLElement): DiffPatchLinePrefix { + switch (lineElement.dataset.lineType) { + case "change-deletion": + return "-"; + case "change-addition": + return "+"; + default: + return " "; + } +} + +function getDiffDomPatchLine({ + hunkIndex, + lineElement, +}: { + hunkIndex: number; + lineElement: HTMLElement; +}): DiffPatchLine | null { + const lineIndex = getDiffDomLineIndex(lineElement); + if (lineIndex === null) { + return null; + } + const lineNumber = getDiffDomLineNumber(lineElement); + const prefix = getDiffDomPatchPrefix(lineElement); + const side = getDiffDomLineSide(lineElement); + return { + hunkIndex, + newLineNumber: + lineNumber !== null && + (prefix === "+" || (prefix === " " && side === "additions")) + ? lineNumber + : null, + oldLineNumber: + lineNumber !== null && + (prefix === "-" || (prefix === " " && side === "deletions")) + ? lineNumber + : null, + prefix, + selectionSide: prefix === " " ? null : side, + splitLineIndex: lineIndex.splitLineIndex, + text: getDiffDomLineText(lineElement), + unifiedLineIndex: lineIndex.unifiedLineIndex, + }; +} + +function mergeDiffDomContextLine( + existingLine: DiffPatchLine, + nextLine: DiffPatchLine, +) { + return { + ...existingLine, + newLineNumber: existingLine.newLineNumber ?? nextLine.newLineNumber, + oldLineNumber: existingLine.oldLineNumber ?? nextLine.oldLineNumber, + }; +} + +export function buildDiffDomSelectionText({ + containerElement, + fileDiff, +}: { + containerElement: HTMLElement | null; + fileDiff: ParsedGitDiffFile; +}): string | null { + if (containerElement === null) { + return null; + } + + const selectedRows: HTMLElement[] = []; + const seenRows = new Set(); + for (const root of getDiffShadowRoots(containerElement)) { + for (const row of root.querySelectorAll( + "[data-selected-line][data-line]", + )) { + const text = getDiffDomLineText(row); + const lineIndex = row.dataset.lineIndex ?? ""; + const side = getDiffDomLineSide(row); + const key = `${lineIndex}:${side}:${text}`; + if (seenRows.has(key)) { + continue; + } + seenRows.add(key); + selectedRows.push(row); + } + } + + if (selectedRows.length === 0) { + return null; + } + + const patchLineMap = new Map(); + for (const row of selectedRows) { + const patchLine = getDiffDomPatchLine({ hunkIndex: 0, lineElement: row }); + if (patchLine === null) { + continue; + } + const key = [ + patchLine.unifiedLineIndex, + patchLine.splitLineIndex, + patchLine.prefix, + patchLine.text, + ].join(":"); + const existingLine = patchLineMap.get(key); + patchLineMap.set( + key, + existingLine !== undefined && patchLine.prefix === " " + ? mergeDiffDomContextLine(existingLine, patchLine) + : (existingLine ?? patchLine), + ); + } + const patchLines = Array.from(patchLineMap.values()).sort((lineA, lineB) => { + if (lineA.unifiedLineIndex !== lineB.unifiedLineIndex) { + return lineA.unifiedLineIndex - lineB.unifiedLineIndex; + } + return lineA.prefix.localeCompare(lineB.prefix); + }); + return patchLines.length > 0 + ? buildUnifiedDiffPatchText({ fileDiff, lines: patchLines }) + : null; +} diff --git a/apps/app/src/components/plugin/PluginDiff.tsx b/apps/app/src/components/plugin/PluginDiff.tsx new file mode 100644 index 0000000000..b56c8a48ed --- /dev/null +++ b/apps/app/src/components/plugin/PluginDiff.tsx @@ -0,0 +1,48 @@ +import { useMemo } from "react"; +import type { DiffProps } from "@get-bb/plugin-sdk"; +import { DiffHost } from "@/components/code/DiffHost"; +import { normalizeFilePatch } from "@/components/git-diff/git-diff-parsing"; +import { cn } from "@bb/shared-ui/lib/utils"; + +/** + * The public `experimental_Diff` component. It normalizes whatever patch shape + * the caller has (a `git diff` patch, a GitHub REST patch, a single `@@` hunk) + * into one the renderer understands, then hands it to the host boundary. + * Content that does not parse as a patch degrades to plain monospace text + * rather than to an empty diff. + */ +export function PluginDiff({ + patch, + path, + view, + overflow, + showLineNumbers, + className, +}: DiffProps) { + const normalized = useMemo( + () => normalizeFilePatch({ patch, path }), + [patch, path], + ); + if (normalized === null) { + return ( +
+        {patch}
+      
+ ); + } + return ( + + ); +} diff --git a/apps/app/src/components/plugin/PluginSourceCode.tsx b/apps/app/src/components/plugin/PluginSourceCode.tsx new file mode 100644 index 0000000000..a97eac6e7b --- /dev/null +++ b/apps/app/src/components/plugin/PluginSourceCode.tsx @@ -0,0 +1,26 @@ +import type { SourceCodeProps } from "@get-bb/plugin-sdk"; +import { SourceCodeHost } from "@/components/code/SourceCodeHost"; + +/** + * The public `experimental_SourceCode` component. It is the host boundary with + * the host-only inputs withheld: a plugin supplies text and presentation, and + * BB owns highlighting, gutters, the live code theme, and any active + * `experimental_sourceCodeRenderer` replacement. + */ +export function PluginSourceCode({ + content, + path, + overflow, + highlightedLines, + className, +}: SourceCodeProps) { + return ( + + ); +} diff --git a/apps/app/src/components/secondary-panel/FilePreview.stories.tsx b/apps/app/src/components/secondary-panel/FilePreview.stories.tsx index a10b17b505..2044fb1c30 100644 --- a/apps/app/src/components/secondary-panel/FilePreview.stories.tsx +++ b/apps/app/src/components/secondary-panel/FilePreview.stories.tsx @@ -234,7 +234,6 @@ export function Overview() { file: { name: "Button.tsx", contents: SAMPLE_BUTTON_TSX, - lang: "tsx", }, }} /> @@ -257,7 +256,6 @@ export function Overview() { cacheKey: "story:large.ts", name: "large.ts", contents: SAMPLE_LARGE_TS, - lang: "ts", }, }} /> @@ -283,7 +281,6 @@ export function Overview() { cacheKey: "story:large.ts", name: "large.ts", contents: SAMPLE_LARGE_TS, - lang: "ts", }, }} /> @@ -306,7 +303,6 @@ export function Overview() { cacheKey: "story:skill-script", name: "lint.ts", contents: SAMPLE_LARGE_TS.split("\n").slice(0, 400).join("\n"), - lang: "ts", }, }} /> @@ -330,7 +326,6 @@ export function Overview() { file: { name: "legacy-button.tsx", contents: SAMPLE_BUTTON_TSX, - lang: "tsx", }, }} /> diff --git a/apps/app/src/components/secondary-panel/FilePreview.test.tsx b/apps/app/src/components/secondary-panel/FilePreview.test.tsx index 1e3dc961a2..0540b69176 100644 --- a/apps/app/src/components/secondary-panel/FilePreview.test.tsx +++ b/apps/app/src/components/secondary-panel/FilePreview.test.tsx @@ -10,11 +10,11 @@ import { import { act, type ReactElement } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { - FILE_PREVIEW_CODE_MAX_LINES, FilePreview, buildCsvPreviewData, getCsvTruncationNote, } from "./FilePreview"; +import { SOURCE_CODE_MAX_LINES } from "@/components/code/source-code-budget"; import { SecondaryPanelFilePreview } from "./ThreadStorageFilePreview"; import { PierreWorkerPoolGateContext, @@ -354,7 +354,7 @@ describe("FilePreview", () => { // The code view scrolls its own virtualized viewport (which sits at the // origin in jsdom), not the surrounding panel scroller. const codeViewport = scrollViewport.querySelector( - "[data-file-preview-code-viewport]", + "[data-bb-source-code-viewport]", ); expect(codeViewport).not.toBeNull(); await waitFor(() => { @@ -368,7 +368,7 @@ describe("FilePreview", () => { expect( pierreFile.shadowRoot ?.querySelector('[data-line="2"]') - ?.hasAttribute("data-file-preview-target-line"), + ?.hasAttribute("data-bb-source-code-target-line"), ).toBe(true); }); @@ -417,7 +417,7 @@ describe("FilePreview", () => { }); it("caps oversized code previews to a leading prefix until the full file is requested", async () => { - const totalLineCount = FILE_PREVIEW_CODE_MAX_LINES + 1_500; + const totalLineCount = SOURCE_CODE_MAX_LINES + 1_500; const contents = Array.from( { length: totalLineCount }, (_, index) => `line ${index + 1}`, @@ -442,7 +442,7 @@ describe("FilePreview", () => { await screen.findByTestId("pierre-file"); expect(pierreMock.state.lastFile?.contents.split("\n")).toHaveLength( - FILE_PREVIEW_CODE_MAX_LINES, + SOURCE_CODE_MAX_LINES, ); // The capped prefix must not share the full file's highlight cache slot. expect(pierreMock.state.lastFile?.cacheKey).toBe( @@ -450,7 +450,7 @@ describe("FilePreview", () => { ); expect( screen.getByText( - `Showing the first ${FILE_PREVIEW_CODE_MAX_LINES.toLocaleString()} of ${totalLineCount.toLocaleString()} lines.`, + `Showing the first ${SOURCE_CODE_MAX_LINES.toLocaleString()} of ${totalLineCount.toLocaleString()} lines.`, ), ).toBeTruthy(); @@ -488,7 +488,7 @@ describe("FilePreview", () => { }); it("shows the whole file when a line link points past the capped prefix", async () => { - const totalLineCount = FILE_PREVIEW_CODE_MAX_LINES + 20; + const totalLineCount = SOURCE_CODE_MAX_LINES + 20; const contents = Array.from( { length: totalLineCount }, (_, index) => `line ${index + 1}`, @@ -502,8 +502,8 @@ describe("FilePreview", () => { kind: "ready", file: { name: "generated.ts", contents }, lineRange: { - startLineNumber: FILE_PREVIEW_CODE_MAX_LINES + 10, - endLineNumber: FILE_PREVIEW_CODE_MAX_LINES + 10, + startLineNumber: SOURCE_CODE_MAX_LINES + 10, + endLineNumber: SOURCE_CODE_MAX_LINES + 10, }, textPreviewKind: null, }} diff --git a/apps/app/src/components/secondary-panel/FilePreview.tsx b/apps/app/src/components/secondary-panel/FilePreview.tsx index aa11f18503..179b1b4d69 100644 --- a/apps/app/src/components/secondary-panel/FilePreview.tsx +++ b/apps/app/src/components/secondary-panel/FilePreview.tsx @@ -1,25 +1,7 @@ -import { - type CSSProperties, - type ReactNode, - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from "react"; -import { File as PierreFile, VirtualizerContext } from "@pierre/diffs/react"; -import type { FileOptions } from "@pierre/diffs/react"; -import { - DIFFS_TAG_NAME, - Virtualizer as PierreVirtualizer, - type SelectedLineRange, - type SupportedLanguages, - type VirtualFileMetrics, -} from "@pierre/diffs"; +import { type CSSProperties, useEffect, useMemo, useState } from "react"; import type { UrlTransform } from "react-markdown"; import { Button } from "@bb/shared-ui/button"; -import { usePierreLineSelectionActions } from "@/components/git-diff/PierreLineSelectionActions.js"; +import { SourceCodeHost } from "@/components/code/SourceCodeHost"; import { COARSE_POINTER_TEXT_SM_CLASS } from "@bb/shared-ui/coarse-pointer-sizing"; import { EmptyStatePanel } from "@bb/shared-ui/empty-state"; import { CopyButton } from "@/components/ui/copy-button.js"; @@ -37,14 +19,7 @@ import { TooltipTrigger, } from "@bb/shared-ui/tooltip"; import { TruncateStart } from "@/components/ui/truncate-start.js"; -import { usePreferredTheme } from "@/hooks/useTheme"; -import { useResolvedCodeThemePair } from "@/lib/code-theme"; import { copyToClipboardWithToast } from "@/lib/clipboard"; -import { PierreWorkerPoolBoundary } from "@/lib/pierre-worker-pool-boundary"; -import { - usePierreWorkerPool, - useRequirePierreWorkerPool, -} from "@/lib/pierre-worker-pool-gate"; import type { FilePreviewLineRange, WorkspaceFilePreviewStatusLabel, @@ -61,7 +36,6 @@ export interface FilePreviewFile { cacheKey?: string; name: string; contents: string; - lang?: SupportedLanguages; } export type IframePreviewSandbox = "allow-scripts"; @@ -185,18 +159,6 @@ interface FilePreviewCodeProps { path: string; } -interface FilePreviewWorkerPoolStats { - managerState: "waiting" | "initializing" | "initialized"; - workersFailed: boolean; - totalWorkers: number; - busyWorkers: number; - queuedTasks: number; - activeTasks: number; - themeSubscribers: number; - fileCacheSize: number; - diffCacheSize: number; -} - interface GetInitialFilePreviewViewModeArgs { lineRange: FilePreviewLineRange | null; toggleKind: FilePreviewToggleKind | null; @@ -224,101 +186,6 @@ const CSV_PREVIEW_MAX_ROWS = 500; * file is what stalls iOS Safari; the prefix keeps the first paint bounded and * the full file stays one tap away. */ -export const FILE_PREVIEW_CODE_MAX_LINES = 5_000; -export const FILE_PREVIEW_CODE_MAX_CHARS = 512 * 1024; - -const FILE_PREVIEW_CODE_LINE_HEIGHT_PX = 18; -const FILE_PREVIEW_CODE_GAP_BLOCK_PX = 16; - -const FILE_PREVIEW_VIEW_STYLE = { - "--diffs-font-size": "12px", - "--diffs-line-height": `${FILE_PREVIEW_CODE_LINE_HEIGHT_PX}px`, - // Pierre paints its theme bg inside this gap, so the top breathing room of - // the code body lives on Pierre's bg — not on the panel's bg-background. - // Without this, the gap above Pierre would show a visible bg-color seam. - "--diffs-gap-block": `${FILE_PREVIEW_CODE_GAP_BLOCK_PX}px`, -} as CSSProperties; - -// Pierre's virtualizer estimates row positions from these before it measures -// them; they mirror the CSS variables above so the first layout guess is exact -// in `scroll` overflow mode (fixed-height rows) and close in `wrap` mode. -const FILE_PREVIEW_VIRTUAL_FILE_METRICS: VirtualFileMetrics = { - hunkLineCount: 50, - lineHeight: FILE_PREVIEW_CODE_LINE_HEIGHT_PX, - diffHeaderHeight: 0, - spacing: FILE_PREVIEW_CODE_GAP_BLOCK_PX, -}; - -export interface FilePreviewCodeTruncation { - /** The rendered prefix, cut at a line boundary. */ - contents: string; - renderedLineCount: number; - totalLineCount: number; -} - -// FNV-1a over the contents; only used to derive a mount key for previews -// whose caller did not supply a `cacheKey`. -function hashFilePreviewContents(contents: string): string { - let hash = 0x811c9dc5; - for (let index = 0; index < contents.length; index += 1) { - hash ^= contents.charCodeAt(index); - hash = Math.imul(hash, 0x01000193); - } - return `${contents.length}:${(hash >>> 0).toString(36)}`; -} - -function countLines(contents: string): number { - if (contents.length === 0) return 0; - let count = 1; - for (let index = contents.indexOf("\n"); index !== -1; ) { - count += 1; - index = contents.indexOf("\n", index + 1); - } - return contents.endsWith("\n") ? count - 1 : count; -} - -/** - * Decide whether a code preview exceeds {@link FILE_PREVIEW_CODE_MAX_LINES} or - * {@link FILE_PREVIEW_CODE_MAX_CHARS} and, if so, return the leading prefix - * that fits both budgets. Returns `null` when the whole file fits. - */ -export function truncateFilePreviewCode( - contents: string, -): FilePreviewCodeTruncation | null { - const totalLineCount = countLines(contents); - if ( - contents.length <= FILE_PREVIEW_CODE_MAX_CHARS && - totalLineCount <= FILE_PREVIEW_CODE_MAX_LINES - ) { - return null; - } - let renderedLineCount = 0; - let cutIndex = 0; - for ( - let lineStart = 0; - lineStart < contents.length && - renderedLineCount < FILE_PREVIEW_CODE_MAX_LINES; - ) { - const newlineIndex = contents.indexOf("\n", lineStart); - const lineEnd = newlineIndex === -1 ? contents.length : newlineIndex; - if (lineEnd > FILE_PREVIEW_CODE_MAX_CHARS && renderedLineCount > 0) { - break; - } - renderedLineCount += 1; - cutIndex = lineEnd; - lineStart = lineEnd + 1; - } - return { - contents: contents.slice(0, cutIndex), - renderedLineCount, - totalLineCount, - }; -} - -// `--md-content-w` tells MarkdownPreview the surrounding text-column width so -// narrow tables sit flush with the prose on the left instead of centering in -// the panel. `100cqi` resolves against the `@container/page` scope on the -// wrapper below — i.e. the panel width. const FILE_PREVIEW_WRAPPER_STYLE = { "--md-content-w": "100cqi", } as CSSProperties; @@ -1203,216 +1070,6 @@ function IframeFilePreview({ sandbox, title, url }: IframeFilePreviewTarget) { ); } -function getPreviewTargetRoots(container: HTMLElement): ParentNode[] { - const roots: ParentNode[] = [container]; - // Pierre owns its rendered line elements inside an open shadow root, which - // normal descendant queries on the React wrapper cannot cross. - for (const pierreContainer of container.querySelectorAll( - DIFFS_TAG_NAME, - )) { - if (pierreContainer.shadowRoot !== null) { - roots.push(pierreContainer.shadowRoot); - } - } - return roots; -} - -function clearPreviewTargetLine(container: HTMLElement) { - for (const root of getPreviewTargetRoots(container)) { - const targetLines = root.querySelectorAll( - "[data-file-preview-target-line]", - ); - for (const targetLine of targetLines) { - targetLine.removeAttribute("data-file-preview-target-line"); - targetLine.removeAttribute("data-selected-line"); - } - } -} - -function findPreviewTargetLine( - container: HTMLElement, - lineNumber: number, -): HTMLElement | null { - const roots = getPreviewTargetRoots(container); - for (const root of roots) { - const lines = root.querySelectorAll(`[data-line="${lineNumber}"]`); - for (const line of lines) { - if (line instanceof HTMLElement && line.dataset.lineIndex !== undefined) { - return line; - } - } - } - for (const root of roots) { - const lines = root.querySelectorAll(`[data-line="${lineNumber}"]`); - for (const line of lines) { - if (line instanceof HTMLElement) { - return line; - } - } - } - return null; -} - -function findVirtualizedCodeViewport( - container: HTMLElement, -): HTMLElement | null { - return container.querySelector( - "[data-file-preview-code-viewport]", - ); -} - -/** - * Nudge the virtualized code viewport toward `lineNumber` when that row is not - * realized yet. With rendered rows in hand the distance is measured from the - * nearest one (rows are at least one line tall, so the step never overshoots - * in `wrap` mode); with none rendered the offset is estimated from the fixed - * line metrics. Each call moves at most to the estimate; the caller retries on - * the next frame once pierre has rendered the new window. - */ -function approachVirtualizedTargetLine( - container: HTMLElement, - lineNumber: number, -) { - const viewport = findVirtualizedCodeViewport(container); - if (viewport === null) return; - const viewportRect = viewport.getBoundingClientRect(); - const centerOffset = viewportRect.height / 2; - const renderedBounds = getRenderedPreviewLineBounds(container); - if (renderedBounds === null) { - const estimatedTop = - FILE_PREVIEW_CODE_GAP_BLOCK_PX + - (lineNumber - 1) * FILE_PREVIEW_CODE_LINE_HEIGHT_PX; - viewport.scrollTop = Math.max(0, estimatedTop - centerOffset); - return; - } - const { firstLineNumber, firstTop, lastLineNumber, lastBottom } = - renderedBounds; - if (lineNumber > lastLineNumber) { - const distance = - lastBottom - - viewportRect.top + - (lineNumber - lastLineNumber - 1) * FILE_PREVIEW_CODE_LINE_HEIGHT_PX; - viewport.scrollTop += Math.max(0, distance - centerOffset); - } else if (lineNumber < firstLineNumber) { - const distance = - viewportRect.top - - firstTop + - (firstLineNumber - lineNumber) * FILE_PREVIEW_CODE_LINE_HEIGHT_PX; - viewport.scrollTop = Math.max( - 0, - viewport.scrollTop - distance - centerOffset, - ); - } -} - -interface RenderedPreviewLineBounds { - firstLineNumber: number; - firstTop: number; - lastLineNumber: number; - lastBottom: number; -} - -function getRenderedPreviewLineBounds( - container: HTMLElement, -): RenderedPreviewLineBounds | null { - let bounds: RenderedPreviewLineBounds | null = null; - for (const root of getPreviewTargetRoots(container)) { - for (const line of root.querySelectorAll( - "[data-line][data-line-index]", - )) { - const lineNumber = Number(line.dataset.line); - if (!Number.isFinite(lineNumber)) continue; - const rect = line.getBoundingClientRect(); - if (bounds === null) { - bounds = { - firstLineNumber: lineNumber, - firstTop: rect.top, - lastLineNumber: lineNumber, - lastBottom: rect.bottom, - }; - continue; - } - if (lineNumber < bounds.firstLineNumber) { - bounds.firstLineNumber = lineNumber; - bounds.firstTop = rect.top; - } - if (lineNumber > bounds.lastLineNumber) { - bounds.lastLineNumber = lineNumber; - bounds.lastBottom = rect.bottom; - } - } - } - return bounds; -} - -function findPreviewScrollViewport(container: HTMLElement): HTMLElement | null { - const virtualizedViewport = findVirtualizedCodeViewport(container); - if (virtualizedViewport !== null) { - return virtualizedViewport; - } - const view = container.ownerDocument.defaultView; - if (view === null) return null; - - let candidate = container.parentElement; - while (candidate !== null) { - const overflowY = view.getComputedStyle(candidate).overflowY; - if ( - overflowY === "auto" || - overflowY === "scroll" || - overflowY === "overlay" - ) { - return candidate; - } - candidate = candidate.parentElement; - } - return null; -} - -function scrollPreviewTargetLine(container: HTMLElement, line: HTMLElement) { - const viewport = findPreviewScrollViewport(container); - if (viewport === null) return; - - const lineRect = line.getBoundingClientRect(); - const viewportRect = viewport.getBoundingClientRect(); - const lineCenter = lineRect.top + lineRect.height / 2; - const viewportCenter = viewportRect.top + viewportRect.height / 2; - // Adjust only the vertical scroll offset. `scrollIntoView()` can also move - // the horizontal axis when a long source line extends beyond the viewport. - viewport.scrollTop += lineCenter - viewportCenter; -} - -function formatLineRange(startLineNumber: number, endLineNumber: number) { - return startLineNumber === endLineNumber - ? String(startLineNumber) - : `${startLineNumber}-${endLineNumber}`; -} - -function buildFilePreviewLineSelectionText({ - contents, - path, - range, -}: { - contents: string; - path: string; - range: SelectedLineRange; -}): string | null { - const startLineNumber = Math.max(1, Math.min(range.start, range.end)); - const endLineNumber = Math.max( - startLineNumber, - Math.max(range.start, range.end), - ); - const lines = contents.split(/\r\n|\n|\r/); - const selectedLines = lines.slice(startLineNumber - 1, endLineNumber); - if (selectedLines.length === 0) { - return null; - } - const selectedText = selectedLines.join("\n").trimEnd(); - if (selectedText.trim().length === 0) { - return null; - } - return `${path}:${formatLineRange(startLineNumber, endLineNumber)}\n${selectedText}`; -} - function FilePreviewLoading() { return (
@@ -1434,6 +1091,12 @@ function FilePreviewMessage({ message, role }: FilePreviewMessageProps) { ); } +/** + * The preview's source body. Everything here is chrome and policy — which + * lines to highlight, whether to scroll to them, the selection-to-chat hook — + * while the render itself goes through the shared host boundary, so an + * `experimental_sourceCodeRenderer` replacement covers the file preview too. + */ function FilePreviewCode({ file, lineOverflowMode, @@ -1441,336 +1104,27 @@ function FilePreviewCode({ onSelectionAddToChat, path, }: FilePreviewCodeProps) { - const preferredTheme = usePreferredTheme(); - const codeTheme = useResolvedCodeThemePair(); - const containerRef = useRef(null); - // `PierreFile` captures the worker pool when it creates its instance, so - // wait for the workspace to build the pool before the first render. - const isWorkerPoolReady = useRequirePierreWorkerPool(); - const workerPool = usePierreWorkerPool(); - const lastWorkerPoolStatsKeyRef = useRef(null); - const [workerPoolStats, setWorkerPoolStats] = - useState(null); - const [, rerenderAfterWorkerPoolChange] = useState(0); - const fileIdentity = file.cacheKey ?? file.name; - const truncation = useMemo( - () => truncateFilePreviewCode(file.contents), - [file.contents], - ); - // Which file the user asked to see in full. Keyed by identity rather than a - // boolean so opening a different large file goes back to the capped view - // without an effect resetting state. - const [fullFileRequestedFor, setFullFileRequestedFor] = useState< - string | null - >(null); - const buildSelectionText = useCallback( - (range: SelectedLineRange) => - buildFilePreviewLineSelectionText({ - contents: file.contents, - path, - range, - }), - [file.contents, path], - ); - const lineSelectionActions = usePierreLineSelectionActions({ - buildSelectionText, - containerRef, - enabled: onSelectionAddToChat !== undefined, - onSelectionAddToChat, - }); - const options = useMemo>( - () => ({ - themeType: preferredTheme, - theme: codeTheme, - overflow: lineOverflowMode, - disableFileHeader: true, - enableGutterUtility: onSelectionAddToChat !== undefined, - enableLineSelection: - lineRange !== null || onSelectionAddToChat !== undefined, - lineHoverHighlight: - onSelectionAddToChat === undefined ? "disabled" : "number", - onGutterUtilityClick: - onSelectionAddToChat === undefined - ? undefined - : lineSelectionActions.onGutterUtilityClick, - onLineSelectionChange: lineSelectionActions.onLineSelectionChange, - onLineSelectionEnd: lineSelectionActions.onLineSelectionEnd, - onLineSelectionStart: lineSelectionActions.onLineSelectionStart, - }), - [ - codeTheme, - lineOverflowMode, - lineRange, - lineSelectionActions.onGutterUtilityClick, - lineSelectionActions.onLineSelectionChange, - lineSelectionActions.onLineSelectionEnd, - lineSelectionActions.onLineSelectionStart, - onSelectionAddToChat, - preferredTheme, - ], - ); - const selectedLines = useMemo(() => { - if (lineSelectionActions.selectedRange !== null) { - return lineSelectionActions.selectedRange; - } - return lineRange === null - ? null - : { - start: lineRange.startLineNumber, - end: lineRange.endLineNumber, - }; - }, [lineRange, lineSelectionActions.selectedRange]); - const targetLineNumber = selectedLines?.start ?? null; - // A deep link past the capped prefix is an implicit request for the whole - // file: the target line has to exist in the DOM to be scrolled to. - const showsFullFile = - truncation === null || - fullFileRequestedFor === fileIdentity || - (targetLineNumber !== null && - targetLineNumber > truncation.renderedLineCount); - const renderedFile = useMemo(() => { - if (showsFullFile || truncation === null) { - return file; - } - return { - ...file, - // The worker highlight cache is keyed by `cacheKey`; the capped prefix - // must not collide with the full file's entry. - cacheKey: - file.cacheKey === undefined ? undefined : `${file.cacheKey}:head`, - contents: truncation.contents, - }; - }, [file, showsFullFile, truncation]); - // Pierre's virtualized file instance keeps the contents it was hydrated - // with (`VirtualizedFile.render` ignores a later `file`), so a content swap - // — the capped prefix giving way to the full file, or a refetch — needs a - // fresh mount. Callers that supply a `cacheKey` already fold the content - // hash into it; otherwise hash here. - const renderedFileMountKey = useMemo( + const highlightedLines = useMemo( () => - renderedFile.cacheKey ?? - `${renderedFile.name}:${hashFilePreviewContents(renderedFile.contents)}`, - [renderedFile], - ); - // "Load full file" remounts pierre with the whole file; carry the reader's - // scroll offset across so the prefix they were looking at stays put. - const pendingViewportScrollTopRef = useRef(null); - const handleLoadFullFile = () => { - const viewport = - containerRef.current === null + lineRange === null ? null - : findVirtualizedCodeViewport(containerRef.current); - pendingViewportScrollTopRef.current = viewport?.scrollTop ?? null; - setFullFileRequestedFor(fileIdentity); - }; - useLayoutEffect(() => { - const scrollTop = pendingViewportScrollTopRef.current; - if (scrollTop === null) return; - pendingViewportScrollTopRef.current = null; - const viewport = - containerRef.current === null - ? null - : findVirtualizedCodeViewport(containerRef.current); - if (viewport === null) return; - viewport.scrollTop = scrollTop; - // The virtualizer sizes the fresh instance on its next frame; reapply once - // that height exists so the offset is not clamped away. - const frame = window.requestAnimationFrame(() => { - viewport.scrollTop = scrollTop; - }); - return () => window.cancelAnimationFrame(frame); - }, [renderedFileMountKey]); - - useEffect(() => { - if (!workerPool) { - setWorkerPoolStats(null); - return; - } - - lastWorkerPoolStatsKeyRef.current = null; - return workerPool.subscribeToStatChanges((stats) => { - setWorkerPoolStats(stats); - const statsKey = [ - stats.managerState, - stats.workersFailed, - stats.busyWorkers, - stats.queuedTasks, - stats.activeTasks, - stats.fileCacheSize, - ].join(":"); - if (lastWorkerPoolStatsKeyRef.current === statsKey) { - return; - } - lastWorkerPoolStatsKeyRef.current = statsKey; - rerenderAfterWorkerPoolChange((version) => version + 1); - }); - }, [file.contents, file.name, workerPool]); - - const shouldWaitForWorkerPool = - workerPool !== undefined && - workerPoolStats?.managerState !== "initialized" && - workerPoolStats?.workersFailed !== true; - // Pierre can mount an empty zero-height
 while its worker highlighter is
-  // still initializing, so the code view waits for pool readiness. After that
-  // a single mount is enough: pierre paints the plain-text AST first and
-  // repaints in place when the worker delivers the highlighted one. That
-  // repaint swaps the line elements, so the target-line effect below re-runs
-  // when the highlight cache entry for this file appears.
-  const workerHighlightCacheState =
-    workerPool?.getFileResultCache(renderedFile) !== undefined
-      ? "highlighted"
-      : "plain";
-
-  useEffect(() => {
-    const cleanupContainer = containerRef.current;
-    let animationFrame: number | null = null;
-    let attempts = 0;
-
-    // Retry on the next frame (the target line may not be in the DOM yet). One
-    // rAF channel only: `scrollToLine` overwrites `animationFrame` on each
-    // reschedule, so at most one callback is ever pending and cleanup cancels
-    // it — no doubling or leaked stale callbacks marking the wrong line.
-    function scheduleRetry() {
-      animationFrame = window.requestAnimationFrame(scrollToLine);
-    }
-
-    function scrollToLine() {
-      const container = containerRef.current;
-      if (!container) return;
-      clearPreviewTargetLine(container);
-      if (targetLineNumber === null) return;
-
-      const line = findPreviewTargetLine(container, targetLineNumber);
-      if (line) {
-        line.setAttribute("data-file-preview-target-line", "");
-        line.setAttribute("data-selected-line", "single");
-        scrollPreviewTargetLine(container, line);
-        return;
-      }
-
-      // The virtualizer only realizes rows near the scroll window, so a
-      // target outside it is not in the DOM yet. Move the viewport toward the
-      // line's estimated offset and let pierre render that window before the
-      // next attempt.
-      approachVirtualizedTargetLine(container, targetLineNumber);
-      attempts += 1;
-      if (attempts < FILE_PREVIEW_TARGET_LINE_MAX_ATTEMPTS) {
-        scheduleRetry();
-      }
-    }
-
-    scrollToLine();
-    return () => {
-      if (cleanupContainer) {
-        clearPreviewTargetLine(cleanupContainer);
-      }
-      if (animationFrame !== null) {
-        window.cancelAnimationFrame(animationFrame);
-      }
-    };
-  }, [
-    renderedFile.contents,
-    renderedFile.name,
-    shouldWaitForWorkerPool,
-    targetLineNumber,
-    workerHighlightCacheState,
-  ]);
-
-  if (shouldWaitForWorkerPool || !isWorkerPoolReady) {
-    return ;
-  }
-
-  return (
-    
- - - - {truncation !== null && !showsFullFile ? ( - - ) : null} - - - {lineSelectionActions.menu} -
+ : { start: lineRange.startLineNumber, end: lineRange.endLineNumber }, + [lineRange], ); -} - -const FILE_PREVIEW_TARGET_LINE_MAX_ATTEMPTS = 40; - -/** - * The code view's own scroll container, registered as pierre's virtualizer - * root so `PierreFile` mounts a `VirtualizedFile` that renders only the rows - * near the viewport. This mirrors `@pierre/diffs/react`'s ``, - * inlined so the scroller carries a ref and a data marker the target-line - * scrolling can find without walking the tree by class name. - */ -function FilePreviewCodeViewport({ children }: { children: ReactNode }) { - const [virtualizer] = useState(() => - typeof window === "undefined" ? undefined : new PierreVirtualizer(), - ); - const viewportRef = useCallback( - (node: HTMLDivElement | null) => { - if (node !== null) { - virtualizer?.setup(node); - } else { - virtualizer?.cleanUp(); - } - }, - [virtualizer], - ); - return ( - -
-
{children}
-
-
- ); -} - -function FilePreviewCodeTruncationNotice({ - truncation, - onLoadFullFile, -}: { - truncation: FilePreviewCodeTruncation; - onLoadFullFile: () => void; -}) { return ( -
- - Showing the first {truncation.renderedLineCount.toLocaleString()} of{" "} - {truncation.totalLineCount.toLocaleString()} lines. - - -
+ } + onSelectionAddToChat={onSelectionAddToChat} + /> ); } diff --git a/apps/app/src/components/secondary-panel/GitDiffCard.stories.tsx b/apps/app/src/components/secondary-panel/GitDiffCard.stories.tsx index d40e3038cb..9921e0f55a 100644 --- a/apps/app/src/components/secondary-panel/GitDiffCard.stories.tsx +++ b/apps/app/src/components/secondary-panel/GitDiffCard.stories.tsx @@ -1,7 +1,7 @@ import { useCallback, useMemo, useState, type ReactNode } from "react"; import type { FileContents } from "@pierre/diffs"; +import type { DiffPresentation } from "@/components/code/code-rendering"; import { - GIT_DIFF_VIEW_BASE_OPTIONS, GitDiffCard, type DiffFileContentsResult, type RequestDiffFileContents, @@ -20,7 +20,6 @@ import { summarizeGitDiff, type ParsedGitDiffFile, } from "../git-diff/git-diff-parsing"; -import { usePreferredTheme } from "@/hooks/useTheme"; import { StoryCard, StoryRow } from "../../../.ladle/story-card"; import { appToast } from "@/components/ui/app-toast"; @@ -745,7 +744,6 @@ function InteractiveDiffPanel({ ), [parsed], ); - const preferredTheme = usePreferredTheme(); const [selection, setSelection] = useState("working"); const [displayMode, setDisplayMode] = useState("unified"); const [lineOverflowMode, setLineOverflowMode] = useState( @@ -776,14 +774,13 @@ function InteractiveDiffPanel({ return next; }); }, []); - const viewOptions = useMemo( + const presentation = useMemo( () => ({ - ...GIT_DIFF_VIEW_BASE_OPTIONS, - diffStyle: displayMode, + view: displayMode, overflow: lineOverflowMode, - themeType: preferredTheme, + showLineNumbers: true, }), - [displayMode, lineOverflowMode, preferredTheme], + [displayMode, lineOverflowMode], ); const onOpenFileInEditor = useCallback((path: string) => { appToast.message("Opening in editor", { description: path }); @@ -837,7 +834,7 @@ function InteractiveDiffPanel({ toggleFileCollapsed(fileKey)} diff --git a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx index a8937ed772..686a48a4af 100644 --- a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx +++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx @@ -46,13 +46,12 @@ import type { SecondaryPanelFileTab, SecondaryPanelTabReorderHandler, } from "./secondaryPanelFileTab"; -import { GIT_DIFF_VIEW_BASE_OPTIONS } from "../git-diff/GitDiffCard"; -import { usePreferredTheme } from "@/hooks/useTheme"; import { useEnvironmentDiffFiles } from "@/hooks/queries/environment-queries"; import { DEFAULT_CODE_OVERFLOW_MODE, type CodeOverflowMode, } from "@/lib/code-overflow-mode"; +import type { DiffPresentation } from "@/components/code/code-rendering"; import { useGitDiffPanelState } from "./git-diff/useGitDiffPanelState"; import { useResponsiveGitDiffPanelDisplay } from "./git-diff/useResponsiveGitDiffPanelDisplay"; import { @@ -634,15 +633,13 @@ export function ThreadSecondaryPanel({ windowState: desktopWindowState, }), }); - const preferredTheme = usePreferredTheme(); - const gitDiffViewOptions = useMemo( + const gitDiffPresentation = useMemo( () => ({ - ...GIT_DIFF_VIEW_BASE_OPTIONS, - diffStyle: gitDiffDisplayMode, + view: gitDiffDisplayMode, overflow: gitDiffLineOverflowMode, - themeType: preferredTheme, + showLineNumbers: true, }), - [gitDiffDisplayMode, gitDiffLineOverflowMode, preferredTheme], + [gitDiffDisplayMode, gitDiffLineOverflowMode], ); const handlePanelFocusCapture = (event: FocusEvent) => { const previousTarget = event.relatedTarget; @@ -957,7 +954,7 @@ export function ThreadSecondaryPanel({ target={gitDiffTarget} isDiffPanelActive={isSurfaceDiffActive} isPanelOpen={isLayoutOpen} - gitDiffViewOptions={gitDiffViewOptions} + gitDiffPresentation={gitDiffPresentation} onClearPendingGitDiffIntent={onClearPendingGitDiffIntent} onOpenFileInEditor={onOpenFileInEditor} onOpenFilePreview={onOpenFilePreview} @@ -1496,7 +1493,7 @@ export function ThreadSecondaryPanel({ target={gitDiffTarget} isDiffPanelActive={isDiffPanelActive} isPanelOpen={isLayoutOpen} - gitDiffViewOptions={gitDiffViewOptions} + gitDiffPresentation={gitDiffPresentation} onClearPendingGitDiffIntent={onClearPendingGitDiffIntent} onOpenFileInEditor={onOpenFileInEditor} onOpenFilePreview={onOpenFilePreview} diff --git a/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.panelGate.test.tsx b/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.panelGate.test.tsx index e0d842c0bf..71d58d5875 100644 --- a/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.panelGate.test.tsx +++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.panelGate.test.tsx @@ -68,7 +68,11 @@ describe("GitDiffTabContent panel gating", () => { target={TARGET} isDiffPanelActive isPanelOpen={isPanelOpen} - gitDiffViewOptions={{}} + gitDiffPresentation={{ + view: "unified", + overflow: "scroll", + showLineNumbers: true, + }} /> ); diff --git a/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.tsx b/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.tsx index b38fcaaadc..2d17fba303 100644 --- a/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.tsx +++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.tsx @@ -1,4 +1,5 @@ import { useEffect, type ReactNode } from "react"; +import type { DiffPresentation } from "@/components/code/code-rendering"; import type { WorkspaceDiffTarget } from "@bb/domain"; import type { MarkdownLinkRouting } from "@/components/ui/markdown-link-routing.js"; import { Skeleton } from "@bb/shared-ui/skeleton"; @@ -50,7 +51,7 @@ export interface GitDiffTabContentProps { * and refetching into an off-screen panel is wasted network and diff work. */ isPanelOpen: boolean; - gitDiffViewOptions: Record; + gitDiffPresentation: DiffPresentation; onClearPendingGitDiffIntent?: () => void; onOpenFileInEditor?: (path: string) => void; onOpenFilePreview?: (path: string) => void; @@ -188,7 +189,7 @@ export function GitDiffTabContent({ target, isDiffPanelActive, isPanelOpen, - gitDiffViewOptions, + gitDiffPresentation, onClearPendingGitDiffIntent, onOpenFileInEditor, onOpenFilePreview, @@ -333,7 +334,7 @@ export function GitDiffTabContent({ files={diffFilesResponse.files} initialPatches={diffFilesResponse.initialPatches} filesUpdatedAt={diffFilesUpdatedAt} - diffViewOptions={gitDiffViewOptions} + presentation={gitDiffPresentation} filePathRoot={workspaceRootPath} isPanelOpen={isPanelOpen} isPlaceholderData={isDiffFilesPlaceholder} diff --git a/apps/app/src/components/secondary-panel/git-diff/DiffFileCard.contextExpansion.test.tsx b/apps/app/src/components/secondary-panel/git-diff/DiffFileCard.contextExpansion.test.tsx index 3c34d53a13..88e29d6fac 100644 --- a/apps/app/src/components/secondary-panel/git-diff/DiffFileCard.contextExpansion.test.tsx +++ b/apps/app/src/components/secondary-panel/git-diff/DiffFileCard.contextExpansion.test.tsx @@ -23,6 +23,22 @@ vi.mock("@pierre/diffs/react", () => ({ }, })); +/** + * BB's diff renderer is a lazy chunk behind the host boundary (the same + * pattern `LazyTimelineFileDiffBlock` uses), so the card paints its skeleton + * until that import resolves. Testing Library's 1s default is not enough for + * a module compile while the whole suite runs in parallel. + */ +const DIFF_RENDERER_CHUNK_TIMEOUT_MS = 10_000; + +function findDiffView() { + return screen.findByTestId( + "diff-view", + {}, + { timeout: DIFF_RENDERER_CHUNK_TIMEOUT_MS }, + ); +} + const MODIFIED_PATCH = [ "diff --git a/src/file.ts b/src/file.ts", "index 1111111..2222222 100644", @@ -114,7 +130,11 @@ function renderModifiedCard(onRequestFileContents: RequestDiffFileContents) { render( {}} patchState={{ status: "loaded", patch: MODIFIED_PATCH, truncated: false }} @@ -164,7 +184,7 @@ describe("DiffFileCard context expansion", () => { renderModifiedCard(onRequestFileContents); revealCardBodies(); - await screen.findByTestId("diff-view"); + await findDiffView(); const expandButton = await screen.findByRole("button", { name: "Expand context", }); @@ -263,7 +283,11 @@ describe("DiffFileCard context expansion", () => { additions: 2, deletions: 0, })} - diffViewOptions={{}} + presentation={{ + view: "unified", + overflow: "scroll", + showLineNumbers: true, + }} isCollapsed={false} onToggleCollapsed={() => {}} patchState={{ status: "loaded", patch: ADDED_PATCH, truncated: false }} @@ -274,7 +298,7 @@ describe("DiffFileCard context expansion", () => { ); revealCardBodies(); - await screen.findByTestId("diff-view"); + await findDiffView(); await new Promise((resolve) => setTimeout(resolve, 300)); expect(onRequestFileContents).not.toHaveBeenCalled(); expect( diff --git a/apps/app/src/components/secondary-panel/git-diff/DiffFileCard.stories.tsx b/apps/app/src/components/secondary-panel/git-diff/DiffFileCard.stories.tsx index 9f6f84063c..38e014e556 100644 --- a/apps/app/src/components/secondary-panel/git-diff/DiffFileCard.stories.tsx +++ b/apps/app/src/components/secondary-panel/git-diff/DiffFileCard.stories.tsx @@ -1,9 +1,8 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useState } from "react"; import type { DiffFileEntry } from "@bb/server-contract"; -import { GIT_DIFF_VIEW_BASE_OPTIONS } from "@/components/git-diff/GitDiffCard"; +import type { DiffPresentation } from "@/components/code/code-rendering"; import type { RequestDiffFileContents } from "@/components/git-diff/GitDiffCardBody"; import { DEFAULT_CODE_OVERFLOW_MODE } from "@/lib/code-overflow-mode"; -import { usePreferredTheme } from "@/hooks/useTheme"; import type { DiffPatchState } from "@/hooks/queries/use-environment-diff-patches"; import { appToast } from "@/components/ui/app-toast"; import { StoryCard, StoryRow } from "../../../../.ladle/story-card"; @@ -116,22 +115,18 @@ interface CardStageProps { // Mounts a single DiffFileCard at a panel-realistic width with live theme-aware // view options, mirroring how DiffFilesPanel renders each row. +const CARD_PRESENTATION: DiffPresentation = { + view: "unified", + overflow: DEFAULT_CODE_OVERFLOW_MODE, + showLineNumbers: true, +}; + function CardStage({ entry, patchState = { status: "idle" }, collapsed = false, onRequestFileContents, }: CardStageProps) { - const preferredTheme = usePreferredTheme(); - const diffViewOptions = useMemo( - () => ({ - ...GIT_DIFF_VIEW_BASE_OPTIONS, - diffStyle: "unified", - overflow: DEFAULT_CODE_OVERFLOW_MODE, - themeType: preferredTheme, - }), - [preferredTheme], - ); const [isCollapsed, setIsCollapsed] = useState(collapsed); const toast = useCallback( (label: string) => (path: string) => @@ -142,7 +137,7 @@ function CardStage({
setIsCollapsed((value) => !value)} patchState={patchState} diff --git a/apps/app/src/components/secondary-panel/git-diff/DiffFileCard.test.tsx b/apps/app/src/components/secondary-panel/git-diff/DiffFileCard.test.tsx index fb39c56f2f..3fd07dbd93 100644 --- a/apps/app/src/components/secondary-panel/git-diff/DiffFileCard.test.tsx +++ b/apps/app/src/components/secondary-panel/git-diff/DiffFileCard.test.tsx @@ -7,8 +7,23 @@ import type { RequestDiffFileContents, } from "@/components/git-diff/GitDiffCardBody"; import type { DiffPatchState } from "@/hooks/queries/use-environment-diff-patches"; +import { + resetPluginSlotStoreForTest, + setPluginSlotRegistrations, +} from "@/lib/plugin-slots"; import { DiffFileCard } from "./DiffFileCard"; +// The diff body defers its renderer until the card scrolls into view. jsdom +// has no layout, so report every observed sentinel as visible. +vi.mock("usehooks-ts", async (importOriginal) => ({ + ...(await importOriginal()), + useIntersectionObserver: () => ({ + ref: () => {}, + isIntersecting: true, + entry: undefined, + }), +})); + const IMAGE_DATA_URL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/Qo3AAAAAElFTkSuQmCC"; @@ -40,7 +55,11 @@ function renderCard({ render( {}} patchState={patchState} @@ -51,8 +70,19 @@ function renderCard({ ); } +const TEXT_PATCH = [ + "diff --git a/src/file.ts b/src/file.ts", + "--- a/src/file.ts", + "+++ b/src/file.ts", + "@@ -1,2 +1,2 @@", + "-const b = 2;", + "+const b = 3;", + "", +].join("\n"); + afterEach(() => { cleanup(); + resetPluginSlotStoreForTest(); }); describe("DiffFileCard", () => { @@ -121,6 +151,43 @@ describe("DiffFileCard", () => { expect(onRequestFileContents).not.toHaveBeenCalled(); }); + it("renders its text body through the shared host diff boundary", async () => { + // The point of the boundary: one `experimental_diffRenderer` registration + // has to reach BB's own diff panel, not just plugin-rendered diffs. + const seen: { patch: string; path: string; view: string }[] = []; + setPluginSlotRegistrations("demo", { + homepageSections: [], + settingsSections: [], + navPanels: [], + threadPanelActions: [], + sidebarFooterActions: [], + fileOpeners: [], + messageDirectives: [], + diffRenderers: [ + { + id: "diffs", + title: "Demo diffs", + component: ({ patch, path, view }) => { + seen.push({ patch, path, view }); + return
plugin diff
; + }, + }, + ], + }); + + renderCard({ + entry: buildEntry(), + patchState: { status: "loaded", patch: TEXT_PATCH }, + }); + + expect(await screen.findByTestId("plugin-diff-body")).toBeTruthy(); + // The caller had the real bytes, so the replacement gets those — not a + // reconstruction. + expect(seen.at(-1)?.patch).toBe(TEXT_PATCH); + expect(seen.at(-1)?.path).toBe("src/file.ts"); + expect(seen.at(-1)?.view).toBe("unified"); + }); + it("falls back to the load gate when an image-looking path is not previewable", async () => { const onLoadPatch = vi.fn(); const onRequestFileContents = vi.fn( diff --git a/apps/app/src/components/secondary-panel/git-diff/DiffFileCard.tsx b/apps/app/src/components/secondary-panel/git-diff/DiffFileCard.tsx index 3dffb9146c..503b60e6de 100644 --- a/apps/app/src/components/secondary-panel/git-diff/DiffFileCard.tsx +++ b/apps/app/src/components/secondary-panel/git-diff/DiffFileCard.tsx @@ -1,6 +1,7 @@ import { memo, useEffect, useMemo, useRef, useState } from "react"; import { useIntersectionObserver } from "usehooks-ts"; import type { DiffFileEntry } from "@bb/server-contract"; +import type { DiffPresentation } from "@/components/code/code-rendering"; import { getGitDiffCardImageSizeStat, GitDiffCardBody, @@ -121,7 +122,7 @@ function buildBinaryImagePreviewPlan( export interface DiffFileCardProps { entry: DiffFileEntry; - diffViewOptions: Record; + presentation: DiffPresentation; filePathRoot?: string | null; isCollapsed: boolean; onToggleCollapsed: () => void; @@ -174,7 +175,7 @@ function areDiffFileCardPropsEqual( ): boolean { return ( previous.entry === next.entry && - previous.diffViewOptions === next.diffViewOptions && + previous.presentation === next.presentation && previous.filePathRoot === next.filePathRoot && previous.isCollapsed === next.isCollapsed && previous.onToggleCollapsed === next.onToggleCollapsed && @@ -275,7 +276,7 @@ function useBinaryImagePreview({ export const DiffFileCard = memo(function DiffFileCard({ entry, - diffViewOptions, + presentation, filePathRoot, isCollapsed, onToggleCollapsed, @@ -400,7 +401,7 @@ export const DiffFileCard = memo(function DiffFileCard({ ; + presentation: DiffPresentation; parsedFile: ParsedGitDiffFile | null; patchState: DiffPatchState; svgDisplayMode: GitDiffCardSvgDisplayMode; @@ -483,7 +484,7 @@ function DiffFileCardLoadDiffNotice({ function DiffFileCardBody({ entry, changedLines, - diffViewOptions, + presentation, parsedFile, patchState, svgDisplayMode, @@ -597,7 +598,7 @@ function DiffFileCardBody({ entry={entry} parsedFile={parsedFile} patchText={patchState.truncated ? undefined : patchState.patch} - diffViewOptions={diffViewOptions} + presentation={presentation} svgDisplayMode={svgDisplayMode} truncated={patchState.truncated ?? false} onOpenFilePreview={onOpenFilePreview} @@ -611,7 +612,7 @@ interface DiffFileCardRenderedBodyProps { entry: DiffFileEntry; parsedFile: ParsedGitDiffFile; patchText?: string; - diffViewOptions: Record; + presentation: DiffPresentation; svgDisplayMode: GitDiffCardSvgDisplayMode; truncated: boolean; onOpenFilePreview?: (path: string) => void; @@ -630,7 +631,7 @@ function DiffFileCardRenderedBody({ entry, parsedFile, patchText, - diffViewOptions, + presentation, svgDisplayMode, truncated, onOpenFilePreview, @@ -648,7 +649,7 @@ function DiffFileCardRenderedBody({ <> ; + presentation: DiffPresentation; filePathRoot?: string | null; /** * Whether the secondary panel is open. While closed the list stays mounted @@ -84,7 +85,7 @@ export function DiffFilesPanel({ files, initialPatches, filesUpdatedAt, - diffViewOptions, + presentation, filePathRoot, isPanelOpen, isPlaceholderData, @@ -232,7 +233,7 @@ export function DiffFilesPanel({ entry={entry} diffIdentity={diffIdentity} fileCount={files.length} - diffViewOptions={diffViewOptions} + presentation={presentation} filePathRoot={filePathRoot} patchState={getPatchState(entry.path)} loadPath={loadPath} @@ -257,7 +258,7 @@ interface DiffFileRowProps { entry: DiffFileEntry; diffIdentity: string; fileCount: number; - diffViewOptions: Record; + presentation: DiffPresentation; filePathRoot?: string | null; patchState: DiffPatchState; loadPath: LoadDiffPatchPath; @@ -272,7 +273,7 @@ function DiffFileRow({ entry, diffIdentity, fileCount, - diffViewOptions, + presentation, filePathRoot, patchState, loadPath, @@ -308,7 +309,7 @@ function DiffFileRow({ return ( { + cleanup(); + window.localStorage.clear(); + resetPluginSlotStoreForTest(); +}); + +describe("CodeRendererSettings", () => { + it("shows no control until a plugin supplies a renderer", () => { + render( + + + , + ); + + expect(screen.queryByRole("button", { name: "Source code" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Diffs" })).toBeNull(); + }); + + it("pins BB's diff renderer without touching the source-code choice", async () => { + setPluginSlotRegistrations("inkwell", { + ...EMPTY_REGISTRATIONS, + sourceCodeRenderers: [ + { id: "source", title: "Inkwell source", component: () => null }, + ], + diffRenderers: [ + { id: "diffs", title: "Inkwell diffs", component: () => null }, + ], + }); + const store = createStore(); + render( + + + , + ); + + const diffTrigger = screen.getByRole("button", { name: "Diffs" }); + expect(diffTrigger.textContent).toContain("Automatic"); + + fireEvent.pointerDown(diffTrigger, { button: 0 }); + fireEvent.click(await screen.findByRole("menuitem", { name: /built-in/u })); + + expect(store.get(diffRendererProviderAtom)).toBe( + BUILT_IN_REPLACEMENT_PROVIDER, + ); + // The two renderers are pinned independently — turning off plugin diffs + // must not silently turn off its source viewer too. + expect(store.get(sourceCodeRendererProviderAtom)).toBe( + AUTOMATIC_REPLACEMENT_PROVIDER, + ); + }); + + it("offers each registered provider by name", () => { + setPluginSlotRegistrations("inkwell", { + ...EMPTY_REGISTRATIONS, + diffRenderers: [ + { id: "diffs", title: "Inkwell diffs", component: () => null }, + ], + }); + setPluginSlotRegistrations("zed", { + ...EMPTY_REGISTRATIONS, + diffRenderers: [ + { + id: "zed-diffs", + title: "Zed diffs", + description: "Side-by-side with word highlights.", + component: () => null, + }, + ], + }); + const store = createStore(); + render( + + + , + ); + + const trigger = screen.getByRole("button", { name: "Diffs" }); + // Plugin ids sort, so "inkwell" is the automatic winner and the label has + // to name it rather than whichever plugin loaded first. + expect(trigger.textContent).toContain("Automatic"); + fireEvent.pointerDown(trigger, { button: 0 }); + expect( + screen.getByRole("menuitem", { name: /Currently using Inkwell diffs/u }), + ).toBeTruthy(); + // Both providers stay individually pinnable, and each carries its own + // description rather than the generic "From the plugin" fallback. + const items = screen + .getAllByRole("menuitem") + .map((item) => item.textContent ?? ""); + expect(items).toHaveLength(4); + expect(items.some((text) => text.includes("From the inkwell plugin"))).toBe( + true, + ); + expect( + items.some((text) => + text.includes("Side-by-side with word highlights."), + ), + ).toBe(true); + }); +}); diff --git a/apps/app/src/components/settings/CodeRendererSettings.tsx b/apps/app/src/components/settings/CodeRendererSettings.tsx new file mode 100644 index 0000000000..58143be793 --- /dev/null +++ b/apps/app/src/components/settings/CodeRendererSettings.tsx @@ -0,0 +1,145 @@ +import { useAtom, type PrimitiveAtom } from "jotai"; +import { Icon } from "@bb/shared-ui/icon"; +import { cn } from "@bb/shared-ui/lib/utils"; +import { COARSE_POINTER_ICON_SIZE_CLASS } from "@bb/shared-ui/coarse-pointer-sizing"; +import { Button } from "@bb/shared-ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@bb/shared-ui/dropdown-menu"; +import { SettingsWithControl } from "@/components/ui/settings-section"; +import { + diffRendererProviderAtom, + sourceCodeRendererProviderAtom, +} from "@/components/code/codeRendererProvider"; +import { + AUTOMATIC_REPLACEMENT_PROVIDER, + BUILT_IN_REPLACEMENT_PROVIDER, + replacementProviderKey, +} from "@/lib/plugin-replacement-preference"; +import { usePluginSlots } from "@/lib/plugin-slots"; + +interface CodeRendererProviderSlot { + pluginId: string; + id: string; + title: string; + description?: string; +} + +interface CodeRendererSettingProps { + label: string; + description: string; + builtInDescription: string; + preferenceAtom: PrimitiveAtom; + slots: readonly CodeRendererProviderSlot[]; +} + +/** + * The per-client pin for one code renderer, mirroring the sidebar thread list + * control. A renderer takes over surfaces the user has no other way back + * from — the file preview, every diff — so pinning has to be reachable + * without uninstalling the plugin that supplied it. + */ +function CodeRendererSetting({ + label, + description, + builtInDescription, + preferenceAtom, + slots, +}: CodeRendererSettingProps) { + const [preference, setPreference] = useAtom(preferenceAtom); + + const automaticProvider = slots[0]; + if (automaticProvider === undefined) return null; + const builtInOption = { + key: BUILT_IN_REPLACEMENT_PROVIDER, + title: "bb (built-in)", + description: builtInDescription, + }; + const options = [ + { + key: AUTOMATIC_REPLACEMENT_PROVIDER, + title: "Automatic", + description: `Currently using ${automaticProvider.title} from ${automaticProvider.pluginId}.`, + }, + builtInOption, + ...slots.map((slot) => ({ + key: replacementProviderKey(slot), + title: slot.title, + description: slot.description ?? `From the ${slot.pluginId} plugin.`, + })), + ]; + // An unavailable explicit provider renders BB's renderer until it returns. + const selected = + options.find((option) => option.key === preference) ?? builtInOption; + + return ( + + + + + + + {options.map((option) => ( + setPreference(option.key)} + className="flex items-start gap-2" + > + + {option.title} + + {option.description} + + + + + ))} + + + + ); +} + +/** Both code-renderer pins; each row hides itself when no plugin supplies one. */ +export function CodeRendererSettings() { + const { sourceCodeRenderers, diffRenderers } = usePluginSlots(); + return ( + <> + + + + ); +} diff --git a/apps/app/src/components/sidebar/threadListProvider.ts b/apps/app/src/components/sidebar/threadListProvider.ts index 013c971eb6..a5d77a63c2 100644 --- a/apps/app/src/components/sidebar/threadListProvider.ts +++ b/apps/app/src/components/sidebar/threadListProvider.ts @@ -1,33 +1,34 @@ -import { atomWithStorage } from "jotai/utils"; import { useAtomValue } from "jotai"; -import { createJsonLocalStorage } from "@/lib/browser-storage"; -import { resolveThreadListReplacement } from "@/lib/plugin-slot-resolvers"; +import { + AUTOMATIC_REPLACEMENT_PROVIDER, + BUILT_IN_REPLACEMENT_PROVIDER, + createReplacementPreferenceAtom, + replacementProviderKey, + resolvePreferredReplacement, +} from "@/lib/plugin-replacement-preference"; import type { ResolvedReplacement } from "@/lib/plugin-slot-resolvers"; import { usePluginSlots, type PluginThreadListSlot } from "@/lib/plugin-slots"; const THREAD_LIST_PROVIDER_STORAGE_KEY = "bb.sidebar.threadListProvider"; /** Follow deterministic slot order and activate the first provider. */ -export const AUTOMATIC_THREAD_LIST_PROVIDER = "__automatic__"; +export const AUTOMATIC_THREAD_LIST_PROVIDER = AUTOMATIC_REPLACEMENT_PROVIDER; /** Always use BB's own thread list. */ -export const BUILT_IN_THREAD_LIST_PROVIDER = "__builtin__"; +export const BUILT_IN_THREAD_LIST_PROVIDER = BUILT_IN_REPLACEMENT_PROVIDER; /** * Automatic by default, with an explicit per-client override available in * Appearance. Existing stored built-in and plugin selections remain valid. */ -export const threadListProviderAtom = atomWithStorage( +export const threadListProviderAtom = createReplacementPreferenceAtom( THREAD_LIST_PROVIDER_STORAGE_KEY, - AUTOMATIC_THREAD_LIST_PROVIDER, - createJsonLocalStorage(), - { getOnInit: true }, ); export function threadListProviderKey( slot: Pick, ): string { - return `${slot.pluginId}/${slot.id}`; + return replacementProviderKey(slot); } /** @@ -39,26 +40,13 @@ export function resolveThreadListProvider( slots: readonly PluginThreadListSlot[], preference: string = AUTOMATIC_THREAD_LIST_PROVIDER, ): PluginThreadListSlot | null { - const resolved = resolveThreadListProviderReplacement(slots, preference); + const resolved = resolvePreferredReplacement(slots, preference); return resolved.kind === "plugin" ? resolved.registration : null; } -function resolveThreadListProviderReplacement( - slots: readonly PluginThreadListSlot[], - preference: string, -): ResolvedReplacement { - if (preference === BUILT_IN_THREAD_LIST_PROVIDER) return { kind: "owner" }; - return resolveThreadListReplacement( - slots, - preference === AUTOMATIC_THREAD_LIST_PROVIDER - ? undefined - : (candidate) => threadListProviderKey(candidate) === preference, - ); -} - /** The active replacement, or the owner when none is registered. */ export function useThreadListReplacement(): ResolvedReplacement { const { threadLists } = usePluginSlots(); const preference = useAtomValue(threadListProviderAtom); - return resolveThreadListProviderReplacement(threadLists, preference); + return resolvePreferredReplacement(threadLists, preference); } diff --git a/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx b/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx index c7d954efed..69c7cd4475 100644 --- a/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx +++ b/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx @@ -62,7 +62,6 @@ import type { ThreadTimelineImageViewSrcResolver, ThreadTimelineConsumerMessageAction, ThreadTimelinePluginMessageAction, - ThreadTimelineTheme, ThreadTimelineUnreadDividerPlacement, UserAttachmentImageSrcResolver, } from "./types.js"; @@ -186,7 +185,6 @@ export interface ThreadTimelineRowsProps { hasOlderTimelineRows?: boolean; isLoadingOlderTimelineRows?: boolean; onLoadOlderRows?: () => Promise | void; - themeType?: ThreadTimelineTheme; timelineRows: TimelineRow[]; threadId?: string; threadRuntimeDisplayStatus: ThreadRuntimeDisplayStatus; @@ -250,7 +248,6 @@ interface TimelineRendererStaticContextValue { resolveMentionLink: PromptMentionLinkResolver | undefined; resolveSegmentLinkHref: TimelineTitleLinkResolver | undefined; resolveUserAttachmentImageSrc: UserAttachmentImageSrcResolver | undefined; - themeType: ThreadTimelineTheme; threadId: string | undefined; workspaceRootPath: string | undefined; } @@ -1306,7 +1303,6 @@ function TimelineExpandableBody({ onOpenLocalFileLink, projectId, resolveUserAttachmentImageSrc, - themeType, workspaceRootPath, resolveImageViewSrc, } = useTimelineRendererStaticContext(); @@ -1416,7 +1412,6 @@ function TimelineExpandableBody({ ); @@ -2120,7 +2115,6 @@ function ThreadTimelineRowsForTimelineView(props: ThreadTimelineRowsProps) { () => (scopeActive ? findStreamingAssistantMessageId(rows) : null), [rows, scopeActive], ); - const themeType = props.themeType ?? "light"; const computedAutoExpansionRowIds = useMemo( () => collectTimelineAutoExpansionRowIds({ rows, scopeActive }), [rows, scopeActive], @@ -2281,7 +2275,6 @@ function ThreadTimelineRowsForTimelineView(props: ThreadTimelineRowsProps) { resolveMentionLink: props.resolveMentionLink, resolveSegmentLinkHref, resolveUserAttachmentImageSrc: props.resolveUserAttachmentImageSrc, - themeType, threadId: props.threadId, workspaceRootPath: props.workspaceRootPath, }), @@ -2311,7 +2304,6 @@ function ThreadTimelineRowsForTimelineView(props: ThreadTimelineRowsProps) { props.resolveUserAttachmentImageSrc, props.threadId, props.workspaceRootPath, - themeType, ], ); const turnStateContextValue = useMemo( diff --git a/apps/app/src/components/thread/timeline/ThreadTimelineSurface.tsx b/apps/app/src/components/thread/timeline/ThreadTimelineSurface.tsx index 978011b318..8f04924ba9 100644 --- a/apps/app/src/components/thread/timeline/ThreadTimelineSurface.tsx +++ b/apps/app/src/components/thread/timeline/ThreadTimelineSurface.tsx @@ -11,7 +11,6 @@ import { ConversationTimeline } from "@/components/ui/conversation.js"; import { HeightTransition } from "@/components/ui/height-transition.js"; import { Icon } from "@bb/shared-ui/icon"; import { Skeleton } from "@bb/shared-ui/skeleton"; -import { usePreferredTheme } from "@/hooks/useTheme"; import { toUserAttachmentImageSrc } from "@/lib/user-attachment-images"; import { ThreadTimelineRows } from "./ThreadTimelineRows.js"; import { useAutoLoadOlderRows } from "./useAutoLoadOlderRows.js"; @@ -178,7 +177,6 @@ export function ThreadTimelineSurface({ unreadDividerPlacement, workspaceRootPath, }: ThreadTimelineSurfaceProps) { - const preferredTheme = usePreferredTheme(); const showActiveThinking = activeThinking !== null && ongoingIndicatorLabel === undefined; const activeThinkingText = activeThinking?.text.trim() ?? ""; @@ -241,7 +239,6 @@ export function ThreadTimelineSurface({ hasOlderTimelineRows={hasOlderTimelineRows} isLoadingOlderTimelineRows={isLoadingOlderTimelineRows} onLoadOlderRows={onLoadOlderRows} - themeType={preferredTheme} timelineRows={timelineRowsWithPendingStop} threadId={threadId} threadRuntimeDisplayStatus={threadRuntimeDisplayStatus} diff --git a/apps/app/src/components/thread/timeline/TimelineFileDiffBlock.tsx b/apps/app/src/components/thread/timeline/TimelineFileDiffBlock.tsx index 73162cabed..9ba51b081b 100644 --- a/apps/app/src/components/thread/timeline/TimelineFileDiffBlock.tsx +++ b/apps/app/src/components/thread/timeline/TimelineFileDiffBlock.tsx @@ -8,12 +8,11 @@ import { } from "@bb/client-core"; import { GitDiffCard } from "../../git-diff/GitDiffCard.js"; import { EventCodeBlock } from "../../ui/event-code-block.js"; +import type { DiffPresentation } from "@/components/code/code-rendering"; import { TimelineDetailScroll } from "./TimelineDetailScroll.js"; -import type { ThreadTimelineTheme } from "./types.js"; export interface TimelineFileDiffBlockProps { change: TimelineFileChange; - themeType: ThreadTimelineTheme; /** * Workspace root path the agent ran in (`environment.path`). When defined, * the prefix is stripped from `change.path`/`change.movePath` before the @@ -27,6 +26,7 @@ export interface TimelineFileDiffBlockProps { interface RenderablePatch { disableLineNumbers: boolean; fileDiff: FileDiffMetadata; + patch: string; } interface RenderedFileChange { @@ -34,11 +34,6 @@ interface RenderedFileChange { renderablePatch: RenderablePatch | null; } -const DIFF_VIEW_BASE_OPTIONS = { - overflow: "scroll", - diffStyle: "unified", -} as const; - const renderedFileChangeCache = new WeakMap< TimelineFileChange, RenderedFileChange @@ -63,6 +58,7 @@ function parseRenderablePatch( return { disableLineNumbers: patchText.disableLineNumbers, fileDiff, + patch: patchText.patch, }; } catch { return null; @@ -92,7 +88,6 @@ function buildRenderedFileChange( export const TimelineFileDiffBlock = memo(function TimelineFileDiffBlock({ change, - themeType, workspaceRootPath, }: TimelineFileDiffBlockProps) { const renderedChange = useMemo( @@ -100,16 +95,16 @@ export const TimelineFileDiffBlock = memo(function TimelineFileDiffBlock({ [change], ); const renderablePatch = renderedChange.renderablePatch; - const cardDiffViewOptions = useMemo( + const cardPresentation = useMemo( () => renderablePatch ? { - ...DIFF_VIEW_BASE_OPTIONS, - themeType, - disableLineNumbers: renderablePatch.disableLineNumbers, + view: "unified", + overflow: "scroll", + showLineNumbers: !renderablePatch.disableLineNumbers, } : null, - [renderablePatch, themeType], + [renderablePatch], ); if (renderablePatch === null && renderedChange.plainDiff === null) { @@ -122,7 +117,7 @@ export const TimelineFileDiffBlock = memo(function TimelineFileDiffBlock({ const diffContentKey = `${renderablePatch ? "p" : "n"}:${renderedChange.plainDiff?.length ?? 0}`; - if (renderablePatch && cardDiffViewOptions) { + if (renderablePatch && cardPresentation) { return ( {row.stderr ? ( diff --git a/apps/app/src/components/thread/timeline/index.ts b/apps/app/src/components/thread/timeline/index.ts index 383c6ae29b..b1fa279df0 100644 --- a/apps/app/src/components/thread/timeline/index.ts +++ b/apps/app/src/components/thread/timeline/index.ts @@ -45,6 +45,5 @@ export type { ThreadTimelineLocalFileLinkHandler, ThreadTimelineOpenPluginPanelHandler, ThreadTimelineUnreadDividerPlacement, - ThreadTimelineTheme, UserAttachmentImageSrcResolver, } from "./types.js"; diff --git a/apps/app/src/components/thread/timeline/rows/FileChange.stories.tsx b/apps/app/src/components/thread/timeline/rows/FileChange.stories.tsx index cdbd79a169..c3589617b2 100644 --- a/apps/app/src/components/thread/timeline/rows/FileChange.stories.tsx +++ b/apps/app/src/components/thread/timeline/rows/FileChange.stories.tsx @@ -1,9 +1,5 @@ import type { TimelineRow } from "@bb/server-contract"; -import { - ThreadTimelineRows, - type ThreadTimelineRowsProps, -} from "@/components/thread/timeline"; -import { usePreferredTheme } from "@/hooks/useTheme"; +import { ThreadTimelineRows } from "@/components/thread/timeline"; import { fileChangeRow } from "@/test/fixtures/thread-timeline-rows"; import { StoryCard, StoryRow } from "../../../../../.ladle/story-card"; @@ -20,18 +16,6 @@ const baseProps = { workspaceRootPath: "/Users/michael/.bb-dev/worktrees/env_story/bb", }; -// Story-only wrapper — pulls the active theme from ladle so the diff body's -// syntax highlighting flips with the toolbar toggle. Without this each -// ThreadTimelineRows render would default to themeType="light" regardless -// of the page theme. -type ThemedTimelineRowsProps = Omit & - Partial>; - -function ThemedTimelineRows(props: ThemedTimelineRowsProps) { - const themeType = usePreferredTheme(); - return ; -} - // --------------------------------------------------------------------------- // Real file-change rows pulled from live threads in ~/.bb-dev/bb.db. // @@ -199,7 +183,7 @@ export function Overview() { hint="production-default — header only, click to expand. Real unified diff." > - + - @@ -218,7 +202,7 @@ export function Overview() { hint="kind=delete. Diff is the prior file content; stats count as removed." > - @@ -229,7 +213,7 @@ export function Overview() { hint="status=pending, no completedAt — edit is mid-flight" > - @@ -237,7 +221,7 @@ export function Overview() { - + - @@ -256,7 +240,7 @@ export function Overview() { hint="approvalStatus=waiting_for_approval, parked before applying the edit" > - @@ -267,7 +251,7 @@ export function Overview() { hint="approvalStatus=denied, user rejected the edit" > - @@ -278,7 +262,7 @@ export function Overview() { hint="extract-to-memo refactor of ThreadFollowUpComposer.tsx — full diff body inline" > - getSettingsRoutePath("appearance"), ), + ...namedSlotItems( + pluginId, + slots.sourceCodeRenderers, + "source-code-renderer", + "Replaces how source code is displayed everywhere in the app.", + ), + ...namedSlotItems( + pluginId, + slots.diffRenderers, + "diff-renderer", + "Replaces how diffs are displayed everywhere in the app.", + ), ...namedSlotItems( pluginId, slots.threadPanelActions, diff --git a/apps/app/src/lib/plugin-replacement-preference.ts b/apps/app/src/lib/plugin-replacement-preference.ts new file mode 100644 index 0000000000..136ac56ae5 --- /dev/null +++ b/apps/app/src/lib/plugin-replacement-preference.ts @@ -0,0 +1,57 @@ +import { atomWithStorage } from "jotai/utils"; +import { createJsonLocalStorage } from "@/lib/browser-storage"; +import { + resolveReplacement, + type ResolvedReplacement, +} from "@/lib/plugin-slot-resolvers"; + +/** + * The per-client pin shared by every exclusive replacement surface that offers + * one (the sidebar thread list, the source and diff renderers). + * + * All three answer the same question — automatic, BB's own, or one named + * provider — so they answer it the same way, and a stored selection for an + * unavailable provider degrades to BB without being erased: a temporarily + * disabled plugin gets its surface back when it returns. + */ + +/** Follow deterministic slot order and activate the first provider. */ +export const AUTOMATIC_REPLACEMENT_PROVIDER = "__automatic__"; + +/** Always use BB's own implementation. */ +export const BUILT_IN_REPLACEMENT_PROVIDER = "__builtin__"; + +interface ReplacementProviderIdentity { + pluginId: string; + id: string; +} + +export function replacementProviderKey( + slot: ReplacementProviderIdentity, +): string { + return `${slot.pluginId}/${slot.id}`; +} + +export function createReplacementPreferenceAtom(storageKey: string) { + return atomWithStorage( + storageKey, + AUTOMATIC_REPLACEMENT_PROVIDER, + createJsonLocalStorage(), + { getOnInit: true }, + ); +} + +export function resolvePreferredReplacement< + Slot extends ReplacementProviderIdentity, +>( + slots: readonly Slot[], + preference: string = AUTOMATIC_REPLACEMENT_PROVIDER, +): ResolvedReplacement { + if (preference === BUILT_IN_REPLACEMENT_PROVIDER) return { kind: "owner" }; + return resolveReplacement( + slots, + preference === AUTOMATIC_REPLACEMENT_PROVIDER + ? undefined + : (candidate) => replacementProviderKey(candidate) === preference, + ); +} diff --git a/apps/app/src/lib/plugin-sdk-app-impl.tsx b/apps/app/src/lib/plugin-sdk-app-impl.tsx index fb0d70d4d6..c754eff3a5 100644 --- a/apps/app/src/lib/plugin-sdk-app-impl.tsx +++ b/apps/app/src/lib/plugin-sdk-app-impl.tsx @@ -1,6 +1,8 @@ import { useMemo } from "react"; import type { MarkdownProps, PluginSdkApp } from "@get-bb/plugin-sdk"; +import { PluginDiff } from "@/components/plugin/PluginDiff"; import { PluginNewThreadComposer } from "@/components/plugin/PluginNewThreadComposer"; +import { PluginSourceCode } from "@/components/plugin/PluginSourceCode"; import { PluginThreadChat } from "@/components/plugin/PluginThreadChat"; import { MarkdownPreview } from "@/components/ui/markdown-preview"; import type { @@ -58,6 +60,11 @@ export const pluginSdkAppImplementation = { // Experimental (see docs/api_to_audit.md): the create-side counterpart to // ThreadChat. experimental_NewThreadComposer: PluginNewThreadComposer, + // Experimental (see docs/api_to_audit.md): the host-owned code renderers. + // Both resolve any active plugin replacement, so first-party surfaces and + // plugins share one boundary. + experimental_SourceCode: PluginSourceCode, + experimental_Diff: PluginDiff, // Experimental (see docs/api_to_audit.md): the sidebar thread-list data // plane, for plugins that replace the list itself. experimental_useSidebarThreads: useSidebarThreads, diff --git a/apps/app/src/lib/plugin-slot-resolvers.test.ts b/apps/app/src/lib/plugin-slot-resolvers.test.ts index fdc17cf704..f5202eee54 100644 --- a/apps/app/src/lib/plugin-slot-resolvers.test.ts +++ b/apps/app/src/lib/plugin-slot-resolvers.test.ts @@ -4,7 +4,6 @@ import type { PluginFileOpenerSlot, PluginMessageDirectiveSlot, PluginPendingInteractionSlot, - PluginThreadListSlot, } from "./plugin-slots"; import { BUILT_IN_FILE_OPENER_PREFERENCE, @@ -19,7 +18,6 @@ import { resolveMessageDirectiveRegistry, resolvePendingInteraction, resolveReplacement, - resolveThreadListReplacement, } from "./plugin-slot-resolvers"; function Component() { @@ -164,22 +162,6 @@ describe("replacement resolvers", () => { expect(resolveReplacement([], () => true)).toEqual({ kind: "owner" }); }); - it("automatically activates the first thread-list replacement", () => { - const threadList: PluginThreadListSlot = { - pluginId: "inbox", - generation: 1, - id: "threads", - title: "Inbox", - component: Component, - }; - - expect(resolveThreadListReplacement([threadList])).toEqual({ - kind: "plugin", - registration: threadList, - }); - expect(resolveThreadListReplacement([])).toEqual({ kind: "owner" }); - }); - it("activates the first matching file opener and preserves per-open overrides", () => { const markdown: PluginFileOpenerSlot = { pluginId: "docs", diff --git a/apps/app/src/lib/plugin-slot-resolvers.ts b/apps/app/src/lib/plugin-slot-resolvers.ts index 124759faa8..0947e34ab7 100644 --- a/apps/app/src/lib/plugin-slot-resolvers.ts +++ b/apps/app/src/lib/plugin-slot-resolvers.ts @@ -8,7 +8,6 @@ import type { PluginFileOpenerSlot, PluginMessageDirectiveSlot, PluginPendingInteractionSlot, - PluginThreadListSlot, } from "./plugin-slots"; type ComposerAction = NonNullable[number]; @@ -265,13 +264,6 @@ export function resolveReplacement( : { kind: "plugin", registration }; } -export function resolveThreadListReplacement( - registrations: readonly PluginThreadListSlot[], - applies?: (registration: PluginThreadListSlot) => boolean, -): ResolvedReplacement { - return resolveReplacement(registrations, applies); -} - export type FileOpenerOverride = | "builtin" | { pluginId: string; openerId: string }; diff --git a/apps/app/src/lib/plugin-slots.ts b/apps/app/src/lib/plugin-slots.ts index 897d11e76d..2317ad19b9 100644 --- a/apps/app/src/lib/plugin-slots.ts +++ b/apps/app/src/lib/plugin-slots.ts @@ -1,6 +1,7 @@ import { useSyncExternalStore } from "react"; import type { ComposerCustomization, + PluginDiffRendererRegistration, PluginPendingInteractionRegistration, PluginFileOpenerRegistration, PluginHomepageSectionRegistration, @@ -11,6 +12,7 @@ import type { PluginProviderIconRegistration, PluginSettingsSectionRegistration, PluginSidebarFooterActionRegistration, + PluginSourceCodeRendererRegistration, PluginThreadHeaderActionRegistration, PluginThreadListRegistration, PluginThreadPanelActionRegistration, @@ -42,6 +44,13 @@ export interface PluginRegistrationSet { /** Optional for the same reason as `threadLists`: bundles built earlier. */ threadHeaderActions?: readonly PluginThreadHeaderActionRegistration[]; fileOpeners: readonly PluginFileOpenerRegistration[]; + /** + * Optional for the same reason as `threadLists`: bundles built before the + * exclusive code-rendering slots existed never call them. + */ + sourceCodeRenderers?: readonly PluginSourceCodeRendererRegistration[]; + /** Optional for the same reason as `sourceCodeRenderers`. */ + diffRenderers?: readonly PluginDiffRendererRegistration[]; messageDirectives: readonly PluginMessageDirectiveRegistration[]; messageActions?: readonly PluginMessageActionRegistration[]; /** Optional for the same reason as `threadLists`: bundles built earlier. */ @@ -81,6 +90,10 @@ export interface PluginThreadHeaderActionSlot extends PluginThreadHeaderActionRegistration, PluginSlotBase {} export interface PluginFileOpenerSlot extends PluginFileOpenerRegistration, PluginSlotBase {} +export interface PluginSourceCodeRendererSlot + extends PluginSourceCodeRendererRegistration, PluginSlotBase {} +export interface PluginDiffRendererSlot + extends PluginDiffRendererRegistration, PluginSlotBase {} export interface PluginMessageDirectiveSlot extends PluginMessageDirectiveRegistration, PluginSlotBase {} export interface PluginMessageActionSlot @@ -101,6 +114,8 @@ export interface PluginSlotSnapshot { threadLists: readonly PluginThreadListSlot[]; threadHeaderActions: readonly PluginThreadHeaderActionSlot[]; fileOpeners: readonly PluginFileOpenerSlot[]; + sourceCodeRenderers: readonly PluginSourceCodeRendererSlot[]; + diffRenderers: readonly PluginDiffRendererSlot[]; messageDirectives: readonly PluginMessageDirectiveSlot[]; messageActions: readonly PluginMessageActionSlot[]; providerIcons: readonly PluginProviderIconSlot[]; @@ -118,6 +133,8 @@ export const EMPTY_PLUGIN_SLOT_SNAPSHOT: PluginSlotSnapshot = { threadLists: [], threadHeaderActions: [], fileOpeners: [], + sourceCodeRenderers: [], + diffRenderers: [], messageDirectives: [], messageActions: [], providerIcons: [], @@ -142,6 +159,8 @@ const SLOT_KINDS: readonly SlotKind[] = [ "threadLists", "threadHeaderActions", "fileOpeners", + "sourceCodeRenderers", + "diffRenderers", "messageDirectives", "messageActions", "providerIcons", @@ -187,6 +206,8 @@ function flattenRegistrations( threadLists: stamp(set.threadLists), threadHeaderActions: stamp(set.threadHeaderActions), fileOpeners: stamp(set.fileOpeners), + sourceCodeRenderers: stamp(set.sourceCodeRenderers), + diffRenderers: stamp(set.diffRenderers), messageDirectives: stamp(set.messageDirectives), messageActions: stamp(set.messageActions), providerIcons: stamp(set.providerIcons), diff --git a/apps/app/src/views/SettingsView.tsx b/apps/app/src/views/SettingsView.tsx index 3b3b334676..093881992b 100644 --- a/apps/app/src/views/SettingsView.tsx +++ b/apps/app/src/views/SettingsView.tsx @@ -41,6 +41,7 @@ import { } from "@/hooks/useTheme"; import { useHostDaemon, useLocalHostDaemonAccess } from "@/hooks/useHostDaemon"; import { UsageLimitsSettingsSection } from "@/components/settings/UsageLimitsSettingsSection"; +import { CodeRendererSettings } from "@/components/settings/CodeRendererSettings"; import { SidebarThreadListSetting } from "@/components/settings/SidebarThreadListSetting"; import { SplitDimmingSetting } from "@/components/settings/SplitDimmingSetting"; import { useSettingsNavState } from "@/components/settings/settings-nav"; @@ -684,6 +685,7 @@ export function AppearanceSettingsSection({
+ diff --git a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md index f91b1b529e..6ed8dc424e 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md +++ b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md @@ -1682,6 +1682,35 @@ projectId }` (nullable fields) and `path` follows the source (workspace: always use the built-in preview, and a removed/disabled opener degrades back to it. Pair with `bb.sdk.files` (rpc from your server) to load and CAS-save the content. +- `experimental_sourceCodeRenderer` / `experimental_diffRenderer` → + replace bb's source or diff renderer everywhere it draws supplied content: + the native file preview, timeline file diffs, the environment diff panel's + file bodies, and every plugin calling the host components. Registration: + `{ id, title, description?, component }`. Like `experimental_threadList` + each slot is **exclusive** — one renderer at a time, first in slot order + wins, and a missing, disabled, or crashing replacement falls back to bb's + renderer. Installing and enabling the plugin activates it, and the user can + pin bb's renderer or a specific provider under + **Settings → Appearance** ("Source code" / "Diffs"), per client. There are no + scope or extension filters on the registration, so conditional behavior + belongs in the component. Source props: + `{ content, path, overflow, highlightedLines, experimental_Original }`; + diff props: + `{ patch, path, view, overflow, showLineNumbers, experimental_Original }`. + Every value is already resolved. Render `experimental_Original` (bb's + renderer, bound to this call) to delegate without re-entering resolution — + behind a plugin setting, by language, over a size threshold: + + ```tsx + app.slots.experimental_diffRenderer({ + id: "compact", + title: "Compact diffs", + component: ({ patch, path, experimental_Original: Original }) => + patch.length > 20_000 ? : , + }); + ``` + + Experimental: see `docs/api_to_audit.md`. - `messageDirective` → `{ attributes, source, message, openWorkspaceFile }` — register a leaf assistant-message directive. Registration: @@ -1772,6 +1801,32 @@ className?, leadingContent?, messageActions? }` — host owns timeline loading, streaming, drafts, send/queue/steer/stop, attachments, execution controls, pending interactions, and read tracking — do not proxy thread data through your own RPC or rebuild the composer. +- `experimental_SourceCode` — bb's source viewer. Props: + `{ content, path, overflow?, highlightedLines?, className? }` — `path` + drives language detection, `overflow` is `"scroll"` (default) or `"wrap"`, + and `highlightedLines` is a 1-based inclusive `{ start, end }` (default + null). bb owns syntax highlighting, gutters, and the live code theme. +- `experimental_Diff` — bb's diff viewer. Props: + `{ patch, path, view?, overflow?, showLineNumbers?, className? }` — + `patch` is a unified patch for exactly ONE file and `view` is `"unified"` + (default) or `"split"`. bb normalizes the patch, so a GitHub REST patch or + a bare `@@` hunk works without synthesizing a `diff --git` header + yourself; unparseable content degrades to plain monospace text. Reference: + `plugins/github/app.tsx`. + + Alias both on import — JSX reads a lowercase-initial name as an intrinsic + element: + + ```tsx + import { experimental_Diff as Diff } from "@get-bb/plugin-sdk/app"; + + ; + ``` + + Highlighting uses the host's shared worker pool from React context. Thread + panels and plugin nav panels have one; homepage and settings sections do + not, so code there renders unhighlighted rather than broken. + Experimental: see `docs/api_to_audit.md`. - `Markdown` — bb's chat-message markdown renderer (same typography, spacing, and code styling as timeline messages). Props: `{ content, className? }`. Use it wherever plugin UI quotes or previews @@ -1973,16 +2028,12 @@ only `definePluginApp` + the hooks): its namespace would bloat the host's boot payload) — it bundles from your `node_modules` in both `app.tsx` and `server.ts`, so keep it in `dependencies`. -- Syntax-highlighted diffs: `parsePatchFiles` from `@pierre/diffs` + - `FileDiff` from `@pierre/diffs/react` render patches exactly like the - app's own diff panel (the host provides the highlighting worker pool via - React context on every plugin surface; add `@pierre/diffs` to - devDependencies for types). Pass - `theme: { dark: document.documentElement.dataset.bbCodeThemeDark, -light: document.documentElement.dataset.bbCodeThemeLight }` so a custom - UI theme's Pierre JSON applies. Synthesize a `diff --git a/

b/

` - header when your patch source (e.g. the GitHub REST API) omits it — see - `plugins/github/app.tsx`. +- Source and diffs: use the host components + `experimental_SourceCode` / `experimental_Diff` (see "Host components"), + NOT a direct + `@pierre/diffs` import. The shim stays for compatibility, but hand-rolled + Pierre usage means owning patch normalization and the code theme yourself, + and it opts you out of any installed renderer replacement. - Everything else bundles from YOUR `node_modules` (hugeicons, lucide, non-portal radix, zod, form/calendar/chart libs): run `npm install` after adding components (`bb plugin new` runs the first one; `shadcn add` diff --git a/apps/server/test/services/plugins/plugin-authoring-docs.test.ts b/apps/server/test/services/plugins/plugin-authoring-docs.test.ts index 3458a6a332..ac39e8b4c8 100644 --- a/apps/server/test/services/plugins/plugin-authoring-docs.test.ts +++ b/apps/server/test/services/plugins/plugin-authoring-docs.test.ts @@ -8,6 +8,7 @@ import { type PluginAppSlots, type PluginContentScriptContext, type PluginContentScriptRegistration, + type PluginDiffRendererProps, type PluginFileOpenerProps, type PluginHomepageSectionProps, type PluginHttpAuthMode, @@ -22,6 +23,7 @@ import { type PluginSettingDescriptor, type PluginSettingsSectionProps, type PluginSidebarFooterActionProps, + type PluginSourceCodeRendererProps, type PluginThreadHeaderActionProps, type PluginThreadListProps, type PluginSidebarFooterActionRegistration, @@ -162,6 +164,8 @@ type SlotPropsByName = { experimental_threadList: PluginThreadListProps; experimental_threadHeaderAction: PluginThreadHeaderActionProps; fileOpener: PluginFileOpenerProps; + experimental_sourceCodeRenderer: PluginSourceCodeRendererProps; + experimental_diffRenderer: PluginDiffRendererProps; messageDirective: PluginMessageDirectiveProps; messageAction: PluginMessageActionContext; // Registration-object slot: the component receives only className, so the @@ -240,6 +244,21 @@ const FRONTEND_SLOT_PROP_FIELDS = { "isCompactViewport", ], fileOpener: ["path", "source", "experimental_Original"], + experimental_sourceCodeRenderer: [ + "content", + "path", + "overflow", + "highlightedLines", + "experimental_Original", + ], + experimental_diffRenderer: [ + "patch", + "path", + "view", + "overflow", + "showLineNumbers", + "experimental_Original", + ], messageDirective: ["attributes", "source", "message", "openWorkspaceFile"], messageAction: ["threadId", "message", "selectedText", "openPanel"], experimental_providerIcon: ["providerId", "icon"], diff --git a/docs/api_to_audit.md b/docs/api_to_audit.md index 08f19c5292..6ed96e2c40 100644 --- a/docs/api_to_audit.md +++ b/docs/api_to_audit.md @@ -557,6 +557,121 @@ the same plugin again. 4. Verify the owner renderer remains independent of provider precedence and cannot recurse through file-opener resolution. +## `experimental_SourceCode` / `experimental_Diff` (`@get-bb/plugin-sdk/app`) + +**What it does.** Two host-owned renderers for supplied code content. +`experimental_SourceCode` takes source text plus a path and owns syntax +highlighting, gutters, wrapping, highlighted-line presentation, and the live BB +code theme. `experimental_Diff` takes a single-file patch plus a path and owns +patch normalization (a patch without a `diff --git` header is completed from +`path`, which is what makes GitHub's REST patches and bare `@@` hunks render), +syntax highlighting, unified/split presentation, gutters, and the same live +theme. Patch content that will not parse degrades to plain monospace text. + +These are the same components BB's own file preview, timeline file diffs, and +environment diff panel render through, so an active +`experimental_sourceCodeRenderer` / `experimental_diffRenderer` replacement +covers first-party surfaces and plugin surfaces at once. Fetching files or git +data, multi-file lists, tabs, card headers, git actions, and add-to-prompt +behavior deliberately stay with the caller. + +**Audit before stabilizing.** + +1. **Prop surface.** Confirm content + path + presentation is the right minimal + contract, and decide whether `className` belongs in it at all — a + replacement never receives it today, so a `className` that only styles BB's + renderer is a quiet inconsistency. +2. **Diff input shape.** Confirm single-file patch text is the right currency. + Multi-file patches, `processFile`-style pre-parsed input, and per-hunk + rendering are all things callers have wanted; none are expressible now. +3. **Language selection.** Highlighting is inferred from `path` only. Confirm + an explicit language override is not needed before the names freeze, and + that no implementation-library language union leaks in when it is added. +4. **Worker pool.** Highlighting needs BB's Pierre worker pool from React + context. Thread panes and plugin nav panels provide one; homepage and + settings sections do not, so a diff rendered there is unhighlighted rather + than broken. Decide whether the host should provide the pool at the + component instead of the surface. +5. **Selection to chat.** BB's own surfaces pass a selection-to-composer + handler that the public component withholds. Confirm plugins should reach + that through `useComposer()` rather than a renderer prop. +6. **Size and virtualization.** Neither component caps input size or + virtualizes. Audit against a plugin that renders a very large file or patch. + +## `app.slots.experimental_sourceCodeRenderer` / `app.slots.experimental_diffRenderer` (`@get-bb/plugin-sdk/app`) + +**What it does.** Replaces BB's source or diff renderer everywhere it draws +supplied content — the native file preview, timeline file diffs, the +environment diff panel's file bodies, and every plugin calling the public +components. Like `experimental_threadList` these slots are **exclusive**: one +renderer each. Registering activates it while the plugin is enabled; if several +are registered the first in slot snapshot order wins (plugin ids sorted, then +each plugin's registration order). The user can override that under +Settings → Appearance ("Source code" and "Diffs") by pinning BB's renderer or +a specific provider; the choice is per client, and it is the same +automatic/built-in/named-provider model the sidebar thread list uses. There are +deliberately no scope, extension, or enabled-by-setting filters on the +registration — conditional behavior belongs in the component, which decides per +call from its semantic props and renders `experimental_Original` when it does +not want the render. + +Fallbacks: no registration renders BB's renderer; a disabled or uninstalled +plugin reveals the next registration or BB's renderer; a component that throws +renders BB's renderer through the slot's crash fallback. A pinned provider that +is temporarily unavailable renders BB's renderer without erasing the pin. + +**Audit before stabilizing.** + +1. **Arbitration.** Confirm automatic/pinned/built-in is the right long-term + selection model here as it is for the thread list. **Resolved (Aug 2026): + the pin stays per client.** A device-local override matches the sidebar + thread list, even though the key/value app settings added in #1875 would + now make an account-level pin cheap to add. Still open: the two renderers + pin independently; confirm users do not instead expect one "code rendering" + choice. +2. **Resolved (Aug 2026): a crash swaps back to BB's renderer silently.** + A diff card is not a whole sidebar — the reader still sees a correct diff, + where a blank thread list strands them — so neither host passes `onCrash`. + Authors are not left without a signal: `PluginSlotBoundary` still + `console.warn`s the plugin id, slot key, and component stack. The hosts pass + no `instanceId`, so the first crash disables the slot for the session rather + than letting cards crash one at a time. +3. **Resolved (Aug 2026): the replacement is global, other plugins' + surfaces included.** "Install this and every diff looks like X" is the + point; covering BB's surfaces but not the GitHub plugin's would be a + half-measure, and a plugin calling `experimental_Diff` would silently opt + its users out. No first-party-only or own-surfaces-only scope. Audit this as + precedent rather than as a fact about these two slots: no other slot lets a + plugin reach into another plugin's rendered output. +4. **Capability parity.** A replacement cannot implement context expansion, + selection-to-chat, or the deleted-file gate, because those inputs are + host-only. Confirm that asymmetry is acceptable, or promote the ones that + should be part of the contract. +5. **Two slots or one.** Confirm source and diff should stay separately + replaceable rather than one "code renderer" registration. + +## `PluginSourceCodeRendererProps.experimental_Original` / `PluginDiffRendererProps.experimental_Original` (`@get-bb/plugin-sdk/app`) + +**What it does.** Supplies a renderer replacement with BB's renderer bound to +the current render. Rendering it delegates without re-entering replacement +resolution; the host renders the same component as the crash fallback. BB's +renderers are behind `lazy()`, so a replacement that never delegates never +downloads them. + +**Audit before stabilizing.** + +1. Confirm a no-props bound component stays the right delegation contract as + the host-only inputs (pre-parsed files, selection-to-chat) grow. +2. Verify delegation preserves everything the owner path does on BB's own + surfaces — context expansion, line selection, highlighted-line scrolling — + when the replacement delegates from inside a first-party card. +3. Confirm the lazy boundary stays lazy: a replacement that never delegates + must not pull BB's renderer chunk, and the Suspense fallback must not + flash on the owner path. +4. Decide whether this field should stabilize together with the shared + replacement primitive that `PluginThreadListProps` and + `PluginFileOpenerProps` also use, rather than per surface. + ## `experimental_useSidebarThreads` / `experimental_useSidebarThreadActions` (`@get-bb/plugin-sdk/app`) **What it does.** Gives a plugin component the sidebar's live thread view and diff --git a/packages/plugin-sdk/src/app-contract.ts b/packages/plugin-sdk/src/app-contract.ts index 9b82efe875..5656421061 100644 --- a/packages/plugin-sdk/src/app-contract.ts +++ b/packages/plugin-sdk/src/app-contract.ts @@ -183,6 +183,108 @@ export interface PluginFileOpenerProps { experimental_Original: ComponentType; } +// --------------------------------------------------------------------------- +// Host-owned code rendering (SourceCode / Diff) — the public components and +// the props their replacements receive. +// --------------------------------------------------------------------------- + +/** How a code line longer than the viewport is presented. */ +export type CodeOverflowMode = "scroll" | "wrap"; + +/** How a diff presents its two sides. */ +export type DiffViewMode = "unified" | "split"; + +/** A 1-based, inclusive line range. */ +export interface SourceCodeLineRange { + start: number; + end: number; +} + +/** + * Props of the host-owned `experimental_SourceCode` component — BB's source + * viewer. The host owns syntax highlighting, gutters, wrapping, line-selection + * presentation, and the live BB code theme; the caller owns loading the text + * and any surrounding chrome. + */ +export interface SourceCodeProps { + /** The complete source text to render. */ + content: string; + /** File path or name. Drives language detection and the a11y label. */ + path: string; + /** Long-line presentation. Defaults to `"scroll"`. */ + overflow?: CodeOverflowMode; + /** + * Lines to highlight and scroll into view (1-based, inclusive). Defaults to + * `null` — nothing highlighted. + */ + highlightedLines?: SourceCodeLineRange | null; + /** Applied to the renderer's root element. */ + className?: string; +} + +/** + * Props of the host-owned `experimental_Diff` component — BB's diff viewer. + * The host owns patch normalization (a patch without a `diff --git` header is + * completed from `path`), syntax highlighting, unified/split presentation, + * gutters, line-selection presentation, and the live BB code theme. Content + * that cannot be parsed as a patch degrades to plain monospace text. + */ +export interface DiffProps { + /** Unified patch text for exactly ONE file. */ + patch: string; + /** + * The file the patch applies to. Used to complete a patch that arrives + * without a `diff --git` header (GitHub's REST patches, single `@@` hunks) + * and for language detection. + */ + path: string; + /** Side-by-side or inline. Defaults to `"unified"`. */ + view?: DiffViewMode; + /** Long-line presentation. Defaults to `"scroll"`. */ + overflow?: CodeOverflowMode; + /** Whether the gutter shows line numbers. Defaults to `true`. */ + showLineNumbers?: boolean; + /** Applied to the renderer's root element. */ + className?: string; +} + +/** + * Props passed to an `experimental_sourceCodeRenderer` component. Every value + * is already resolved — the replacement never re-applies a host default. + */ +export interface PluginSourceCodeRendererProps { + content: string; + path: string; + overflow: CodeOverflowMode; + highlightedLines: SourceCodeLineRange | null; + /** + * BB's source renderer, bound to this request. Render it to delegate + * conditionally without re-entering plugin replacement resolution. + * + * @experimental Audit before relying on this as a stable contract. + */ + experimental_Original: ComponentType; +} + +/** + * Props passed to an `experimental_diffRenderer` component. `patch` is always + * a complete single-file unified patch, whatever shape the caller supplied. + */ +export interface PluginDiffRendererProps { + patch: string; + path: string; + view: DiffViewMode; + overflow: CodeOverflowMode; + showLineNumbers: boolean; + /** + * BB's diff renderer, bound to this request. Render it to delegate + * conditionally without re-entering plugin replacement resolution. + * + * @experimental Audit before relying on this as a stable contract. + */ + experimental_Original: ComponentType; +} + /** * Message context passed to a `messageDirective` component — the assistant * (or nested agent) message that contained the directive. @@ -730,6 +832,43 @@ export interface PluginFileOpenerRegistration { component: ComponentType; } +/** + * Replace BB's source-code renderer everywhere it renders supplied source + * text — the native file preview and every plugin that calls + * `experimental_SourceCode`. Like `experimental_threadList` this slot is + * **exclusive**: one renderer at a time. Registering activates it while the + * plugin is enabled; if several are registered the first in deterministic slot + * order wins. A missing, disabled, or crashing replacement falls back to BB's + * renderer, and a replacement can render `experimental_Original` to delegate + * per call (behind its own setting, by language, by size — whatever it needs). + */ +export interface PluginSourceCodeRendererRegistration { + /** Unique within the plugin; letters, digits, `-`, `_`. */ + id: string; + /** Label shown in capability details. */ + title: string; + /** Optional one-line description shown with the provider choice. */ + description?: string; + component: ComponentType; +} + +/** + * Replace BB's diff renderer everywhere it renders supplied diff content — the + * timeline file diffs, the environment diff panel's text bodies, and every + * plugin that calls `experimental_Diff`. Exclusive, with the same activation, + * fallback, and `experimental_Original` delegation rules as + * {@link PluginSourceCodeRendererRegistration}. + */ +export interface PluginDiffRendererRegistration { + /** Unique within the plugin; letters, digits, `-`, `_`. */ + id: string; + /** Label shown in capability details. */ + title: string; + /** Optional one-line description shown with the provider choice. */ + description?: string; + component: ComponentType; +} + /** * Register a leaf message directive rendered inside assistant (and nested * agent) message Markdown. `id` is the directive name: `inline-vis` matches @@ -877,6 +1016,22 @@ export interface PluginAppSlots { registration: PluginThreadHeaderActionRegistration, ): void; fileOpener(registration: PluginFileOpenerRegistration): void; + /** + * Replace BB's source-code renderer (see + * {@link PluginSourceCodeRendererRegistration}). Experimental: see + * docs/api_to_audit.md. + */ + experimental_sourceCodeRenderer( + registration: PluginSourceCodeRendererRegistration, + ): void; + /** + * Replace BB's diff renderer (see + * {@link PluginDiffRendererRegistration}). Experimental: see + * docs/api_to_audit.md. + */ + experimental_diffRenderer( + registration: PluginDiffRendererRegistration, + ): void; messageDirective(registration: PluginMessageDirectiveRegistration): void; messageAction(registration: PluginMessageActionRegistration): void; /** @@ -1526,5 +1681,20 @@ export interface PluginSdkApp { * docs/api_to_audit.md for what to audit before the prefix drops. */ experimental_NewThreadComposer: ComponentType; + /** + * The host-owned source viewer (see {@link SourceCodeProps}). Renders + * supplied source text with BB's syntax highlighting, gutters, and live code + * theme, and honours an active `experimental_sourceCodeRenderer` + * replacement. Experimental: see docs/api_to_audit.md. + */ + experimental_SourceCode: ComponentType; + /** + * The host-owned diff viewer (see {@link DiffProps}). Renders supplied patch + * content with BB's normalization, syntax highlighting, unified/split + * presentation, and live code theme, and honours an active + * `experimental_diffRenderer` replacement. Experimental: see + * docs/api_to_audit.md. + */ + experimental_Diff: ComponentType; useComposerView(): ComposerView; } diff --git a/packages/plugin-sdk/src/app.ts b/packages/plugin-sdk/src/app.ts index 133c36b0de..f07ebf75f4 100644 --- a/packages/plugin-sdk/src/app.ts +++ b/packages/plugin-sdk/src/app.ts @@ -50,6 +50,9 @@ export const ThreadChat = runtime.ThreadChat; export const Markdown = runtime.Markdown; export const experimental_NewThreadComposer = runtime.experimental_NewThreadComposer; +// Host-owned code rendering (experimental — see docs/api_to_audit.md). +export const experimental_SourceCode = runtime.experimental_SourceCode; +export const experimental_Diff = runtime.experimental_Diff; export const useRpc = runtime.useRpc; export const useRealtime = runtime.useRealtime; export const useRealtimeConnectionState = runtime.useRealtimeConnectionState; diff --git a/packages/plugin-sdk/src/internal/plugin-app-collector.ts b/packages/plugin-sdk/src/internal/plugin-app-collector.ts index cc983b1f57..d374780aec 100644 --- a/packages/plugin-sdk/src/internal/plugin-app-collector.ts +++ b/packages/plugin-sdk/src/internal/plugin-app-collector.ts @@ -2,6 +2,7 @@ import type { ComposerCustomization, PluginAppDefinition, PluginContentScriptRegistration, + PluginDiffRendererRegistration, PluginFileOpenerRegistration, PluginHomepageSectionRegistration, PluginMessageActionRegistration, @@ -12,6 +13,7 @@ import type { PluginProviderIconRegistration, PluginSettingsSectionRegistration, PluginSidebarFooterActionRegistration, + PluginSourceCodeRendererRegistration, PluginThreadHeaderActionRegistration, PluginThreadListRegistration, PluginThreadPanelActionRegistration, @@ -45,6 +47,8 @@ export interface CollectedPluginAppRegistrations { threadLists: PluginThreadListRegistration[]; threadHeaderActions: PluginThreadHeaderActionRegistration[]; fileOpeners: PluginFileOpenerRegistration[]; + sourceCodeRenderers: PluginSourceCodeRendererRegistration[]; + diffRenderers: PluginDiffRendererRegistration[]; messageDirectives: PluginMessageDirectiveRegistration[]; messageActions: PluginMessageActionRegistration[]; providerIcons: PluginProviderIconRegistration[]; @@ -74,6 +78,8 @@ export function collectPluginAppRegistrations( threadLists: [], threadHeaderActions: [], fileOpeners: [], + sourceCodeRenderers: [], + diffRenderers: [], messageDirectives: [], messageActions: [], providerIcons: [], @@ -91,6 +97,8 @@ export function collectPluginAppRegistrations( threadList: new Set(), threadHeaderAction: new Set(), fileOpener: new Set(), + sourceCodeRenderer: new Set(), + diffRenderer: new Set(), messageDirective: new Set(), messageAction: new Set(), providerIcon: new Set(), @@ -353,6 +361,38 @@ export function collectPluginAppRegistrations( component: requireComponent(kind, registration.component), }); }, + experimental_sourceCodeRenderer(registration) { + const kind = "slots.experimental_sourceCodeRenderer"; + const id = requireSlotId(kind, registration?.id); + requireUniqueId(kind, seenIds.sourceCodeRenderer, id); + const description = requireOptionalString( + kind, + "description", + registration.description, + ); + collected.sourceCodeRenderers.push({ + id, + title: requireNonEmptyString(kind, "title", registration.title), + ...(description !== undefined ? { description } : {}), + component: requireComponent(kind, registration.component), + }); + }, + experimental_diffRenderer(registration) { + const kind = "slots.experimental_diffRenderer"; + const id = requireSlotId(kind, registration?.id); + requireUniqueId(kind, seenIds.diffRenderer, id); + const description = requireOptionalString( + kind, + "description", + registration.description, + ); + collected.diffRenderers.push({ + id, + title: requireNonEmptyString(kind, "title", registration.title), + ...(description !== undefined ? { description } : {}), + component: requireComponent(kind, registration.component), + }); + }, messageDirective(registration) { const kind = "slots.messageDirective"; const id = requireMessageDirectiveId(kind, registration?.id); diff --git a/packages/plugin-sdk/src/testing/app.tsx b/packages/plugin-sdk/src/testing/app.tsx index 1759b4cd07..7f8a092eb6 100644 --- a/packages/plugin-sdk/src/testing/app.tsx +++ b/packages/plugin-sdk/src/testing/app.tsx @@ -29,6 +29,7 @@ import { type PluginHomepageSectionRegistration, type PluginMessageActionRegistration, type PluginMessageDirectiveRegistration, + type PluginDiffRendererRegistration, type PluginNavPanelRegistration, type PluginNewThreadPanelActionRegistration, type PluginPendingInteractionRegistration, @@ -44,6 +45,7 @@ import { type PluginSidebarThreadPullRequestState, type PluginSidebarThreadSplit, type PluginSidebarThreadsState, + type PluginSourceCodeRendererRegistration, type PluginThreadHeaderActionRegistration, type PluginThreadListRegistration, type PluginThreadPanelActionRegistration, @@ -53,6 +55,8 @@ import { type MarkdownProps, type NewThreadComposerProps, type ThreadChatProps, + type DiffProps, + type SourceCodeProps, type JsonValue, } from "@get-bb/plugin-sdk"; import { isComposerDraftEmpty } from "../internal/composer-view.js"; @@ -360,6 +364,61 @@ function TestNewThreadComposer({ ); } +/** + * Stand-in for the host-owned source viewer: emits the raw source in a + * recognizable wrapper carrying the resolved presentation, so plugin tests can + * assert what they asked the host to render without the real highlighter. + */ +function TestSourceCode({ + content, + path, + overflow = "scroll", + highlightedLines = null, + className, +}: SourceCodeProps) { + return ( +

+      {content}
+    
+ ); +} + +/** + * Stand-in for the host-owned diff viewer: emits the raw patch in a + * recognizable wrapper carrying the resolved presentation. + */ +function TestDiff({ + patch, + path, + view = "unified", + overflow = "scroll", + showLineNumbers = true, + className, +}: DiffProps) { + return ( +
+      {patch}
+    
+ ); +} + const testPluginSdkApp = { definePluginApp, useRpc< @@ -425,6 +484,8 @@ const testPluginSdkApp = { ThreadChat: TestThreadChat, Markdown: TestMarkdown, experimental_NewThreadComposer: TestNewThreadComposer, + experimental_SourceCode: TestSourceCode, + experimental_Diff: TestDiff, experimental_useSidebarThreads(): PluginSidebarThreadsState { return useSlotEnv("experimental_useSidebarThreads").sidebarThreads; }, @@ -515,6 +576,8 @@ export interface CapturedPluginApp { threadLists: PluginThreadListRegistration[]; threadHeaderActions: PluginThreadHeaderActionRegistration[]; fileOpeners: PluginFileOpenerRegistration[]; + sourceCodeRenderers: PluginSourceCodeRendererRegistration[]; + diffRenderers: PluginDiffRendererRegistration[]; messageDirectives: PluginMessageDirectiveRegistration[]; messageActions: PluginMessageActionRegistration[]; providerIcons: PluginProviderIconRegistration[]; diff --git a/packages/templates/src/templates/bb-guide-plugins.md b/packages/templates/src/templates/bb-guide-plugins.md index 60d0bf322f..887e5cd531 100644 --- a/packages/templates/src/templates/bb-guide-plugins.md +++ b/packages/templates/src/templates/bb-guide-plugins.md @@ -535,10 +535,13 @@ components/ui/ and `npx shadcn add @bb/` pulls more from the BB component registry (the full stock shadcn set, version-matched to the running BB via the pinned ref in components.json). `import { toast } from "sonner"` reaches the host toaster; react, the portaling radix families, -sonner, vaul, @pierre/diffs (the app's syntax-highlighted diff -renderer), and the host-resident clsx, tailwind-merge, and -class-variance-authority libraries are runtime-shimmed (never bundled), -everything else (zod included) bundles from the plugin's node_modules (`npm install` for authors; BB installs +sonner, vaul, @pierre/diffs, and the host-resident clsx, tailwind-merge, and +class-variance-authority libraries are runtime-shimmed (never bundled) — +though source and diffs should go through the host's own +experimental_SourceCode / experimental_Diff components rather than +@pierre/diffs directly, so bb owns patch normalization, syntax +highlighting, and the live code theme. +Everything else (zod included) bundles from the plugin's node_modules (`npm install` for authors; BB installs release packages with their declared production dependencies). A crashing slot collapses to a "plugin crashed" chip without touching the rest of the app. Installed plugins and their declared settings diff --git a/plugins/github/app.tsx b/plugins/github/app.tsx index 0ee45a0454..5fdc83e1ac 100644 --- a/plugins/github/app.tsx +++ b/plugins/github/app.tsx @@ -15,10 +15,10 @@ import { useMemo, useRef, useState, - useSyncExternalStore, } from "react"; import { definePluginApp, + experimental_Diff as Diff, useBbNavigate, useRealtime, useRpc, @@ -37,11 +37,6 @@ import { type SuggestionIcon, } from "./app-logic.js"; import type { githubRpcContract } from "./server.js"; -// Shimmed to the host's copy at build time (shared worker-pool context + -// shiki stays out of the plugin bundle) — diffs render with the same syntax -// highlighting as the app's own diff panel. -import { parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs"; -import { FileDiff as PierreFileDiff } from "@pierre/diffs/react"; import { toast } from "sonner"; import { Badge } from "@bb/shared-ui/badge"; import { Button } from "@bb/shared-ui/button"; @@ -1391,116 +1386,6 @@ function ChecksSection({ checks }: { checks: PullCheck[] }) { ); } -function readHostCodeTheme(): { dark: string; light: string } { - const root = document.documentElement.dataset; - return { - dark: root.bbCodeThemeDark ?? "pierre-dark", - light: root.bbCodeThemeLight ?? "pierre-light", - }; -} - -/** Read lazily: this module also loads outside a DOM, such as in the plugin - bundle tests, where a module-eval `document` access throws. */ -let hostCodeTheme: { dark: string; light: string } | null = null; -const hostCodeThemeListeners = new Set<() => void>(); -let hostCodeThemeObserver: MutationObserver | null = null; - -function getHostCodeTheme(): { dark: string; light: string } { - hostCodeTheme ??= readHostCodeTheme(); - return hostCodeTheme; -} - -function subscribeHostCodeTheme(onStoreChange: () => void): () => void { - hostCodeThemeListeners.add(onStoreChange); - if (hostCodeThemeObserver === null) { - hostCodeThemeObserver = new MutationObserver(() => { - const next = readHostCodeTheme(); - const current = getHostCodeTheme(); - if (next.dark === current.dark && next.light === current.light) { - return; - } - hostCodeTheme = next; - for (const listener of hostCodeThemeListeners) listener(); - }); - hostCodeThemeObserver.observe(document.documentElement, { - attributes: true, - attributeFilter: ["data-bb-code-theme-dark", "data-bb-code-theme-light"], - }); - } - return () => { - hostCodeThemeListeners.delete(onStoreChange); - }; -} - -function useHostCodeTheme(): { dark: string; light: string } { - return useSyncExternalStore( - subscribeHostCodeTheme, - getHostCodeTheme, - getHostCodeTheme, - ); -} - -/** The host toggles dark mode via a `dark` class on ; pierre's diff - themes are picked per render, so track it live. */ -function useIsDarkTheme(): boolean { - const [dark, setDark] = useState(() => - document.documentElement.classList.contains("dark"), - ); - useEffect(() => { - const observer = new MutationObserver(() => - setDark(document.documentElement.classList.contains("dark")), - ); - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ["class"], - }); - return () => observer.disconnect(); - }, []); - return dark; -} - -/** - * A patch (or single `@@` hunk) rendered through the host's @pierre/diffs — - * syntax highlighting included (the host provides the worker pool via - * context). GitHub's REST patches lack the `diff --git` header, so one is - * synthesized; unparseable input falls back to plain mono text. - */ -function DiffPatch({ path, patch }: { path: string; patch: string }) { - const dark = useIsDarkTheme(); - const codeTheme = useHostCodeTheme(); - const fileDiff = useMemo(() => { - const normalized = patch.replace(/\r\n/g, "\n").trimEnd(); - if (normalized.length === 0) return null; - const text = normalized.startsWith("diff --git") - ? `${normalized}\n` - : `diff --git a/${path} b/${path}\n--- a/${path}\n+++ b/${path}\n${normalized}\n`; - try { - return parsePatchFiles(text)[0]?.files[0] ?? null; - } catch { - return null; - } - }, [path, patch]); - const options = useMemo( - () => - ({ - diffStyle: "unified", - overflow: "scroll", - disableFileHeader: true, - themeType: dark ? "dark" : "light", - theme: codeTheme, - }) as const, - [codeTheme, dark], - ); - if (fileDiff === null) { - return ( -
-        {patch}
-      
- ); - } - return ; -} - function FileDiffCard({ file, url }: { file: PullFile; url: string }) { const [open, setOpen] = useState(false); return ( @@ -1528,7 +1413,7 @@ function FileDiffCard({ file, url }: { file: PullFile; url: string }) { {open ? ( file.patch !== null ? (
- +
) : (

@@ -1554,7 +1439,7 @@ function ReviewThreadCard({ thread }: { thread: ReviewThread }) {

{thread.diffHunk.length > 0 ? (
- +
) : null}
diff --git a/plugins/github/package.json b/plugins/github/package.json index aff7393343..6155dcc166 100644 --- a/plugins/github/package.json +++ b/plugins/github/package.json @@ -44,7 +44,6 @@ }, "devDependencies": { "@get-bb/plugin-sdk": "workspace:*", - "@pierre/diffs": "^1.2.9", "@radix-ui/react-dialog": "^1.1.19", "@radix-ui/react-dropdown-menu": "^2.1.20", "@types/node": "^22.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 65c35ff783..055efca7d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3111,9 +3111,6 @@ importers: '@get-bb/plugin-sdk': specifier: workspace:* version: link:../../packages/plugin-sdk - '@pierre/diffs': - specifier: ^1.2.9 - version: 1.2.9(@shikijs/themes@3.23.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-dialog': specifier: ^1.1.19 version: 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) From e93ce277d9bbd144ad0d854e3810e893fa410814 Mon Sep 17 00:00:00 2001 From: Michael Yong <610102+ymichael@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:16:03 -0700 Subject: [PATCH 012/232] Fix intermittent new-thread submit blockers (#2017) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What was wrong The new-thread composer treated two background discovery requests as hard submission prerequisites. Connected-provider discovery is host-keyed, so changing machines restarted an expensive onboarding probe and disabled an otherwise complete composer; it could also silently change the automatic provider choice. Project branch metadata loading also disabled managed-worktree submission even though the create path independently resolves and validates the default base branch on the selected host. The composer additionally collapsed all remaining blockers into one unexplained disabled boolean. ## What changed Connected-provider discovery is now used only to establish the initial automatic provider default. Its first settled result is retained across machine switches, and the background probe no longer gates submission. Managed-worktree submission can now proceed with `{ kind: "default" }` while branch metadata is loading; the server resolves that default authoritatively during thread creation. A confirmed non-Git or commitless project source still disables managed-worktree creation, and the branch query still enriches the branch picker. The composer also resolves the remaining legitimate eligibility checks into a prioritized disabled reason. The prompt submit action exposes that reason through its accessible label and a tooltip on a pointer-capable wrapper. This is an app-only behavior change with no host-daemon wire, CLI, guide, or documentation changes. ## How you verified - Added hook coverage proving that switching machines retains the initial connected-provider selection and does not issue another onboarding probe; this fails against the previous behavior. - Added coverage proving that a managed-worktree request can use the server-resolved default while branch metadata is absent. - Added resolver and prompt-box interaction coverage for the remaining disabled reasons and tooltip behavior. - `pnpm exec turbo run test --filter=@bb/app -- src/hooks/useThreadCreationOptions.test.tsx src/views/RootComposeView.test.ts src/views/root-compose-thread-environment.test.ts src/components/promptbox/PromptBoxInternal.test.tsx` — 197 tests passed. - `pnpm exec turbo run typecheck lint --filter=@bb/app` — all tasks passed; lint reported zero errors and the existing warning baseline. - `git diff --check` — passed. Fixes: no linked issue. > AGENT GENERATED: by GPT-5 --- .../promptbox/NewThreadComposer.tsx | 140 +++++++++++++----- .../promptbox/NewThreadPromptBox.tsx | 6 + .../promptbox/PromptBoxInternal.test.tsx | 25 ++++ .../promptbox/PromptBoxInternal.tsx | 112 +++++++++++--- .../hooks/useThreadCreationOptions.test.tsx | 25 +++- .../app/src/hooks/useThreadCreationOptions.ts | 52 ++++++- apps/app/src/views/RootComposeView.test.ts | 93 ++++++++++++ apps/app/src/views/RootComposeView.tsx | 4 +- .../root-compose-thread-environment.test.ts | 17 +++ 9 files changed, 408 insertions(+), 66 deletions(-) diff --git a/apps/app/src/components/promptbox/NewThreadComposer.tsx b/apps/app/src/components/promptbox/NewThreadComposer.tsx index 18a30f9e98..ed6aea2611 100644 --- a/apps/app/src/components/promptbox/NewThreadComposer.tsx +++ b/apps/app/src/components/promptbox/NewThreadComposer.tsx @@ -19,6 +19,7 @@ import type { NewThreadRequest } from "@get-bb/plugin-sdk"; import type { CreateExecutionInputSources, SidebarBootstrapResponse, + SystemExecutionOptionsModelLoadError, } from "@bb/server-contract"; import type { ProjectSelectorCreateProjectConfig } from "@/components/pickers/ProjectSelector"; import { @@ -26,6 +27,7 @@ import { encodeReuseValue, parseEnvironmentValue, } from "@/components/pickers/environment-picker-value"; +import { formatModelLoadErrorText } from "@/components/pickers/model-load-error-message"; import { NewThreadPromptBox, type NewThreadPromptBoxProps, @@ -115,7 +117,8 @@ export interface NewThreadComposerPromptOptions { zenModeStorageKey: string; banner?: ReactNode; header?: ReactNode; - externallyBlocked?: boolean; + /** When present, submission is blocked and this reason is shown on the submit button. */ + blockedReason?: string; resolveMentionLink?: PromptMentionLinkResolver; /** Override the host bound to this prompt box; omission uses this Composer's host. */ pluginComposerHost?: PluginComposerHost; @@ -185,6 +188,74 @@ type ProjectDefaultsState = | { status: "error" } | { status: "resolved"; defaults: ProjectExecutionDefaults | null }; +export interface ResolveNewThreadSubmitDisabledReasonArgs { + branchMutationBlockerTitle: string | null; + isCopyingAttachments: boolean; + isLoadingModels: boolean; + isSubmitting: boolean; + isUploading: boolean; + managedWorktreeUnavailableReason: string | null; + modelLoadError: SystemExecutionOptionsModelLoadError | null; + projectDefaultsStatus: ProjectDefaultsState["status"]; + projectDefaultsUnavailable: boolean; + promptInputEmpty: boolean; + providerDisplayName: string; + selectedProviderId: string; + selectedThreadModel: string; + submissionEnvironmentUnavailable: boolean; +} + +export function resolveNewThreadSubmitDisabledReason({ + branchMutationBlockerTitle, + isCopyingAttachments, + isLoadingModels, + isSubmitting, + isUploading, + managedWorktreeUnavailableReason, + modelLoadError, + projectDefaultsStatus, + projectDefaultsUnavailable, + promptInputEmpty, + providerDisplayName, + selectedProviderId, + selectedThreadModel, + submissionEnvironmentUnavailable, +}: ResolveNewThreadSubmitDisabledReasonArgs): string | null { + if (isSubmitting) return "Starting thread..."; + if (isCopyingAttachments) { + return "Moving attachments to the selected project..."; + } + if (isUploading) return "Uploading attachments..."; + if (projectDefaultsUnavailable) { + return projectDefaultsStatus === "error" + ? "Could not load the project's execution defaults." + : "Loading the project's execution defaults..."; + } + if (!selectedProviderId) return "Select a provider."; + if (isLoadingModels) { + return "Loading models from the selected machine..."; + } + + const fatalModelLoadError = + modelLoadError?.code === "provider_unavailable" || + modelLoadError?.code === "missing_executable" || + modelLoadError?.code === "auth_required"; + if (modelLoadError && (fatalModelLoadError || !selectedThreadModel)) { + return formatModelLoadErrorText({ + error: modelLoadError, + providerLabel: providerDisplayName || selectedProviderId, + }); + } + if (!selectedThreadModel) return "Select a model."; + if (submissionEnvironmentUnavailable) return "Select an environment."; + if (managedWorktreeUnavailableReason) { + return managedWorktreeUnavailableReason; + } + if (branchMutationBlockerTitle) return branchMutationBlockerTitle; + if (promptInputEmpty) return "Enter a prompt or attach a file."; + return null; +} + export function resolveNewThreadProjectDefaultsState({ cachedDefaults, projectFound, @@ -500,7 +571,6 @@ export function NewThreadComposer({ environmentSelectionValue, hasMultipleProviders, isLoadingModels, - isResolvingInitialProvider, modelLoadError, modelLoadFailed, modelOptions, @@ -512,6 +582,7 @@ export function NewThreadComposer({ reasoningOptions, selectedModel, selectedProviderComposerActions, + selectedProviderDisplayName, selectedProviderId, serviceTier, serviceTierSupportByProvider, @@ -631,10 +702,12 @@ export function NewThreadComposer({ const worktreeUnavailable = worktreeDisabledReason !== null; const requestsManagedWorktree = isHostMode && parsedEnvironment.mode === "worktree"; - const managedWorktreeAvailabilityPending = - requestsManagedWorktree && !isProjectless && branchesQuery.isLoading; const managedWorktreeUnavailable = requestsManagedWorktree && worktreeUnavailable; + // Branch data enriches the picker and can downgrade a confirmed non-Git or + // commitless source, but loading it is not a creation prerequisite. A + // default worktree request is resolved authoritatively by the server during + // thread creation, including another host.list_branches inspection. useEffect(() => { if ( !worktreeUnavailable || @@ -1003,39 +1076,40 @@ export function NewThreadComposer({ selectedEnvironment ?? (selectionScope === "new-thread" ? seed?.environment : undefined) ?? null; - const baseSubmitDisabled = - !selectedProviderId || - isLoadingModels || - isResolvingInitialProvider || - modelLoadError?.code === "provider_unavailable" || - modelLoadError?.code === "missing_executable" || - modelLoadError?.code === "auth_required" || - !selectedThreadModel || - isSubmitting || - isCopyingAttachments || - isUploading || - projectDefaultsUnavailable || - promptInput.length === 0 || - submissionEnvironment === null || - managedWorktreeAvailabilityPending || - managedWorktreeUnavailable || - (branchEnvironmentMode === "local" && - selectedBranch !== null && - branchUiState.mutationBlocker !== null); + const submitDisabledReason = resolveNewThreadSubmitDisabledReason({ + branchMutationBlockerTitle: + branchEnvironmentMode === "local" && selectedBranch !== null + ? (branchUiState.mutationBlocker?.title ?? null) + : null, + isCopyingAttachments, + isLoadingModels, + isSubmitting, + isUploading, + managedWorktreeUnavailableReason: managedWorktreeUnavailable + ? worktreeDisabledReason + : null, + modelLoadError, + projectDefaultsStatus: projectDefaultsState.status, + projectDefaultsUnavailable, + promptInputEmpty: promptInput.length === 0, + providerDisplayName: selectedProviderDisplayName, + selectedProviderId, + selectedThreadModel, + submissionEnvironmentUnavailable: submissionEnvironment === null, + }); const handleSubmit = useCallback( - async (externallyBlocked: boolean) => { + async (blockedReason: string | null) => { const submittedDraft = promptDraft.getCurrent(); const input = promptDraftToInput(submittedDraft); if ( - externallyBlocked || - baseSubmitDisabled || + blockedReason !== null || + submitDisabledReason !== null || input.length === 0 || isSubmittingRef.current || projectDefaultsUnavailable || submissionEnvironment === null || !selectedProviderId || !selectedThreadModel || - managedWorktreeAvailabilityPending || managedWorktreeUnavailable ) { return; @@ -1071,10 +1145,8 @@ export function NewThreadComposer({ } }, [ - baseSubmitDisabled, clearReuseEnvironment, executionInputSources, - managedWorktreeAvailabilityPending, managedWorktreeUnavailable, onSubmit, permissionMode, @@ -1083,6 +1155,7 @@ export function NewThreadComposer({ promptDraft, reasoningLevel, seededExecutionInputSources, + submitDisabledReason, submissionEnvironment, selectedProviderId, selectedThreadModel, @@ -1152,7 +1225,7 @@ export function NewThreadComposer({ const renderPromptBox = useCallback( (options: NewThreadComposerPromptOptions) => { const locks = options.locks ?? {}; - const externallyBlocked = options.externallyBlocked ?? false; + const disabledReason = options.blockedReason ?? submitDisabledReason; return ( void handleSubmit(externallyBlocked)} + onSubmit={() => void handleSubmit(options.blockedReason ?? null)} isSubmitting={isSubmitting} - disabled={baseSubmitDisabled || externallyBlocked} + disabled={disabledReason !== null} + disabledReason={disabledReason ?? undefined} placeholder={options.placeholder} autoFocus={options.autoFocus} pluginComposerHost={options.pluginComposerHost ?? pluginComposerHost} @@ -1319,7 +1393,6 @@ export function NewThreadComposer({ [ activeModel, attachmentError, - baseSubmitDisabled, branchEnvironmentMode, branchOptions, branchUiState, @@ -1379,6 +1452,7 @@ export function NewThreadComposer({ sidebarNavigationSettled, supportsPermissionModeSelection, supportsServiceTier, + submitDisabledReason, textEffects, worktreeDisabledReason, worktreeUnavailable, diff --git a/apps/app/src/components/promptbox/NewThreadPromptBox.tsx b/apps/app/src/components/promptbox/NewThreadPromptBox.tsx index 7b2e6bbf9d..ee8178dda0 100644 --- a/apps/app/src/components/promptbox/NewThreadPromptBox.tsx +++ b/apps/app/src/components/promptbox/NewThreadPromptBox.tsx @@ -176,6 +176,8 @@ export interface NewThreadPromptBoxUIProps { promptBoxRef?: Ref; isSubmitting: boolean; disabled: boolean; + /** Explains a disabled submit action on hover and to assistive technology. */ + disabledReason?: string; /** Whether the editor should take passive focus when it mounts. */ autoFocus?: boolean; /** Active root-composer binding for plugin composer hooks and customizations. */ @@ -231,6 +233,7 @@ export const NewThreadPromptBoxUI = memo(function NewThreadPromptBoxUI({ promptBoxRef: externalPromptBoxRef, isSubmitting, disabled, + disabledReason, autoFocus, pluginComposerHost, textEffects, @@ -302,6 +305,7 @@ export const NewThreadPromptBoxUI = memo(function NewThreadPromptBoxUI({ promptBoxRef={promptBoxRef} isSubmitting={isSubmitting} disabled={disabled} + disabledReason={disabledReason} autoFocus={autoFocus} textEffects={textEffects} zenModeStorageKey={zenModeStorageKey} @@ -340,6 +344,7 @@ export const DefaultNewThreadComposer = memo(function DefaultNewThreadComposer({ promptBoxRef, isSubmitting, disabled, + disabledReason, autoFocus, textEffects, zenModeStorageKey, @@ -407,6 +412,7 @@ export const DefaultNewThreadComposer = memo(function DefaultNewThreadComposer({ submission={{ isSubmitting, disabled, + disabledReason, title: submitTitle, }} autoFocus={autoFocus} diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx index 4fa3b27999..8c6bbd0d0b 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx @@ -1195,6 +1195,31 @@ describe("PromptBoxInternal controlled value sync", () => { }); describe("PromptBoxInternal submit shortcuts", () => { + it("exposes the disabled submit reason as its label and hover tooltip", async () => { + const reason = "Loading models from the selected machine..."; + render( + , + ); + + const submit = screen.getByRole("button", { name: reason }); + expect(submit.hasAttribute("disabled")).toBe(true); + + const tooltipTrigger = submit.closest( + "[data-promptbox-submit-disabled-reason]", + ); + expect(tooltipTrigger).not.toBeNull(); + fireEvent.pointerMove(tooltipTrigger!, { pointerType: "mouse" }); + + await waitFor(() => { + expect(screen.getByRole("tooltip").textContent).toBe(reason); + }); + }); + it("continues to submit unmodified Enter on a fine-pointer device", () => { const restoreMatchMedia = mockPointerCoarse(false); try { diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.tsx index 19a897ee36..6f0a874a41 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.tsx @@ -44,6 +44,12 @@ import { findActiveTrigger } from "@/components/promptbox/mentions/find-active-t import { canLoadMoreCommandResults } from "@/components/promptbox/mentions/mention-menu-scroll"; import { Button } from "@bb/shared-ui/button"; import { Icon } from "@bb/shared-ui/icon"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@bb/shared-ui/tooltip"; import { ComposerActionsSlot } from "@/components/plugin/PluginComposerActions"; import { useResolvedComposerEditor } from "@/components/plugin/composer-slot-hooks"; import { @@ -211,12 +217,78 @@ function shouldFinishVoiceCompletionTransitionImmediately(): boolean { export interface PromptBoxSubmissionConfig { isSubmitting?: boolean; disabled?: boolean; + /** Explains why submission is disabled. Shown on hover and used as the action's accessible label. */ + disabledReason?: string; title?: string; isRunning?: boolean; onStop?: () => void; onModifierSubmit?: () => void; } +interface PromptSubmitButtonProps { + canSubmit: boolean; + className: string; + disabledReason: string | undefined; + isCompact: boolean; + isSubmitting: boolean; + isZenMode: boolean; + onClick: (event: ReactMouseEvent) => void; + onPointerDown: (event: ReactPointerEvent) => void; + title: string; +} + +function PromptSubmitButton({ + canSubmit, + className, + disabledReason, + isCompact, + isSubmitting, + isZenMode, + onClick, + onPointerDown, + title, +}: PromptSubmitButtonProps) { + const button = ( + + ); + + if (!disabledReason) return button; + + return ( + + + + + {button} + + + {disabledReason} + + + ); +} + /** * The `@`-mention half of {@link TypeaheadConfig}. Unchanged from the prior * `MentionsConfig` surface other than living under `typeahead.mention`. @@ -1175,6 +1247,7 @@ export function PromptBoxInternal({ const { isSubmitting = false, disabled: submitDisabled = false, + disabledReason: submitDisabledReason, title: submitTitle = "Submit (Enter)", isRunning = false, onStop, @@ -2679,9 +2752,13 @@ export function PromptBoxInternal({ setVoiceActionTransition("exiting"); voice?.cancel(); }, [voice]); - const effectiveSubmitTitle = isZenMode + const actionSubmitTitle = isZenMode ? submitTitle.replace(/^Submit\s+/, "") : submitTitle; + const effectiveSubmitTitle = + !canSubmit && submitDisabledReason + ? submitDisabledReason + : actionSubmitTitle; const emitAttachmentFiles = useCallback( (files: File[]) => { @@ -3493,15 +3570,8 @@ export function PromptBoxInternal({ ) : ( - + disabledReason={ + !canSubmit ? submitDisabledReason : undefined + } + isCompact={showCompactLayout} + isSubmitting={isSubmitting} + isZenMode={isZenMode} + onPointerDown={handleSubmitPointerDown} + onClick={handleSubmitClick} + title={effectiveSubmitTitle} + /> )}
diff --git a/apps/app/src/hooks/useThreadCreationOptions.test.tsx b/apps/app/src/hooks/useThreadCreationOptions.test.tsx index 5f0c7dbf03..a084202093 100644 --- a/apps/app/src/hooks/useThreadCreationOptions.test.tsx +++ b/apps/app/src/hooks/useThreadCreationOptions.test.tsx @@ -1049,7 +1049,7 @@ describe("useThreadCreationOptions", () => { }); }); - it("uses the connected provider from the selected machine as create provenance", async () => { + it("latches the initial connected provider instead of resolving it again after a machine switch", async () => { window.localStorage.setItem( "bb.promptbox.environment", "host:remote-host:local", @@ -1076,12 +1076,33 @@ describe("useThreadCreationOptions", () => { signal: expect.any(AbortSignal), }); expect(result.current.selectedProviderId).toBe(PROJECT_PROVIDER_ID); - expect(result.current.isResolvingInitialProvider).toBe(false); expect(result.current.executionInputSources).toMatchObject({ providerId: "client-preference", }); expect(result.current.executionInputSources.model).toBeUndefined(); }); + const initialDiscoveryCallCount = vi.mocked(sdk.system.onboardingAgents) + .mock.calls.length; + + act(() => { + result.current.setEnvironmentSelectionValue("host:second-host:local"); + }); + + await waitFor(() => { + expect(sdk.system.executionOptions).toHaveBeenCalledWith( + expect.objectContaining({ + hostId: "second-host", + providerId: PROJECT_PROVIDER_ID, + }), + ); + expect(result.current.selectedProviderId).toBe(PROJECT_PROVIDER_ID); + }); + expect(sdk.system.onboardingAgents).toHaveBeenCalledTimes( + initialDiscoveryCallCount, + ); + expect(sdk.system.onboardingAgents).not.toHaveBeenCalledWith( + expect.objectContaining({ hostId: "second-host" }), + ); }); it("routes reusable root-composer worktrees through their environment", async () => { diff --git a/apps/app/src/hooks/useThreadCreationOptions.ts b/apps/app/src/hooks/useThreadCreationOptions.ts index 5849e70bb7..1b743e7ea3 100644 --- a/apps/app/src/hooks/useThreadCreationOptions.ts +++ b/apps/app/src/hooks/useThreadCreationOptions.ts @@ -1,4 +1,11 @@ -import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; import type { AvailableModel, PermissionMode, @@ -109,7 +116,6 @@ export interface UseThreadCreationOptionsResult { modelOptions: ModelPickerOption[]; moreModelOptions: ModelPickerOption[]; isLoadingModels: boolean; - isResolvingInitialProvider: boolean; modelLoadFailed: boolean; modelLoadError: SystemExecutionOptionsModelLoadError | null; reasoningOptions: PickerOption[]; @@ -164,6 +170,10 @@ export function resolveThreadCreationProviderRouting({ const NO_MODEL_LOAD_ERROR: SystemExecutionOptionsModelLoadError | null = null; +type InitialConnectedProviderResolution = + | { status: "unresolved" } + | { status: "resolved"; providerId: string | null }; + function sanitizeStoredEnvironmentValue(stored: string): string { // Legacy guard: earlier iterations briefly persisted `reuse:` to // localStorage. Treat any persisted reuse value as absent so the picker @@ -230,6 +240,8 @@ export function useThreadCreationOptions( initialServiceTier, }), ); + const [initialConnectedProvider, setInitialConnectedProvider] = + useState({ status: "unresolved" }); const localProviderSelectionsRef = useRef< Map >(new Map()); @@ -309,20 +321,47 @@ export function useThreadCreationOptions( providerId: selectedProviderIdBeforeConnectedFallback, scope, }); - const shouldResolveConnectedProvider = + const canResolveConnectedProvider = executionOptionsQueryEnabled && scope === "new-thread" && preferConnectedProviderWhenUnset && selectedProviderIdBeforeConnectedFallback.length === 0; + const shouldResolveConnectedProvider = + canResolveConnectedProvider && + initialConnectedProvider.status === "unresolved"; const connectedAgentsQuery = useOnboardingAgents({ enabled: shouldResolveConnectedProvider, ...executionOptionsRouting, }); - const connectedProviderId = shouldResolveConnectedProvider + const queriedConnectedProviderId = shouldResolveConnectedProvider ? connectedAgentsQuery.data?.agents.find( (agent) => agent.status === "connected", )?.providerId : undefined; + const connectedProviderId = + initialConnectedProvider.status === "resolved" + ? (initialConnectedProvider.providerId ?? undefined) + : queriedConnectedProviderId; + // This is an initial default, not a host-scoped live selection. Once the + // first routed probe settles, retain its answer so changing machines does + // not silently reselect the provider or wait on onboarding health checks. + useEffect(() => { + if (!shouldResolveConnectedProvider || connectedAgentsQuery.isPending) { + return; + } + setInitialConnectedProvider((current) => + current.status === "resolved" + ? current + : { + status: "resolved", + providerId: queriedConnectedProviderId ?? null, + }, + ); + }, [ + connectedAgentsQuery.isPending, + queriedConnectedProviderId, + shouldResolveConnectedProvider, + ]); const rawSelectedProviderId = selectedProviderIdBeforeConnectedFallback || connectedProviderId || ""; // Omission delegates the no-selection fallback to the server, whose product @@ -343,8 +382,6 @@ export function useThreadCreationOptions( (executionOptionsQuery.isLoading || (executionOptionsQuery.isPlaceholderData && (executionOptionsQuery.data?.models.length ?? 0) === 0)); - const isResolvingInitialProvider = - shouldResolveConnectedProvider && connectedAgentsQuery.isPending; const modelLoadError = executionOptionsQuery.data?.modelLoadError ?? NO_MODEL_LOAD_ERROR; const modelLoadFailed = @@ -654,7 +691,7 @@ export function useThreadCreationOptions( const touchedFieldsPendingReset = usesLocalThreadSelections && threadResetKeyRef.current !== resetKey; const effectiveInitialProviderSource: ExecutionInputFieldSource | undefined = - shouldResolveConnectedProvider && + canResolveConnectedProvider && connectedProviderId !== undefined && effectiveProviderId === connectedProviderId ? "client-preference" @@ -989,7 +1026,6 @@ export function useThreadCreationOptions( modelOptions, moreModelOptions, isLoadingModels, - isResolvingInitialProvider, modelLoadFailed, modelLoadError, reasoningOptions, diff --git a/apps/app/src/views/RootComposeView.test.ts b/apps/app/src/views/RootComposeView.test.ts index ee28b82aec..a4e6f3197a 100644 --- a/apps/app/src/views/RootComposeView.test.ts +++ b/apps/app/src/views/RootComposeView.test.ts @@ -17,7 +17,9 @@ import { hasPromptOptionValueChanged, mergeMissingPromptDraftAttachments, resolveNewThreadProjectDefaultsState, + resolveNewThreadSubmitDisabledReason, restorePromptDraftAfterOptionChange, + type ResolveNewThreadSubmitDisabledReasonArgs, } from "@/components/promptbox/NewThreadComposer"; import { subscribeComposerFocusRequests } from "@/lib/composer-focus-requests"; import { getProjectStoredPromptAttachmentPaths } from "@/lib/prompt-draft"; @@ -143,6 +145,97 @@ describe("resolveNewThreadProjectDefaultsState", () => { }); }); +describe("resolveNewThreadSubmitDisabledReason", () => { + const readyState = { + branchMutationBlockerTitle: null, + isCopyingAttachments: false, + isLoadingModels: false, + isSubmitting: false, + isUploading: false, + managedWorktreeUnavailableReason: null, + modelLoadError: null, + projectDefaultsStatus: "resolved", + projectDefaultsUnavailable: false, + promptInputEmpty: false, + providerDisplayName: "Codex", + selectedProviderId: "codex", + selectedThreadModel: "gpt-5.6-sol", + submissionEnvironmentUnavailable: false, + } satisfies ResolveNewThreadSubmitDisabledReasonArgs; + + it.each< + [ + label: string, + change: Partial, + reason: string, + ] + >([ + [ + "model loading after a machine switch", + { isLoadingModels: true }, + "Loading models from the selected machine...", + ], + [ + "provider setup failure", + { + modelLoadError: { + providerId: "codex", + code: "auth_required", + }, + }, + "Could not load models for Codex. Authentication is required.", + ], + [ + "project-default failure", + { + projectDefaultsStatus: "error", + projectDefaultsUnavailable: true, + }, + "Could not load the project's execution defaults.", + ], + [ + "an incomplete environment selection", + { submissionEnvironmentUnavailable: true }, + "Select an environment.", + ], + [ + "an unavailable worktree", + { + managedWorktreeUnavailableReason: + "Project source has no commits. Create an initial commit before creating a worktree", + }, + "Project source has no commits. Create an initial commit before creating a worktree", + ], + [ + "a blocked branch checkout", + { branchMutationBlockerTitle: "Checkout blocked by uncommitted changes" }, + "Checkout blocked by uncommitted changes", + ], + [ + "an empty prompt", + { promptInputEmpty: true }, + "Enter a prompt or attach a file.", + ], + ])("reports %s", (_label, change, reason) => { + expect( + resolveNewThreadSubmitDisabledReason({ ...readyState, ...change }), + ).toBe(reason); + }); + + it("returns no reason when every submission requirement is ready", () => { + expect(resolveNewThreadSubmitDisabledReason(readyState)).toBeNull(); + }); + + it("allows a selected fallback model after a transient model-list failure", () => { + expect( + resolveNewThreadSubmitDisabledReason({ + ...readyState, + modelLoadError: { providerId: "claude-code", code: "timeout" }, + }), + ).toBeNull(); + }); +}); + interface MakeThreadArgs { id: string; projectId: string; diff --git a/apps/app/src/views/RootComposeView.tsx b/apps/app/src/views/RootComposeView.tsx index 78db6a2a44..b14aa6227a 100644 --- a/apps/app/src/views/RootComposeView.tsx +++ b/apps/app/src/views/RootComposeView.tsx @@ -2303,7 +2303,9 @@ function RootComposeSurface({ ), banner: promptBanner, header: promptHeader, - externallyBlocked: isCodexCliVersionBlocked, + blockedReason: isCodexCliVersionBlocked + ? "Update the Codex CLI before starting a thread." + : undefined, resolveMentionLink, pluginComposerHost, textEffects: promptTextEffects, diff --git a/apps/app/src/views/root-compose-thread-environment.test.ts b/apps/app/src/views/root-compose-thread-environment.test.ts index c8ee18b55a..c78fed84bb 100644 --- a/apps/app/src/views/root-compose-thread-environment.test.ts +++ b/apps/app/src/views/root-compose-thread-environment.test.ts @@ -87,6 +87,23 @@ describe("resolveRootComposeThreadEnvironment", () => { }); }); + it("can submit the server-resolved default while branch metadata is still loading", () => { + expect( + resolveRootComposeThreadEnvironment({ + defaultBranch: undefined, + defaultWorktreeBaseBranch: undefined, + environmentValue: hostWorktreeEnvironmentValue, + projectId, + selectedBranch: null, + }), + ).toMatchObject({ + workspace: { + type: "managed-worktree", + baseBranch: { kind: "default" }, + }, + }); + }); + it("sends smart remote default base branch for managed worktrees without an explicit pick", () => { expect( resolveRootComposeThreadEnvironment({ From 05cc16d140e40c2e901de1046b07738fff89eb9f Mon Sep 17 00:00:00 2001 From: Michael Yong <610102+ymichael@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:26:19 -0700 Subject: [PATCH 013/232] Show edit actions promptly after thread submit (#2016) ## What was wrong Two async transitions could suppress Edit after submitting a new thread. Provider capability gating waited on the full execution-options/model-discovery path, and navigation could briefly lose the provider facts already loaded by the composer. More importantly, the timeline controller preserved row identity using only the row ID and source-sequence range. The server projects `turnRequest.status` from `pending` to `accepted` onto that same message row without extending its sequence range, so the merge retained the stale pending object indefinitely. Edit requires an accepted message; refresh rebuilt the timeline directly from the accepted server row and made the icon appear. ## What changed The thread detail view now reads capabilities from the lightweight, environment-routed provider roster and reuses composer-warmed provider facts while that roster loads. The timeline merge also includes turn-request fields in its identity signature, so an accepted server projection replaces the pending row instead of being discarded as unchanged. Regression coverage exercises both the post-submit provider fallback and the pending-to-accepted row transition. There are no wire, CLI, guide, or protocol changes. ## How you verified - Added a timeline-merge regression test that fails before the fix by retaining `pending` and passes after the accepted row replaces it. - Reproduced the exact flow in the browser: new thread, submit, navigate to the active thread, and confirmed Edit appears without refresh while the Stop run control is still present. - `pnpm exec turbo run test --filter=@bb/client-core --force` (238 tests) - `pnpm exec turbo run test --filter=@bb/app --force -- --run src/hooks/queries/system-queries.test.tsx` (17 tests) - `pnpm exec turbo run typecheck --filter=@bb/client-core --filter=@bb/app` - `pnpm exec turbo run lint --filter=@bb/client-core --filter=@bb/app` (0 errors; existing warnings remain) - `pnpm exec prettier --check` on the changed source files Fixes: delayed edit-action visibility (no linked issue). > AGENT GENERATED: by GPT-5 --- .../src/hooks/queries/system-queries.test.tsx | 93 +++++++++++++++++++ apps/app/src/hooks/queries/system-queries.ts | 85 ++++++++++------- .../queries/useCachedProviderInfo.test.tsx | 57 ------------ .../views/thread-detail/ThreadDetailView.tsx | 22 +++-- .../src/timeline/timeline-merge.ts | 8 ++ .../client-core/test/timeline-merge.test.ts | 29 +++++- 6 files changed, 198 insertions(+), 96 deletions(-) delete mode 100644 apps/app/src/hooks/queries/useCachedProviderInfo.test.tsx diff --git a/apps/app/src/hooks/queries/system-queries.test.tsx b/apps/app/src/hooks/queries/system-queries.test.tsx index 62ce2fe6c0..74884b2b23 100644 --- a/apps/app/src/hooks/queries/system-queries.test.tsx +++ b/apps/app/src/hooks/queries/system-queries.test.tsx @@ -25,6 +25,7 @@ import { useHostProviderCliStatus, useOnboardingAgents, useSystemExecutionOptions, + useSystemProviderInfo, useSystemUsageLimits, } from "./system-queries"; @@ -32,6 +33,7 @@ vi.mock("@/lib/sdk", () => ({ BbHttpError: class BbHttpError extends Error {}, sdk: { hosts: { providerCliStatus: vi.fn() }, + providers: { list: vi.fn() }, system: { executionOptions: vi.fn(), onboardingAgents: vi.fn(), @@ -78,6 +80,97 @@ afterEach(() => { window.localStorage.clear(); }); +describe("useSystemProviderInfo", () => { + it("uses capabilities already loaded by the composer while the provider roster loads", async () => { + const provider: ProviderInfo = { + id: "codex", + displayName: "Codex", + logoUrl: null, + available: true, + composerActions: [], + capabilities: { + supportsThreadArchive: true, + supportsThreadRename: true, + supportsServiceTier: true, + supportsNativeUserQuestion: false, + supportsFork: true, + supportsSessionRewind: true, + permissionModes: ["accept-edits", "auto", "full"], + }, + }; + vi.mocked(sdk.providers.list).mockImplementation( + () => new Promise(() => undefined), + ); + const { queryClient, wrapper } = createQueryClientTestHarness(); + queryClient.setQueryData( + systemExecutionOptionsQueryKey({ + environmentId: "env-remote", + hostId: null, + providerId: "codex", + }), + { ...EXECUTION_OPTIONS_RESPONSE, providers: [provider] }, + ); + + const { result } = renderHook( + () => + useSystemProviderInfo({ + environmentId: "env-remote", + providerId: "codex", + }), + { wrapper }, + ); + + expect(result.current).toBe(provider); + await waitFor(() => { + expect(sdk.providers.list).toHaveBeenCalledOnce(); + }); + }); + + it("loads routed provider capabilities without waiting for model discovery", async () => { + const providers: ProviderInfo[] = [ + { + id: "codex", + displayName: "Codex", + logoUrl: null, + available: true, + composerActions: [], + capabilities: { + supportsThreadArchive: true, + supportsThreadRename: true, + supportsServiceTier: true, + supportsNativeUserQuestion: false, + supportsFork: true, + supportsSessionRewind: true, + permissionModes: ["accept-edits", "auto", "full"], + }, + }, + ]; + vi.mocked(sdk.providers.list).mockResolvedValue(providers); + vi.mocked(sdk.system.executionOptions).mockImplementation( + () => new Promise(() => undefined), + ); + const { wrapper } = createQueryClientTestHarness(); + + const { result } = renderHook( + () => + useSystemProviderInfo({ + environmentId: "env-remote", + providerId: "codex", + }), + { wrapper }, + ); + + await waitFor(() => { + expect(result.current?.capabilities.supportsSessionRewind).toBe(true); + }); + expect(sdk.providers.list).toHaveBeenCalledWith({ + environmentId: "env-remote", + signal: expect.any(AbortSignal), + }); + expect(sdk.system.executionOptions).not.toHaveBeenCalled(); + }); +}); + describe("useSystemExecutionOptions", () => { it("preloads built-in provider identities while their models are loading", () => { vi.mocked(sdk.system.executionOptions).mockImplementation( diff --git a/apps/app/src/hooks/queries/system-queries.ts b/apps/app/src/hooks/queries/system-queries.ts index 5a060c7d8d..b50db6f2d3 100644 --- a/apps/app/src/hooks/queries/system-queries.ts +++ b/apps/app/src/hooks/queries/system-queries.ts @@ -1,4 +1,3 @@ -import { useCallback, useSyncExternalStore } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import type { QueryKey } from "@tanstack/react-query"; import type { AvailableModel, PermissionMode, ProviderInfo } from "@bb/domain"; @@ -69,6 +68,17 @@ interface QueryOptions { enabled?: boolean; } +type SystemProviderRoutingArgs = + | { environmentId: string; hostId?: never } + | { environmentId?: never; hostId: string } + | { environmentId?: never; hostId?: never }; + +export type UseSystemProvidersArgs = QueryOptions & SystemProviderRoutingArgs; + +export type UseSystemProviderInfoArgs = UseSystemProvidersArgs & { + providerId?: string; +}; + const SYSTEM_EXECUTION_OPTIONS_RETRY_DELAY_MS = 250; const SYSTEM_EXECUTION_OPTIONS_RETRY_COUNT = 1; const CLAUDE_CODE_PROVIDER_ID = "claude-code"; @@ -346,33 +356,6 @@ export function findCachedProviderInfo( return null; } -/** - * Reactive form of {@link findCachedProviderInfo}. The cache read alone is a - * render-time snapshot, and a component that does not mount the - * execution-options query itself never re-renders when that query lands — so a - * capability-gated affordance would stay hidden until some unrelated query - * happened to re-render the tree. Subscribing to the query cache makes it - * appear as soon as the data arrives, without mounting a second request. - */ -export function useCachedProviderInfo( - providerId: string | undefined, -): ProviderInfo | null { - const queryClient = useQueryClient(); - const subscribe = useCallback( - (onStoreChange: () => void) => - queryClient.getQueryCache().subscribe(onStoreChange), - [queryClient], - ); - const getSnapshot = useCallback( - () => - providerId === undefined - ? null - : findCachedProviderInfo(queryClient, providerId), - [providerId, queryClient], - ); - return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); -} - function isAbortLikeError(error: unknown): boolean { return toRecord(error)?.name === "AbortError"; } @@ -399,19 +382,57 @@ function shouldRetrySystemExecutionOptions( /** * The provider roster with the server's display names. Cheaper than the full * execution-options query (no model probe), which is what surfaces that only - * need to name a provider — the skills library's provider filter — should use. + * need provider metadata or capabilities should use. */ -export function useSystemProviders(args: { enabled?: boolean } = {}) { +export function useSystemProviders(args: UseSystemProvidersArgs = {}) { + const environmentId = args.environmentId ?? null; + const hostId = args.hostId ?? null; const enabled = args.enabled ?? true; useSystemRealtimeSubscription({ enabled }); return useQuery({ - queryKey: systemProvidersQueryKey(), - queryFn: ({ signal }) => sdk.providers.list({ signal }), + queryKey: systemProvidersQueryKey({ environmentId, hostId }), + queryFn: ({ signal }) => { + if (args.environmentId !== undefined) { + return sdk.providers.list({ + environmentId: args.environmentId, + signal, + }); + } + if (args.hostId !== undefined) { + return sdk.providers.list({ hostId: args.hostId, signal }); + } + return sdk.providers.list({ signal }); + }, enabled, staleTime: 60_000, }); } +/** + * Resolve one provider from the lightweight provider roster. Unlike the full + * execution-options request, this does not wait for model discovery, so + * capability-gated controls can render as soon as provider metadata arrives. + * A just-submitted composer has already loaded the same provider facts through + * execution options, so reuse that warm cache synchronously during navigation + * while the lightweight roster fills its own route-scoped cache. + */ +export function useSystemProviderInfo({ + providerId, + ...args +}: UseSystemProviderInfoArgs): ProviderInfo | null { + const queryClient = useQueryClient(); + const providersQuery = useSystemProviders({ + ...args, + enabled: (args.enabled ?? true) && providerId !== undefined, + }); + return ( + providersQuery.data?.find((provider) => provider.id === providerId) ?? + (providerId === undefined + ? null + : findCachedProviderInfo(queryClient, providerId)) + ); +} + export function useSystemExecutionOptions( args: UseSystemExecutionOptionsArgs = {}, ) { diff --git a/apps/app/src/hooks/queries/useCachedProviderInfo.test.tsx b/apps/app/src/hooks/queries/useCachedProviderInfo.test.tsx deleted file mode 100644 index 827daf009a..0000000000 --- a/apps/app/src/hooks/queries/useCachedProviderInfo.test.tsx +++ /dev/null @@ -1,57 +0,0 @@ -// @vitest-environment jsdom - -import type { ReactNode } from "react"; -import { act, cleanup, renderHook } from "@testing-library/react"; -import { - QueryClient, - QueryClientProvider, - useQueryClient, -} from "@tanstack/react-query"; -import { afterEach, expect, it } from "vitest"; -import { SYSTEM_EXECUTION_OPTIONS_QUERY_KEY } from "@/hooks/queries/query-keys"; -import { useCachedProviderInfo } from "./system-queries"; - -afterEach(() => { - cleanup(); -}); - -/** - * The views that gate fork/edit affordances on a provider capability do not - * mount the execution-options query themselves — a composer child does. A - * render-time cache read therefore stays stale (affordance missing) until some - * unrelated query happens to re-render them, which is the bug this hook exists - * to prevent. - */ -it("re-renders when the execution-options cache lands after mount", () => { - const queryClient = new QueryClient(); - const wrapper = ({ children }: { children: ReactNode }) => ( - {children} - ); - - const { result } = renderHook( - () => ({ - info: useCachedProviderInfo("codex"), - client: useQueryClient(), - }), - { wrapper }, - ); - - expect(result.current.info).toBeNull(); - - act(() => { - queryClient.setQueryData( - [SYSTEM_EXECUTION_OPTIONS_QUERY_KEY, { environmentId: "env-1" }], - { - providers: [ - { id: "codex", capabilities: { supportsSessionRewind: true } }, - ], - models: [], - selectedOnlyModels: [], - permissionCeiling: "full", - modelLoadError: null, - }, - ); - }); - - expect(result.current.info?.capabilities.supportsSessionRewind).toBe(true); -}); diff --git a/apps/app/src/views/thread-detail/ThreadDetailView.tsx b/apps/app/src/views/thread-detail/ThreadDetailView.tsx index f08886f1fd..d01bc4e2c8 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailView.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailView.tsx @@ -7,7 +7,7 @@ import { type ReactNode, } from "react"; import { createSentMessageEditOperationId } from "./sent-message-edit-operation-id"; -import { useCachedProviderInfo } from "@/hooks/queries/system-queries"; +import { useSystemProviderInfo } from "@/hooks/queries/system-queries"; import { useNavigate } from "react-router-dom"; import { useAtom } from "jotai"; import { atomWithStorage } from "jotai/utils"; @@ -973,11 +973,21 @@ function ThreadDetailViewInternal(props: ThreadDetailViewInternalProps) { }, [forkThreadFromMessage], ); - // Subscribed, not a bare cache read: this view never mounts the - // execution-options query itself (its composer child does), so a render-time - // snapshot would leave capability-gated affordances hidden until an - // unrelated query re-rendered the tree. - const threadProviderInfo = useCachedProviderInfo(thread?.providerId); + // Provider capabilities do not depend on model discovery. Load them from the + // lightweight provider roster so fork/edit affordances are not held behind + // the composer's slower execution-options probe. + const threadProviderInfo = useSystemProviderInfo( + thread?.environmentId + ? { + enabled: true, + environmentId: thread.environmentId, + providerId: thread.providerId, + } + : { + enabled: thread !== undefined, + providerId: thread?.providerId, + }, + ); const isForkAvailable = isThreadForkable( thread ?? null, threadProviderInfo?.capabilities.supportsFork ?? false, diff --git a/packages/client-core/src/timeline/timeline-merge.ts b/packages/client-core/src/timeline/timeline-merge.ts index c75e766700..2c072279ce 100644 --- a/packages/client-core/src/timeline/timeline-merge.ts +++ b/packages/client-core/src/timeline/timeline-merge.ts @@ -154,6 +154,8 @@ function appendTimelineRowsPreservingOrder( } function timelineRowIdentitySignature(row: TimelineRow): string { + const turnRequest = + row.kind === "conversation" && row.role === "user" ? row.turnRequest : null; return [ row.kind, row.id, @@ -163,6 +165,12 @@ function timelineRowIdentitySignature(row: TimelineRow): string { row.sourceSeqEnd, row.startedAt, row.createdAt, + // Acceptance is projected onto the original message row without extending + // its source sequence range. Include the request fields so a refetch swaps + // a pending row for the accepted one instead of preserving stale identity. + turnRequest?.isGrouped, + turnRequest?.kind, + turnRequest?.status, ].join("\u001f"); } diff --git a/packages/client-core/test/timeline-merge.test.ts b/packages/client-core/test/timeline-merge.test.ts index dad13085d2..353da9243a 100644 --- a/packages/client-core/test/timeline-merge.test.ts +++ b/packages/client-core/test/timeline-merge.test.ts @@ -19,6 +19,7 @@ interface TimelineTestRowArgs { endSequence?: number; id: string; sequence: number; + turnRequestStatus?: "accepted" | "pending" | "rejected"; } interface TimelineTurnTestRowArgs extends TimelineTestRowArgs { @@ -51,7 +52,11 @@ function userRow(args: TimelineTestRowArgs): TimelineUserConversationRow { text: args.id, mentions: [], attachments: null, - turnRequest: { isGrouped: false, kind: "message", status: "accepted" }, + turnRequest: { + isGrouped: false, + kind: "message", + status: args.turnRequestStatus ?? "accepted", + }, }; } @@ -363,6 +368,28 @@ describe("timeline page row merging", () => { expect(merge.rows[1]).toBe(updatedTail); }); + it("replaces a pending message row when the server accepts it", () => { + const pendingMessage = userRow({ + id: "submitted-message", + sequence: 1, + turnRequestStatus: "pending", + }); + const acceptedMessage = userRow({ + id: "submitted-message", + sequence: 1, + turnRequestStatus: "accepted", + }); + + const merge = mergeLatestTimelineRows({ + latestWindowStartSequence: 0, + loadedRows: [pendingMessage], + latestRows: [acceptedMessage], + }); + + expect(merge.rows).toEqual([acceptedMessage]); + expect(merge.rows[0]).toBe(acceptedMessage); + }); + it("rebuilds when latest advances past the loaded rows with a gap between", () => { const oldestCursor = timelineCursor({ id: "oldest", sequence: 1 }); const latestCursor = timelineCursor({ id: "latest-page", sequence: 40 }); From 6c62596e6dcefccc52494b965b2d7962ba5f2d35 Mon Sep 17 00:00:00 2001 From: Michael Yong <610102+ymichael@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:51:54 -0700 Subject: [PATCH 014/232] Report pi and ACP turn acceptance on consumption (#2021) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What was wrong [#2013](https://github.com/get-bb/bb/issues/2013) established the uniform rule that `input.accepted` means the provider consumed the input, never that bb queued it, because an acceptance still pending when a stale terminal arrives lets that terminal claim the input and complete an empty turn for a message the provider has not answered. Pi and ACP still emitted acceptance at dispatch. Pi's exposure is not just theoretical timing. `PiSdkSession.prompt()` resolves as soon as pi queues a prompt that arrives while a run is still unwinding, and the bridge reported that resolution as `pi/prompt/settled` — a `claimIfIdle` turn terminal. So a `turn/start` pi merely queued produced acceptance plus a terminal in the same tick, which the assembler turned into a started-and-completed empty turn while the real answer ran later under an unaccepted turn. ACP emitted acceptance in the `turn/start` handler before the turn opened, and for a steer it emitted acceptance at queue time even though the queued input is dropped whenever the turn fails or the session stops — reporting input the agent was never given as accepted into the turn. ## What changed - `PiSdkSession` tracks pending input consumption for both of pi's queues instead of steering only, and resolves it from pi's preflight hook (the input entered a run) or from the queue update that delivers a queued message. Its `prompt()` now returns that consumption signal alongside the settlement of the run it started, and reports no settlement for input pi queued into a run it did not start. - The pi bridge answers `turn/start` and emits `input.accepted` only once pi read the input. - The ACP bridge carries the waiting command with the input and emits `input.accepted` once the `session/prompt` request carrying it goes out, so the acceptance names the open turn and a dropped steer is never accepted. Every turn input still leaves with exactly one reply ([#853](https://github.com/get-bb/bb/issues/853)). - `HOST_DAEMON_PROTOCOL_VERSION` 140 to 141: older daemons emit the queue-time semantics and produce those phantom turns. Two deviations from the issue's proposed fix: 1. The issue states pi's steer path "already waits for actual SDK acceptance." It does not — `PiSdkSession.steer()` resolved once the SDK took the message into its queue, the same queue-time violation as `turn/start`. 2. Both steer paths deliberately keep answering their command at queue time, because the runtime fails a bridge request that goes unanswered for 30 seconds (`sendJsonRpcRequest`). Pi delivers steering only between assistant turns, so a steer sent during a long tool call would time out; ACP delivers a steer only when the cancelled prompt is reissued. Neither can manufacture a turn: a steer's acceptance lands in a turn the assembler already holds open, and the [#2013](https://github.com/get-bb/bb/issues/2013) failure mode needs a *pending* acceptance. Pi keeps reporting a steer its run never read through the session error path. There are no CLI, guide, configuration, or user-facing documentation changes. ## How you verified - New pi regression: a `turn/start` pi queues behind a live run emits no turn events until the queue delivers it, then the acceptance lands in the turn pi opened. Before the change it received `turn/started` + `turn/input/accepted` + `turn/completed` — the phantom turn. - New ACP regressions: acceptance is emitted immediately after the turn opens rather than before it, and a steer dropped by `thread/stop` leaves the turn with one accepted input instead of two. Both fail before, pass after. - New `PiSdkSession` coverage for queued-versus-direct dispatch, and for a queued follow-up surviving the `agent_end` that continues into it. - `pnpm exec turbo run typecheck test --filter=@bb/agent-runtime --filter=bb-plugin-provider-acp --filter=@bb/host-daemon-contract --force` — 417, 172, and 52 tests passed; typechecks passed. - `pnpm exec turbo run build typecheck --filter='...[origin/main]'` — 62 tasks passed. - `git diff --check` — passed. Fixes #2014 🤖 Generated with [Claude Code](https://claude.com/claude-code) > AGENT GENERATED: by Claude Opus 5 Co-authored-by: Claude Opus 5 (1M context) --- .../src/pi/bridge/__tests__/bridge.test.ts | 108 ++++++- .../pi/bridge/__tests__/sdk-session.test.ts | 152 ++++++++-- .../src/pi/bridge/bridge.conformance.test.ts | 74 +++-- .../agent-runtime/src/pi/bridge/bridge.ts | 73 +++-- .../src/pi/bridge/sdk-session.ts | 286 ++++++++++++------ packages/host-daemon-contract/src/protocol.ts | 11 +- .../test/contract.test.ts | 2 +- .../provider-acp/src/bridge/bridge.test.ts | 60 +++- plugins/provider-acp/src/bridge/bridge.ts | 162 +++++++--- 9 files changed, 709 insertions(+), 219 deletions(-) diff --git a/packages/agent-runtime/src/pi/bridge/__tests__/bridge.test.ts b/packages/agent-runtime/src/pi/bridge/__tests__/bridge.test.ts index 788b5aee9f..30bd551c4f 100644 --- a/packages/agent-runtime/src/pi/bridge/__tests__/bridge.test.ts +++ b/packages/agent-runtime/src/pi/bridge/__tests__/bridge.test.ts @@ -212,6 +212,15 @@ function threadEvents( return assembleCapturedThreadEvents(messages); } +/** Turn lifecycle only, without the provider diagnostics around it. */ +function turnEvents( + messages: readonly BridgeJsonRpcOutputMessage[], +): ThreadEvent[] { + return threadEvents(messages).filter((event) => + event.type.startsWith("turn/"), + ); +} + interface ControlledPiAgentSession { abort: ReturnType; bindExtensions: ReturnType; @@ -220,6 +229,7 @@ interface ControlledPiAgentSession { emit(event: AgentSessionEvent): void; extensionRunner: { emit: ReturnType }; finishAbort(): void; + finishPrompt(): void; getActiveToolNames: ReturnType; getContextUsage: ReturnType; hasExtensionHandlers: ReturnType; @@ -233,6 +243,7 @@ interface ControlledPiAgentSession { function createControlledPiAgentSession(): ControlledPiAgentSession { let finishAbort: (() => void) | undefined; + let finishPrompt: (() => void) | undefined; let extensionShutdownHandler: (() => void) | undefined; const listeners: ControlledPiAgentSessionListener[] = []; const abort = vi.fn( @@ -263,11 +274,30 @@ function createControlledPiAgentSession(): ControlledPiAgentSession { finishAbort(); finishAbort = undefined; }, + finishPrompt() { + if (!finishPrompt) { + throw new Error("Expected Pi prompt to be running"); + } + finishPrompt(); + finishPrompt = undefined; + }, getActiveToolNames: vi.fn(() => []), getContextUsage: vi.fn(() => undefined), hasExtensionHandlers: vi.fn(() => false), isStreaming: false, - prompt: vi.fn(async () => {}), + // Pi accepts the prompt in preflight and only settles when the run it + // started ends, so a dispatched prompt stays open until a test finishes it. + prompt: vi.fn( + async ( + _text: string, + options?: { preflightResult?: (accepted: boolean) => void }, + ) => { + options?.preflightResult?.(true); + await new Promise((resolve) => { + finishPrompt = resolve; + }); + }, + ), requestExtensionShutdown(): void { if (!extensionShutdownHandler) { throw new Error("Expected Pi extension shutdown handler to be bound"); @@ -290,11 +320,12 @@ function createControlledPiAgentSession(): ControlledPiAgentSession { function createQueueUpdateEvent( steering: readonly string[], + followUp: readonly string[] = [], ): AgentSessionEvent { return { type: "queue_update", steering, - followUp: [], + followUp, }; } @@ -1137,9 +1168,10 @@ describe("pi bridge", () => { ); await bridge.flushWork(); - expect(piSession.prompt).toHaveBeenCalledWith("interrupting steer", { - streamingBehavior: "steer", - }); + expect(piSession.prompt).toHaveBeenCalledWith( + "interrupting steer", + expect.objectContaining({ streamingBehavior: "steer" }), + ); await expect(bridge.waitForResponse(22)).resolves.toMatchObject({ id: 22, result: { threadId: "thread-steer-consumption" }, @@ -1170,12 +1202,16 @@ describe("pi bridge", () => { ]), ); await bridge.waitForResponse(51); + piSession.finishPrompt(); await bridge.flushWork(); // The accepted input opened a turn the SDK never worked on; the settle // signal must still close it, or the runtime waits forever. expect(threadEvents(bridge.messages)).toContainEqual( - expect.objectContaining({ type: "turn/completed", status: "completed" }), + expect.objectContaining({ + type: "turn/completed", + status: "completed", + }), ); } finally { bridge.restore(); @@ -1264,6 +1300,66 @@ describe("pi bridge", () => { } }); + it("holds a turn/start pi queued behind a live run until pi reads it", async () => { + const bridge = createBridgeJsonRpcTestHarness(handleLine); + const piSession = createControlledPiAgentSession(); + piSession.isStreaming = true; + // Pi queues a prompt that arrives while a run is still live and returns + // straight away: the dispatch call settling is not the run settling. + piSession.prompt.mockImplementation( + async ( + _text: string, + options?: { preflightResult?: (accepted: boolean) => void }, + ) => { + piSession.emit(createQueueUpdateEvent([], ["queued prompt"])); + options?.preflightResult?.(true); + }, + ); + mockCreateAgentSession.mockImplementation(async () => ({ + session: piSession, + })); + + try { + bridge.sendRequest( + 80, + "thread/start", + sessionParams({ threadId: "thread-queued-turn" }), + ); + await bridge.waitForResponse(80); + + bridge.sendRequest( + 81, + "turn/start", + turnStartParams("thread-queued-turn", [ + { type: "text", text: "queued prompt" }, + ]), + ); + await bridge.flushWork(); + await bridge.flushWork(); + + // Accepting queued input lets the queue-time settle report claim it and + // complete an empty turn for a message pi has not read (#2014). + expect(turnEvents(bridge.messages)).toEqual([]); + + piSession.emit(createQueueUpdateEvent([], [])); + await expect(bridge.waitForResponse(81)).resolves.toMatchObject({ + id: 81, + result: { threadId: "thread-queued-turn" }, + }); + + piSession.emit({ type: "agent_start" }); + await bridge.flushWork(); + + // The acceptance lands in the turn pi opened for the input it read. + expect(turnEvents(bridge.messages)).toEqual([ + expect.objectContaining({ type: "turn/started" }), + expect.objectContaining({ type: "turn/input/accepted" }), + ]); + } finally { + bridge.restore(); + } + }); + it("emits an error when a queued steer is not consumed before agent end", async () => { const bridge = createBridgeJsonRpcTestHarness(handleLine); const piSession = createControlledPiAgentSession(); diff --git a/packages/agent-runtime/src/pi/bridge/__tests__/sdk-session.test.ts b/packages/agent-runtime/src/pi/bridge/__tests__/sdk-session.test.ts index 132bcb58b8..78c3094f4c 100644 --- a/packages/agent-runtime/src/pi/bridge/__tests__/sdk-session.test.ts +++ b/packages/agent-runtime/src/pi/bridge/__tests__/sdk-session.test.ts @@ -235,14 +235,34 @@ function emitSessionEvent(event: AgentSessionEvent): void { function createQueueUpdateEvent( steering: readonly string[], + followUp: readonly string[] = [], ): AgentSessionEvent { return { type: "queue_update", steering, - followUp: [], + followUp, }; } +/** + * Every dispatch installs pi's preflight hook: it is how the session learns + * that pi took an input it did not queue. + */ +function withPreflight( + options: Record = {}, +): Record { + return { ...options, preflightResult: expect.any(Function) }; +} + +/** Report pi's preflight acceptance for the most recent dispatch. */ +function reportPreflightAccepted(accepted = true): void { + const call = mockPrompt.mock.calls.at(-1); + const options = call?.[1] as + | { preflightResult?: (accepted: boolean) => void } + | undefined; + options?.preflightResult?.(accepted); +} + function createAgentEndEvent(willRetry = false): AgentSessionEvent { return { type: "agent_end", @@ -284,6 +304,7 @@ async function flushDeferredSteerSettlement(): Promise { describe("PiSdkSession", () => { beforeEach(() => { vi.clearAllMocks(); + mockPrompt.mockReset(); mockSessionState.isStreaming = false; mockSessionEventListeners.length = 0; mockGetActiveToolNames.mockReturnValue([]); @@ -669,8 +690,8 @@ describe("PiSdkSession", () => { ); await session.start(); - await session.prompt("first follow-up"); - await session.prompt("second follow-up"); + await session.prompt("first follow-up").settled; + await session.prompt("second follow-up").settled; expect(mockSetActiveToolsByName).toHaveBeenCalledTimes(2); expect(mockSetActiveToolsByName).toHaveBeenNthCalledWith(1, [ @@ -690,11 +711,57 @@ describe("PiSdkSession", () => { const session = new PiSdkSession({ cwd: "/tmp/project" }, vi.fn(), vi.fn()); await session.start(); - await session.prompt("queued follow-up"); + session.prompt("queued follow-up"); + + expect(mockPrompt).toHaveBeenCalledWith( + "queued follow-up", + withPreflight({ streamingBehavior: "followUp" }), + ); + }); + + it("accepts a queued follow-up prompt only once pi reads it", async () => { + mockSessionState.isStreaming = true; + mockPrompt.mockImplementationOnce(async () => { + emitSessionEvent(createQueueUpdateEvent([], ["expanded follow-up"])); + reportPreflightAccepted(); + }); + const session = new PiSdkSession({ cwd: "/tmp/project" }, vi.fn(), vi.fn()); + + await session.start(); + const dispatch = session.prompt("queued follow-up"); + let consumed = false; + void dispatch.consumed.then(() => { + consumed = true; + }); + await flushAsyncWork(); + + // Pi queued the prompt behind the live run: it has not read the input, and + // the run it lands in reports its own settlement. + expect(consumed).toBe(false); + await expect(dispatch.settled).resolves.toBeNull(); + + emitSessionEvent(createQueueUpdateEvent([], [])); + await expect(dispatch.consumed).resolves.toBeUndefined(); + }); - expect(mockPrompt).toHaveBeenCalledWith("queued follow-up", { - streamingBehavior: "followUp", + it("accepts an unqueued prompt when pi reports preflight acceptance", async () => { + let releaseRun: (() => void) | undefined; + mockPrompt.mockImplementationOnce(async () => { + reportPreflightAccepted(); + await new Promise((resolve) => { + releaseRun = resolve; + }); }); + const session = new PiSdkSession({ cwd: "/tmp/project" }, vi.fn(), vi.fn()); + + await session.start(); + const dispatch = session.prompt("direct prompt"); + + // Pi started the run with the input, so the turn is accepted long before + // the run it started settles. + await expect(dispatch.consumed).resolves.toBeUndefined(); + releaseRun?.(); + await expect(dispatch.settled).resolves.toEqual({}); }); it("resolves queued steer once the SDK accepts it and monitors consumption", async () => { @@ -712,9 +779,10 @@ describe("PiSdkSession", () => { }); await steerPromise; - expect(mockPrompt).toHaveBeenCalledWith("interrupting steer", { - streamingBehavior: "steer", - }); + expect(mockPrompt).toHaveBeenCalledWith( + "interrupting steer", + withPreflight({ streamingBehavior: "steer" }), + ); expect(steerAccepted).toBe(true); expect(onDone).not.toHaveBeenCalled(); @@ -731,9 +799,10 @@ describe("PiSdkSession", () => { await session.start(); await session.steer("handled steer"); - expect(mockPrompt).toHaveBeenCalledWith("handled steer", { - streamingBehavior: "steer", - }); + expect(mockPrompt).toHaveBeenCalledWith( + "handled steer", + withPreflight({ streamingBehavior: "steer" }), + ); }); it("rejects steer consumption when the SDK prompt rejects", async () => { @@ -814,6 +883,36 @@ describe("PiSdkSession", () => { ); }); + it("keeps a queued follow-up pending past the agent end that continues into it", async () => { + mockSessionState.isStreaming = true; + mockPrompt.mockImplementationOnce(async () => { + emitSessionEvent(createQueueUpdateEvent([], ["queued follow-up"])); + }); + const session = new PiSdkSession({ cwd: "/tmp/project" }, vi.fn(), vi.fn()); + + await session.start(); + const dispatch = session.prompt("queued follow-up"); + let settledConsumption: "consumed" | "failed" | undefined; + void dispatch.consumed.then( + () => { + settledConsumption = "consumed"; + }, + () => { + settledConsumption = "failed"; + }, + ); + + // Pi drains its follow-up queue by continuing the same run after + // agent_end, so that event is terminal for steering only. + emitSessionEvent(createAgentEndEvent()); + await flushDeferredSteerSettlement(); + + expect(settledConsumption).toBeUndefined(); + + emitSessionEvent(createQueueUpdateEvent([], [])); + await expect(dispatch.consumed).resolves.toBeUndefined(); + }); + it("keeps queued steer consumption pending when auto retry starts", async () => { mockSessionState.isStreaming = true; mockPrompt.mockImplementationOnce(async () => { @@ -867,11 +966,19 @@ describe("PiSdkSession", () => { const session = new PiSdkSession({ cwd: "/tmp/project" }, vi.fn(), vi.fn()); await session.start(); - await session.prompt("idle follow-up"); + session.prompt("idle follow-up"); await session.steer("idle steer"); - expect(mockPrompt).toHaveBeenNthCalledWith(1, "idle follow-up", {}); - expect(mockPrompt).toHaveBeenNthCalledWith(2, "idle steer", {}); + expect(mockPrompt).toHaveBeenNthCalledWith( + 1, + "idle follow-up", + withPreflight(), + ); + expect(mockPrompt).toHaveBeenNthCalledWith( + 2, + "idle steer", + withPreflight(), + ); }); it("reports pending steer consumption failure when the session closes", async () => { @@ -891,7 +998,7 @@ describe("PiSdkSession", () => { expect(onDone).toHaveBeenCalledTimes(1); expect(onDone).toHaveBeenCalledWith( expect.objectContaining({ - message: "Pi SDK session stopped before steer consumed", + message: "Pi SDK session stopped before input was consumed", }), ); }); @@ -904,7 +1011,7 @@ describe("PiSdkSession", () => { const session = new PiSdkSession({ cwd: "/tmp/project" }, vi.fn(), onDone); await session.start(); - await session.prompt("retry after auth storage miss"); + await session.prompt("retry after auth storage miss").settled; expect(mockPrompt).toHaveBeenCalledTimes(9); expect(onDone).not.toHaveBeenCalled(); @@ -917,7 +1024,12 @@ describe("PiSdkSession", () => { const session = new PiSdkSession({ cwd: "/tmp/project" }, vi.fn(), onDone); await session.start(); - await session.prompt("fail after retry budget"); + const dispatch = session.prompt("fail after retry budget"); + void dispatch.consumed.catch(() => undefined); + await expect(dispatch.settled).resolves.toEqual({ error: authError }); + await expect(dispatch.consumed).rejects.toThrow( + "No API key found for anthropic.", + ); expect(mockPrompt).toHaveBeenCalledTimes(9); expect(onDone).toHaveBeenCalledTimes(1); @@ -927,7 +1039,7 @@ describe("PiSdkSession", () => { it("stays processing across retryable agent-end events", async () => { const session = new PiSdkSession({ cwd: "/tmp/project" }, vi.fn(), vi.fn()); await session.start(); - await session.prompt("retry me"); + session.prompt("retry me"); emitSessionEvent(createAgentEndEvent(true)); expect(session.getIsProcessing()).toBe(true); @@ -939,7 +1051,7 @@ describe("PiSdkSession", () => { it("stays processing while Pi performs post-turn streaming work", async () => { const session = new PiSdkSession({ cwd: "/tmp/project" }, vi.fn(), vi.fn()); await session.start(); - await session.prompt("trigger auto compaction"); + session.prompt("trigger auto compaction"); emitSessionEvent(createAgentEndEvent()); mockSessionState.isStreaming = true; diff --git a/packages/agent-runtime/src/pi/bridge/bridge.conformance.test.ts b/packages/agent-runtime/src/pi/bridge/bridge.conformance.test.ts index bb9d3d6c69..9447fc5c56 100644 --- a/packages/agent-runtime/src/pi/bridge/bridge.conformance.test.ts +++ b/packages/agent-runtime/src/pi/bridge/bridge.conformance.test.ts @@ -142,40 +142,48 @@ function createScriptedPiAgentSession(): ScriptedPiAgentSession { getContextUsage: vi.fn(() => undefined), hasExtensionHandlers: vi.fn(() => false), isStreaming: false, - prompt: vi.fn(async (promptText: string) => { - // A prompt the agent handles without emitting a single SDK event: the - // bridge's own pi/prompt/settled report is then the only signal that can - // settle the turn (#1431). - if (promptText === ZERO_WORK_PROMPT_TEXT) { - return; - } - scriptedTurnCounter += 1; - const text = `hello from turn ${scriptedTurnCounter}`; - emit(asPiSdkEvent({ type: "agent_start" })); - emit( - asPiSdkEvent({ - type: "message_update", - assistantMessageEvent: { - type: "text_delta", - contentIndex: 0, - delta: text, - }, - }), - ); - emit( - asPiSdkEvent({ - type: "agent_end", - messages: [ - { - role: "assistant", - content: [{ type: "text", text }], - usage: { input: 12, output: 5 }, + prompt: vi.fn( + async ( + promptText: string, + options?: { preflightResult?: (accepted: boolean) => void }, + ) => { + // Pi accepts a prompt it is about to run in preflight, which is what + // tells the bridge the input was consumed rather than queued. + options?.preflightResult?.(true); + // A prompt the agent handles without emitting a single SDK event: the + // bridge's own pi/prompt/settled report is then the only signal that + // can settle the turn (#1431). + if (promptText === ZERO_WORK_PROMPT_TEXT) { + return; + } + scriptedTurnCounter += 1; + const text = `hello from turn ${scriptedTurnCounter}`; + emit(asPiSdkEvent({ type: "agent_start" })); + emit( + asPiSdkEvent({ + type: "message_update", + assistantMessageEvent: { + type: "text_delta", + contentIndex: 0, + delta: text, }, - ], - willRetry: false, - }), - ); - }), + }), + ); + emit( + asPiSdkEvent({ + type: "agent_end", + messages: [ + { + role: "assistant", + content: [{ type: "text", text }], + usage: { input: 12, output: 5 }, + }, + ], + willRetry: false, + }), + ); + }, + ), sessionManager: { getLeafId: vi.fn(() => "pi-conformance-checkpoint") }, setActiveToolsByName: vi.fn(), subscribe: vi.fn((listener: (event: AgentSessionEvent) => void) => { diff --git a/packages/agent-runtime/src/pi/bridge/bridge.ts b/packages/agent-runtime/src/pi/bridge/bridge.ts index 49fa8a9f2f..e446a1fb5b 100644 --- a/packages/agent-runtime/src/pi/bridge/bridge.ts +++ b/packages/agent-runtime/src/pi/bridge/bridge.ts @@ -635,7 +635,7 @@ async function handleRequest( await handleThreadFork(request.id, request.params); break; case "turn/start": - handleTurnStart(request.id, request.params); + await handleTurnStart(request.id, request.params); break; case "turn/steer": await handleTurnSteer(request.id, request.params); @@ -834,27 +834,33 @@ async function handleThreadFork( ); } +/** + * Dispatch turn input and report the settlement of the run it starts. The + * returned promise resolves once pi consumed the input. + */ function startPiPrompt( threadSession: ThreadSession, threadId: string, text: string, images: ImageContent[], -): void { - void threadSession.session - .prompt(text, images.length > 0 ? images : undefined) - .then( - () => - reportPromptSettled({ - sessionSerial: threadSession.sessionSerial, - threadId, - }), - (error: unknown) => - reportPromptSettled({ - error, - sessionSerial: threadSession.sessionSerial, - threadId, - }), - ); +): Promise { + const dispatch = threadSession.session.prompt( + text, + images.length > 0 ? images : undefined, + ); + void dispatch.settled.then((outcome) => { + // Input pi queued into a run it did not start has no settlement of its + // own. Reporting one anyway settles whichever turn is open when it lands. + if (outcome === null) { + return; + } + reportPromptSettled({ + ...(outcome.error !== undefined ? { error: outcome.error } : {}), + sessionSerial: threadSession.sessionSerial, + threadId, + }); + }); + return dispatch.consumed; } /** @@ -885,8 +891,10 @@ function startPiCompaction( } /** - * Accepted-input correlation (turn/input/accepted): the assembler owns the - * queue-until-turn-opens behavior, so the bridge only reports the acceptance. + * Accepted-input correlation (turn/input/accepted): acceptance means pi + * consumed the input, never that bb handed it over, so every caller reports it + * only after pi read the input. The assembler owns the queue-until-turn-opens + * behavior, so the bridge only reports the acceptance. */ function recordAcceptedTurnInput(params: TurnStartParams): void { sendThreadDeltas(params.threadId, [ @@ -894,7 +902,10 @@ function recordAcceptedTurnInput(params: TurnStartParams): void { ]); } -function handleTurnStart(id: string | number, params: TurnStartParams): void { +async function handleTurnStart( + id: string | number, + params: TurnStartParams, +): Promise { // Requests resolve the session by bb threadId — pi's stable session handle. const threadSession = sessions.get(params.threadId); if (!threadSession || threadSession.closing) { @@ -918,9 +929,18 @@ function handleTurnStart(id: string | number, params: TurnStartParams): void { return; } - recordAcceptedTurnInput(params); - startPiPrompt(threadSession, params.threadId, text, images); - sendResult(id, { threadId: params.threadId }); + try { + await startPiPrompt(threadSession, params.threadId, text, images); + // Like steer, a new turn is accepted only once pi read the input. Pi + // queues a prompt that arrives while a run is still unwinding, and that + // run's settle report would otherwise claim the queued input and complete + // an empty turn for a message pi has not answered yet. + recordAcceptedTurnInput(params); + sendResult(id, { threadId: params.threadId }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + sendError(id, -32000, message); + } } async function handleTurnSteer( @@ -949,8 +969,11 @@ async function handleTurnSteer( text, images.length > 0 ? images : undefined, ); - // A steer joins the active turn; its acceptance is reported only once - // the SDK actually accepted the queued input. + // A steer joins the turn the assembler already holds open, so its + // acceptance can never be the pending claim a stale terminal takes. It is + // reported once the SDK took the steering message: pi delivers steering + // only between assistant turns, and waiting for that would leave the + // steered message unrendered for the length of the running tool call. sendThreadDeltas(params.threadId, [ { kind: "input.accepted", clientRequestId: params.clientRequestId }, ]); diff --git a/packages/agent-runtime/src/pi/bridge/sdk-session.ts b/packages/agent-runtime/src/pi/bridge/sdk-session.ts index 32390fed65..f02b7bd9c9 100644 --- a/packages/agent-runtime/src/pi/bridge/sdk-session.ts +++ b/packages/agent-runtime/src/pi/bridge/sdk-session.ts @@ -38,25 +38,53 @@ type AppendSystemPromptOverride = (base: string[]) => string[]; interface RunPromptArgs { images?: ImageContent[]; + pending: PendingInputConsumption; streamingBehavior: PiStreamingBehavior; text: string; } -interface RunPromptResult { - steerConsumptionPromise: Promise | null; -} +/** + * Which of pi's two input queues holds a dispatch pi did not run immediately. + * A steering message interrupts the current assistant turn; a follow-up waits + * for it. They drain independently, so a dispatch is correlated against the + * queue it was placed in. + */ +type PiInputQueue = "followUp" | "steering"; -interface PendingSteerConsumption { +interface PendingInputConsumption { + queue: PiInputQueue; queuedText: string | null; reject: (error: Error) => void; resolve: () => void; } -interface TrackedSteerConsumption { - pending: PendingSteerConsumption; +interface TrackedInputConsumption { + pending: PendingInputConsumption; promise: Promise; } +/** The outcome of an agent run pi started for a dispatched input. */ +export interface PiPromptRunOutcome { + /** Omitted when the run finished without a fatal error. */ + error?: unknown; +} + +/** How pi took a dispatched turn input. */ +export interface PiInputDispatch { + /** + * Resolves once pi consumed the input — it started a run with it, or the + * queue that held it delivered it. Rejects when pi refused the input or the + * session ended before delivery. + */ + consumed: Promise; + /** + * The outcome of the run pi started for this input, or `null` when pi queued + * the input into a run it did not start. That run's own dispatch reports its + * settlement; a second report would settle whatever turn is open by then. + */ + settled: Promise; +} + type PiStreamingBehavior = NonNullable; const PI_TRANSIENT_AUTH_RETRY_DELAY_MS = 250; @@ -183,8 +211,11 @@ export class PiSdkSession { private isProcessing = false; private isCompacting = false; private manualCompactionCompletionCount = 0; - private readonly pendingSteerConsumptions: PendingSteerConsumption[] = []; - private lastObservedSteeringQueue: string[] = []; + private readonly pendingInputConsumptions: PendingInputConsumption[] = []; + private lastObservedQueues: Record = { + followUp: [], + steering: [], + }; private autoRetryInProgress = false; private terminalSteerSettlementTimeout: | ReturnType @@ -297,47 +328,89 @@ export class PiSdkSession { // Subscribe to session events this.unsubscribe = session.subscribe((event: AgentSessionEvent) => { this.trackProcessingState(event); - this.observeSteerConsumption(event); + this.observeInputConsumption(event); this.observeTerminalSteerSettlement(event); this.onEvent(event); }); } - async prompt(text: string, images?: ImageContent[]): Promise { - if (!this.session) return; - this.isProcessing = true; - try { - await this.runPromptWithTransientAuthRetry({ - images, - streamingBehavior: "followUp", - text, - }); - } catch (error) { - this.isProcessing = false; - this.rejectPendingSteerConsumptions( - "Pi SDK prompt failed before steer consumed", - ); - this.onDone(error); + /** + * Dispatch turn input. Pi either starts a run with it or, when a run is + * already live, queues it as a follow-up — so the caller learns consumption + * and settlement separately instead of treating the dispatch call returning + * as either one. + */ + prompt(text: string, images?: ImageContent[]): PiInputDispatch { + if (!this.session) { + const consumed = Promise.reject(new Error("No active Pi SDK session")); + // A caller that only watches settlement must not turn this into an + // unhandled rejection. + void consumed.catch(() => undefined); + return { consumed, settled: Promise.resolve(null) }; } + this.isProcessing = true; + const tracked = this.trackPendingInputConsumption("followUp"); + const settled = this.runPromptWithTransientAuthRetry({ + images, + pending: tracked.pending, + streamingBehavior: "followUp", + text, + }).then( + (): PiPromptRunOutcome | null => { + if (tracked.pending.queuedText !== null) { + return null; + } + // Pi handled the input without queueing it and without a preflight + // report (an SDK that predates the hook, or a prompt handled before + // preflight): the returned call is then the only consumption signal. + this.resolvePendingInputConsumption(tracked.pending); + return {}; + }, + (error: unknown): PiPromptRunOutcome | null => { + this.isProcessing = false; + const queued = tracked.pending.queuedText !== null; + this.rejectPendingInputConsumption(tracked.pending, asError(error)); + this.rejectPendingInputConsumptions( + "Pi SDK prompt failed before input was consumed", + ); + this.onDone(error); + return queued ? null : { error }; + }, + ); + return { consumed: tracked.promise, settled }; } + /** + * Steer the live run. Resolves once the SDK took the steering message, not + * once it read it: pi delivers steering between assistant turns, so a steer + * sent during a long tool call waits for that tool, and the runtime's + * 30-second command timeout would fail a steer that is still on its way. + * A steer the run ends without reading is reported through `onDone`. + */ async steer(text: string, images?: ImageContent[]): Promise { if (!this.session) { throw new Error("No active Pi SDK session"); } + const tracked = this.trackPendingInputConsumption("steering"); try { - const result = await this.runPromptWithTransientAuthRetry({ + await this.runPromptWithTransientAuthRetry({ images, + pending: tracked.pending, streamingBehavior: "steer", text, }); - if (result.steerConsumptionPromise) { - this.monitorSteerConsumption(result.steerConsumptionPromise); - } } catch (error) { + this.rejectPendingInputConsumption(tracked.pending, asError(error)); this.onDone(error); throw error; } + if (tracked.pending.queuedText === null) { + // Pi handled the steer without queueing it, so no delivery event is + // coming: the returned call is the consumption signal. + this.resolvePendingInputConsumption(tracked.pending); + return; + } + this.monitorSteerConsumption(tracked.promise); } async compact(): Promise { @@ -366,8 +439,8 @@ export class PiSdkSession { } detach(): void { - this.rejectPendingSteerConsumptions( - "Pi SDK session detached before steer consumed", + this.rejectPendingInputConsumptions( + "Pi SDK session detached before input was consumed", ); if (this.unsubscribe) { this.unsubscribe(); @@ -378,8 +451,8 @@ export class PiSdkSession { } stop(): void { - this.rejectPendingSteerConsumptions( - "Pi SDK session stopped before steer consumed", + this.rejectPendingInputConsumptions( + "Pi SDK session stopped before input was consumed", ); this.detach(); const session = this.session; @@ -389,8 +462,8 @@ export class PiSdkSession { async closeGracefully(timeoutMs: number): Promise { const session = this.session; - this.rejectPendingSteerConsumptions( - "Pi SDK session closed before steer consumed", + this.rejectPendingInputConsumptions( + "Pi SDK session closed before input was consumed", ); this.detach(); if (!session) { @@ -453,7 +526,9 @@ export class PiSdkSession { } } - private trackPendingSteerConsumption(): TrackedSteerConsumption { + private trackPendingInputConsumption( + queue: PiInputQueue, + ): TrackedInputConsumption { let resolvePromise: (() => void) | undefined; let rejectPromise: ((error: Error) => void) | undefined; const promise = new Promise((resolve, reject) => { @@ -461,39 +536,45 @@ export class PiSdkSession { rejectPromise = reject; }); if (!resolvePromise || !rejectPromise) { - throw new Error("Failed to track Pi steer consumption"); + throw new Error("Failed to track Pi input consumption"); } - const pending: PendingSteerConsumption = { + const pending: PendingInputConsumption = { + queue, queuedText: null, reject: rejectPromise, resolve: resolvePromise, }; - this.pendingSteerConsumptions.push(pending); + this.pendingInputConsumptions.push(pending); void promise.catch(() => undefined); return { pending, promise }; } - private observeSteerConsumption(event: AgentSessionEvent): void { + private observeInputConsumption(event: AgentSessionEvent): void { if (event.type !== "queue_update") { return; } + this.observeQueue("steering", event.steering); + this.observeQueue("followUp", event.followUp); + } - const addedQueuedTexts = listMultisetDifference( - event.steering, - this.lastObservedSteeringQueue, - ); + private observeQueue( + queue: PiInputQueue, + queuedTexts: readonly string[], + ): void { + const lastObserved = this.lastObservedQueues[queue]; + const addedQueuedTexts = listMultisetDifference(queuedTexts, lastObserved); const removedQueuedTexts = listMultisetDifference( - this.lastObservedSteeringQueue, - event.steering, + lastObserved, + queuedTexts, ); - this.lastObservedSteeringQueue = [...event.steering]; + this.lastObservedQueues[queue] = [...queuedTexts]; for (const queuedText of addedQueuedTexts) { // Pi queue_update exposes SDK-transformed text, so correlate by FIFO queue - // additions rather than by the raw BB steer text. - const pending = this.pendingSteerConsumptions.find( - (entry) => entry.queuedText === null, + // additions rather than by the raw BB input text. + const pending = this.pendingInputConsumptions.find( + (entry) => entry.queue === queue && entry.queuedText === null, ); if (!pending) { break; @@ -502,11 +583,13 @@ export class PiSdkSession { } for (const queuedText of removedQueuedTexts) { - const pending = this.pendingSteerConsumptions.find( - (entry) => entry.queuedText === queuedText, + // Pi drops a queued message from the queue when it reads it into the + // conversation, which is the only moment the input is truly consumed. + const pending = this.pendingInputConsumptions.find( + (entry) => entry.queue === queue && entry.queuedText === queuedText, ); if (pending) { - this.resolvePendingSteerConsumption(pending); + this.resolvePendingInputConsumption(pending); } } } @@ -528,16 +611,22 @@ export class PiSdkSession { if (event.type === "auto_retry_end") { this.autoRetryInProgress = false; if (!event.success) { - this.rejectPendingSteerConsumptions( + this.rejectPendingInputConsumptions( "Pi auto retry ended before steer was consumed", + "steering", ); } } } private scheduleTerminalSteerSettlement(): void { + // Only steering is terminal at agent_end: pi drains its follow-up queue by + // continuing the same run after that event, so a queued follow-up is still + // on its way to being read. if ( - this.pendingSteerConsumptions.length === 0 || + !this.pendingInputConsumptions.some( + (entry) => entry.queue === "steering", + ) || this.terminalSteerSettlementTimeout !== undefined ) { return; @@ -548,8 +637,9 @@ export class PiSdkSession { if (this.autoRetryInProgress) { return; } - this.rejectPendingSteerConsumptions( + this.rejectPendingInputConsumptions( "Pi turn ended before steer was consumed", + "steering", ); }, 0); } @@ -562,33 +652,41 @@ export class PiSdkSession { this.terminalSteerSettlementTimeout = undefined; } - private resolvePendingSteerConsumption( - pending: PendingSteerConsumption, + private resolvePendingInputConsumption( + pending: PendingInputConsumption, ): void { - const index = this.pendingSteerConsumptions.indexOf(pending); + const index = this.pendingInputConsumptions.indexOf(pending); if (index === -1) { return; } - this.pendingSteerConsumptions.splice(index, 1); + this.pendingInputConsumptions.splice(index, 1); pending.resolve(); } - private rejectPendingSteerConsumption( - pending: PendingSteerConsumption, + /** Reports whether this call was the one that settled the consumption. */ + private rejectPendingInputConsumption( + pending: PendingInputConsumption, error: Error, - ): void { - const index = this.pendingSteerConsumptions.indexOf(pending); + ): boolean { + const index = this.pendingInputConsumptions.indexOf(pending); if (index === -1) { - return; + return false; } - this.pendingSteerConsumptions.splice(index, 1); + this.pendingInputConsumptions.splice(index, 1); pending.reject(error); + return true; } - private rejectPendingSteerConsumptions(message: string): void { + private rejectPendingInputConsumptions( + message: string, + queue?: PiInputQueue, + ): void { this.clearTerminalSteerSettlement(); - const pendingSteers = this.pendingSteerConsumptions.splice(0); - for (const pending of pendingSteers) { + for (const pending of this.pendingInputConsumptions.splice(0)) { + if (queue !== undefined && pending.queue !== queue) { + this.pendingInputConsumptions.push(pending); + continue; + } pending.reject(new Error(message)); } } @@ -624,10 +722,11 @@ export class PiSdkSession { private async runPromptWithTransientAuthRetry( args: RunPromptArgs, - ): Promise { + ): Promise { for (let attempt = 0; ; attempt += 1) { try { - return await this.runPromptOnce(args); + await this.runPromptOnce(args); + return; } catch (error) { if ( !(error instanceof Error) || @@ -641,44 +740,35 @@ export class PiSdkSession { } } - private async runPromptOnce(args: RunPromptArgs): Promise { + private async runPromptOnce(args: RunPromptArgs): Promise { if (!this.session) { throw new Error("No active Pi SDK session"); } this.ensureCustomToolsActive(); - if (this.session.isStreaming) { - const steerConsumption = - args.streamingBehavior === "steer" - ? this.trackPendingSteerConsumption() - : null; - try { - await this.session.prompt(args.text, { - streamingBehavior: args.streamingBehavior, - ...(args.images && args.images.length > 0 - ? { images: args.images } - : {}), - }); - } catch (error) { - if (steerConsumption) { - this.rejectPendingSteerConsumption( - steerConsumption.pending, - error instanceof Error ? error : new Error(String(error)), - ); - } - throw error; - } - if (steerConsumption && steerConsumption.pending.queuedText === null) { - this.resolvePendingSteerConsumption(steerConsumption.pending); - } - return { steerConsumptionPromise: steerConsumption?.promise ?? null }; - } + const pending = args.pending; await this.session.prompt(args.text, { + ...(this.session.isStreaming + ? { streamingBehavior: args.streamingBehavior } + : {}), ...(args.images && args.images.length > 0 ? { images: args.images } : {}), + // Pi reports preflight acceptance after it queued the input and before + // it starts a run with it. Input pi did not queue is therefore consumed + // the moment preflight accepts it; queued input waits for its queue to + // deliver it. Nothing else reports the start of a run pi handles without + // emitting a single SDK event. + preflightResult: (accepted: boolean) => { + if (accepted && pending.queuedText === null) { + this.resolvePendingInputConsumption(pending); + } + }, }); - return { steerConsumptionPromise: null }; } } +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + type PiModel = NonNullable>; /** diff --git a/packages/host-daemon-contract/src/protocol.ts b/packages/host-daemon-contract/src/protocol.ts index e711023a09..0b30a18894 100644 --- a/packages/host-daemon-contract/src/protocol.ts +++ b/packages/host-daemon-contract/src/protocol.ts @@ -1,3 +1,12 @@ +// Version 141 extends the consumed-not-queued acceptance rule to the remaining +// providers. Pi reports `input.accepted` for a turn only once it read the +// input: a prompt pi queues behind a live run stays unaccepted, and the +// queue-time settle report that used to accompany it is gone, so it can no +// longer complete an empty turn for a message pi has not answered. ACP reports +// acceptance once the `session/prompt` request carrying the input goes out, so +// a steer the turn drops is no longer reported as accepted. Older daemons emit +// the queue-time semantics and produce those phantom turns. +// // Version 140 reports each daemon's browser-local editor helper port during // session open. The server uses those ports to let a remote browser discover // the helper on its own machine instead of assuming every machine uses the @@ -71,7 +80,7 @@ // // The version mismatch is what triggers the enrolled daemon's automatic update // instead of an `invalid-message` reconnect loop. -export const HOST_DAEMON_PROTOCOL_VERSION = 140 as const; +export const HOST_DAEMON_PROTOCOL_VERSION = 141 as const; /** * Absolute ceiling for any executable artifact delivered to a host daemon — diff --git a/packages/host-daemon-contract/test/contract.test.ts b/packages/host-daemon-contract/test/contract.test.ts index 1e1f4aff80..e808f2b4c7 100644 --- a/packages/host-daemon-contract/test/contract.test.ts +++ b/packages/host-daemon-contract/test/contract.test.ts @@ -1138,7 +1138,7 @@ describe("host-daemon command schemas", () => { // mixed version. Version 113 carried the Devin Desktop open target rename // and remains part of the protocol lineage. it("uses the current host-daemon protocol version", () => { - expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(140); + expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(141); expect(HOST_ARTIFACT_MAX_BYTES).toBe(256 * 1024 * 1024); }); diff --git a/plugins/provider-acp/src/bridge/bridge.test.ts b/plugins/provider-acp/src/bridge/bridge.test.ts index a3c9a3e801..3d5a4b9053 100644 --- a/plugins/provider-acp/src/bridge/bridge.test.ts +++ b/plugins/provider-acp/src/bridge/bridge.test.ts @@ -12,7 +12,10 @@ import { fileURLToPath } from "node:url"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createStandaloneBuiltinCompactCommandInput } from "@bb/domain"; import type { DynamicTool, ReasoningLevel } from "@bb/domain"; -import { PROVIDER_BRIDGE_PROTOCOL_VERSION } from "@bb/provider-bridge-protocol"; +import { + PROVIDER_BRIDGE_PROTOCOL_VERSION, + THREAD_DELTA_NOTIFICATION_METHOD, +} from "@bb/provider-bridge-protocol"; import { captureBridgeJsonRpcOutput, type BridgeJsonRpcOutputMessage, @@ -100,6 +103,16 @@ function threadEvents(): Record[] { Record[]; } +/** The delta kinds the bridge put on the wire, in emission order. */ +function emittedDeltaKinds(): string[] { + return notifications(THREAD_DELTA_NOTIFICATION_METHOD).flatMap((message) => { + const params = message.params as + | { deltas?: { kind?: string }[] } + | undefined; + return (params?.deltas ?? []).map((delta) => delta.kind ?? ""); + }); +} + function threadEventsOfType(type: string): Record[] { return threadEvents().filter((event) => event.type === type); } @@ -1910,6 +1923,51 @@ describe("acp bridge", () => { expect(threadEventsOfType("thread/compacted")).toEqual([]); }); + it("accepts turn input only after the prompt carrying it goes out", async () => { + const { providerThreadId } = await startThread(); + const turnId = sendTurnRequest("turn/start", providerThreadId, { + input: [{ type: "text", text: "hello there", mentions: [] }], + }); + await waitForResponse(turnId); + await waitForTurnCompleted(); + + // Acceptance means the `session/prompt` request carrying the input went + // out, so the bridge emits it after opening the turn. Emitting it first + // leaves a pending claim on bb's side that any stale terminal can take, + // which is the class #2013 fixed for Claude (#2014). + const deltaKinds = emittedDeltaKinds(); + expect(deltaKinds.indexOf("input.accepted")).toBe( + deltaKinds.indexOf("turn.open") + 1, + ); + }); + + it("never accepts a queued steer the stopped turn did not send", async () => { + const { providerThreadId } = await startThread(); + const turnId = sendTurnRequest("turn/start", providerThreadId, { + input: [{ type: "text", text: "hang", mentions: [] }], + }); + await waitForResponse(turnId); + + const steerId = sendTurnRequest("turn/steer", providerThreadId, { + expectedTurnId: "turn-1", + input: [{ type: "text", text: "never sent", mentions: [] }], + }); + await waitForResponse(steerId); + const stopId = sendRequest("thread/stop", { + threadId: bbThreadIdFor(providerThreadId), + providerThreadId, + intent: "interrupt", + activeTurnId: null, + }); + await waitForResponse(stopId); + await waitForTurnCompleted(); + + // The stop dropped the queued steer before it reached the agent, so the + // turn reports the one input the agent was actually given, not two. + expect(threadEventsOfType("turn/input/accepted")).toHaveLength(1); + startedProviderThreadIds.pop(); + }); + it("rejects steers when no turn is active", async () => { const { providerThreadId } = await startThread(); const steerId = sendTurnRequest("turn/steer", providerThreadId, { diff --git a/plugins/provider-acp/src/bridge/bridge.ts b/plugins/provider-acp/src/bridge/bridge.ts index 53e98784ad..d6b519d495 100644 --- a/plugins/provider-acp/src/bridge/bridge.ts +++ b/plugins/provider-acp/src/bridge/bridge.ts @@ -137,6 +137,24 @@ interface PendingAcpPermission { options: AcpPermissionOption[]; } +/** + * Turn input bb handed the bridge, waiting to reach the agent. ACP has no + * provider acknowledgement to correlate acceptance against, so the designed + * correlation point is the `session/prompt` request that carries the input: + * before that the agent has not seen the input at all, and a queued steer can + * still be dropped by a failed or stopping turn. + */ +interface AcpPendingTurnInput { + clientRequestId: string; + input: PromptInput[]; + /** + * The command to answer once the input reaches the agent, or `null` for a + * steer, which is answered at queue time. Waiting for the cancelled prompt + * to be reissued would risk the runtime's 30-second command timeout. + */ + requestId: AcpBridgeRequestId | null; +} + interface AcpThreadSession { bbThreadId: string; providerThreadId: string; @@ -154,7 +172,7 @@ interface AcpThreadSession { * the provider-local `"compaction"` maintenance prompt, or none. */ activePromptKind: "turn" | "compaction" | null; - queuedInputs: PromptInput[][]; + queuedInputs: AcpPendingTurnInput[]; /** True while a session/prompt request is outstanding. */ promptRequestPending: boolean; /** True after a steer sent session/cancel for the current prompt. */ @@ -202,6 +220,8 @@ const { send, sendResult, sendError } = createBridgeIo< BridgeNotification | BridgeRuntimeRequest >(); +type AcpBridgeRequestId = Parameters[0]; + function sendNotification( method: string, params: Record, @@ -1793,7 +1813,10 @@ async function stopSession(session: AcpThreadSession): Promise { return; } session.stopping = true; - session.queuedInputs = []; + dropQueuedTurnInputs( + session, + "ACP session stopped before the steer was sent", + ); cancelPendingPermissions(session); if (session.activePromptKind !== null && !session.connection.exited) { @@ -1825,7 +1848,10 @@ function releaseSession(session: AcpThreadSession): void { return; } session.stopping = true; - session.queuedInputs = []; + dropQueuedTurnInputs( + session, + "ACP session released before the steer was sent", + ); cancelPendingPermissions(session); session.connection.kill(); removeSession(session); @@ -1851,12 +1877,58 @@ function requestSteerCancel(session: AcpThreadSession): void { }); } +/** + * Accepted-input correlation (turn/input/accepted): the input reached the + * agent, so bb can attach it to the turn it runs in. Reporting acceptance + * before the `session/prompt` request goes out would claim an input the agent + * may never be given. + */ +function acceptTurnInput( + session: AcpThreadSession, + pending: AcpPendingTurnInput, +): void { + sendThreadDeltas(session.bbThreadId, [ + { kind: "input.accepted", clientRequestId: pending.clientRequestId }, + ]); + const requestId = takeTurnInputRequestId(pending); + if (requestId !== null) { + sendResult(requestId, { threadId: session.bbThreadId }); + } +} + +/** + * Report input the turn ended without ever sending. Reply, never drop (#853): + * a command still waiting on the input fails instead of hanging, and no + * acceptance is reported for a turn the agent never received it in. + */ +function dropTurnInput(pending: AcpPendingTurnInput, reason: string): void { + const requestId = takeTurnInputRequestId(pending); + if (requestId !== null) { + sendError(requestId, -32000, reason); + } +} + +/** Answers a command at most once, whatever else happens to the input. */ +function takeTurnInputRequestId( + pending: AcpPendingTurnInput, +): AcpBridgeRequestId | null { + const requestId = pending.requestId; + pending.requestId = null; + return requestId; +} + +function dropQueuedTurnInputs(session: AcpThreadSession, reason: string): void { + for (const pending of session.queuedInputs.splice(0)) { + dropTurnInput(pending, reason); + } +} + function finishTurn( session: AcpThreadSession, stopReason: z.infer, ): void { session.activePromptKind = null; - session.queuedInputs = []; + dropQueuedTurnInputs(session, "ACP turn ended before the steer was sent"); session.promptRequestPending = false; session.cancelRequested = false; emitForSession(session, ACP_TURN_COMPLETED_METHOD, { @@ -1865,16 +1937,20 @@ function finishTurn( }); } -function runTurn(session: AcpThreadSession, firstInput: PromptInput[]): void { +function runTurn( + session: AcpThreadSession, + firstInput: AcpPendingTurnInput, +): void { session.activePromptKind = "turn"; emitForSession(session, ACP_TURN_STARTED_METHOD, { threadId: session.bbThreadId, }); session.turnSettled = (async () => { - let input = firstInput; + let pending = firstInput; for (;;) { if (session.stopping) { + dropTurnInput(pending, "ACP session is stopping"); finishTurn(session, "cancelled"); return; } @@ -1887,10 +1963,14 @@ function runTurn(session: AcpThreadSession, firstInput: PromptInput[]): void { method: "session/prompt", params: { sessionId: session.providerThreadId, - prompt: buildPromptContentBlocks(session, input), + prompt: buildPromptContentBlocks(session, pending.input), }, resultSchema: acpPromptResultSchema, }); + // The agent has the input now, and the turn it runs in is already + // open, so the acceptance names that turn instead of waiting as a + // pending claim any stale terminal could take (#2014). + acceptTurnInput(session, pending); // A steer that stacked behind the cancelled prompt still needs its own // cancel; otherwise this prompt can hang and strand the later input. if (session.queuedInputs.length > 0) { @@ -1900,7 +1980,12 @@ function runTurn(session: AcpThreadSession, firstInput: PromptInput[]): void { stopReason = result.stopReason; } catch (error) { session.promptRequestPending = false; - session.queuedInputs = []; + // Answered already unless the request never went out at all. + dropTurnInput(pending, "ACP turn failed before the prompt was sent"); + dropQueuedTurnInputs( + session, + "ACP turn failed before the steer was sent", + ); session.cancelRequested = false; // An exited agent already produced an error notification from the // connection's exit handler; only report in-protocol prompt failures. @@ -1921,7 +2006,7 @@ function runTurn(session: AcpThreadSession, firstInput: PromptInput[]): void { if (!session.stopping) { const next = session.queuedInputs.shift(); if (next) { - input = next; + pending = next; continue; } } @@ -1953,7 +2038,10 @@ function runTurn(session: AcpThreadSession, firstInput: PromptInput[]): void { * every other stop reason or prompt rejection fails the turn with the agent's * own reason rather than being reported as a shrunk context. */ -function startCompaction(session: AcpThreadSession): void { +function startCompaction( + session: AcpThreadSession, + pending: AcpPendingTurnInput, +): void { session.activePromptKind = "compaction"; emitForSession(session, ACP_COMPACTION_STARTED_METHOD, { threadId: session.bbThreadId, @@ -1968,15 +2056,17 @@ function startCompaction(session: AcpThreadSession): void { session.turnSettled = undefined; }; - session.turnSettled = session.connection - .request({ - method: "session/prompt", - params: { - sessionId: session.providerThreadId, - prompt: [{ type: "text", text: "/compact" }], - }, - resultSchema: acpPromptResultSchema, - }) + const promptResult = session.connection.request({ + method: "session/prompt", + params: { + sessionId: session.providerThreadId, + prompt: [{ type: "text", text: "/compact" }], + }, + resultSchema: acpPromptResultSchema, + }); + acceptTurnInput(session, pending); + + session.turnSettled = promptResult .then((result) => { finish( result.stopReason === "end_turn" @@ -2320,21 +2410,22 @@ async function handleRequest( sendError(request.id, -32000, "A turn is already active"); return; } - // Accepted-input correlation (turn/input/accepted): the assembler owns - // the queue-until-turn-opens behavior, so the bridge only reports the - // acceptance. - sendThreadDeltas(session.bbThreadId, [ - { kind: "input.accepted", clientRequestId: params.clientRequestId }, - ]); + const pending: AcpPendingTurnInput = { + clientRequestId: params.clientRequestId, + input: params.input, + requestId: request.id, + }; + // Both paths answer the command and report the acceptance once the + // `session/prompt` request carrying the input has gone out. + // // A standalone builtin `/compact` mention is bb's manual-compaction // request, not model input: it runs the agent's own compaction command // instead of becoming a prompt. if (isStandaloneBuiltinCompactCommand(params.input)) { - startCompaction(session); + startCompaction(session, pending); } else { - runTurn(session, params.input); + runTurn(session, pending); } - sendResult(request.id, { threadId: params.threadId }); return; } @@ -2353,12 +2444,15 @@ async function handleRequest( ); return; } - // A steer joins the active turn: the assembler emits the acceptance - // into the turn it holds open. - sendThreadDeltas(session.bbThreadId, [ - { kind: "input.accepted", clientRequestId: params.clientRequestId }, - ]); - session.queuedInputs.push(params.input); + // A steer joins the active turn, but the agent only learns about it + // when the cancelled prompt is reissued with it. The command answers now + // — the bridge has the input — while the acceptance waits for that + // reissue, so a steer the turn drops is never reported as accepted. + session.queuedInputs.push({ + clientRequestId: params.clientRequestId, + input: params.input, + requestId: null, + }); requestSteerCancel(session); sendResult(request.id, { threadId: params.threadId }); return; From 08d3f730a3db7df1afab0c8aa2b8c79a7c047ccd Mon Sep 17 00:00:00 2001 From: Michael Yong <610102+ymichael@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:57:42 -0700 Subject: [PATCH 015/232] Fix Cursor ACP dynamic tools (#2022) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What was wrong Cursor ACP applies its project MCP approval gate to client-supplied session MCP servers. ACP has no client permission round trip for that gate, so Cursor rejected the valid `bb-bridge` stdio config before spawning it. The same config-advertisement path exists before and after #1834, and the #1932 bootstrap fix remains valid; the missing Cursor approval was the separate root cause. ## What changed The ACP bridge now installs the exact bb-owned session MCP fingerprint in the Cursor project approval store before `session/new`, `session/load`, or `session/fork`. It limits the workaround to `cursor-agent` plus the `bb-bridge` config, preserves existing approvals, serializes concurrent updates, and removes approvals that bb installed when the session ends. The MCP child also reports `initialize` back to the bridge, giving host-side diagnostics for both config construction and successful child startup. No server/host-daemon wire contract changed, so `HOST_DAEMON_PROTOCOL_VERSION` does not need a bump. ## How you verified Added fingerprint, approval-file preservation/concurrency, session-lifecycle, and MCP initialize diagnostic regressions. These expose the missing approval before the fix and pass afterward. - `pnpm exec turbo run test --filter=bb-plugin-provider-acp --force` — 175 passed - `pnpm exec turbo run typecheck --filter=bb-plugin-provider-acp` - Isolated manual run against Cursor CLI `2026.06.19-20-24-33-653a7fb`, with approval installed after ACP `initialize` and before `session/new`; Cursor spawned and initialized the MCP server Fixes #2018 > AGENT GENERATED: by GPT-5 --- .../provider-acp/src/bridge/bridge.test.ts | 40 +++ plugins/provider-acp/src/bridge/bridge.ts | 105 ++++++-- .../src/bridge/cursor-mcp-approval.test.ts | 140 +++++++++++ .../src/bridge/cursor-mcp-approval.ts | 236 ++++++++++++++++++ .../src/bridge/mcp-server-entry.test.ts | 12 + .../src/bridge/tool-proxy-mcp.test.ts | 9 +- .../provider-acp/src/bridge/tool-proxy-mcp.ts | 40 ++- 7 files changed, 554 insertions(+), 28 deletions(-) create mode 100644 plugins/provider-acp/src/bridge/cursor-mcp-approval.test.ts create mode 100644 plugins/provider-acp/src/bridge/cursor-mcp-approval.ts diff --git a/plugins/provider-acp/src/bridge/bridge.test.ts b/plugins/provider-acp/src/bridge/bridge.test.ts index 3d5a4b9053..dfe25301e4 100644 --- a/plugins/provider-acp/src/bridge/bridge.test.ts +++ b/plugins/provider-acp/src/bridge/bridge.test.ts @@ -4,6 +4,7 @@ import { mkdtempSync, readFileSync, rmSync, + symlinkSync, } from "node:fs"; import { createConnection } from "node:net"; import { tmpdir } from "node:os"; @@ -431,6 +432,7 @@ function callDynamicToolBridge(args: { socket.on("connect", () => { socket.write( `${JSON.stringify({ + kind: "toolCall", arguments: args.toolArguments, callId: args.callId, threadId: args.threadId, @@ -1324,6 +1326,44 @@ describe("acp bridge", () => { ); }); + it("approves Cursor session MCP servers for the session lifetime (#2018)", async () => { + const cursorAgent = join(workspaceDir, "cursor-agent"); + const cursorDataDir = join(workspaceDir, "cursor-data"); + symlinkSync(process.execPath, cursorAgent); + const { providerThreadId } = await startThread({ + agent: { command: cursorAgent, args: [FAKE_AGENT_PATH] }, + envVars: { CURSOR_DATA_DIR: cursorDataDir }, + dynamicTools: [ + { + name: "update_environment_directory", + description: "Move this thread to another environment directory.", + inputSchema: { type: "object", properties: {} }, + }, + ], + }); + const projectSlug = workspaceDir + .replace(/[^a-zA-Z0-9]/gu, "-") + .replace(/-+/gu, "-") + .replace(/^-+|-+$/gu, ""); + const approvalPath = join( + cursorDataDir, + "projects", + projectSlug, + "mcp-approvals.json", + ); + const approvals = JSON.parse( + readFileSync(approvalPath, "utf8"), + ) as unknown; + expect(approvals).toEqual([ + expect.stringMatching(`^${ACP_BRIDGE_MCP_SERVER_NAME}-[a-f0-9]{16}$`), + ]); + + await stopThread(providerThreadId); + expect(JSON.parse(readFileSync(approvalPath, "utf8")) as unknown).toEqual( + [], + ); + }); + it("forwards ACP dynamic tool calls through the runtime tool-call contract", async () => { const { bbThreadId, providerThreadId } = await startThread({ dynamicTools: [ diff --git a/plugins/provider-acp/src/bridge/bridge.ts b/plugins/provider-acp/src/bridge/bridge.ts index d6b519d495..382b867f13 100644 --- a/plugins/provider-acp/src/bridge/bridge.ts +++ b/plugins/provider-acp/src/bridge/bridge.ts @@ -103,6 +103,11 @@ import { type AcpAgentConnection, type AcpAgentRequestResponder, } from "./agent-connection.js"; +import { + approveCursorSessionMcpServer, + revokeCursorSessionMcpServer, + type CursorMcpApproval, +} from "./cursor-mcp-approval.js"; import { buildAgentModelCatalog, buildAcpNativeReasoningSupport, @@ -117,6 +122,7 @@ import { type AgentModelCatalog, } from "./model-catalog.js"; import { + ACP_BRIDGE_MCP_SERVER_NAME, buildAcpMcpServerConfig, runAcpDynamicToolMcpServer, type AcpMcpServerConfig, @@ -184,6 +190,7 @@ interface AcpThreadSession { /** Resolves when the in-flight turn or maintenance prompt fully settles. */ turnSettled: Promise | undefined; pendingPermissions: Set; + cursorMcpApproval: CursorMcpApproval | undefined; } const sessionsByBbThreadId = new Map(); @@ -405,6 +412,13 @@ function handleDynamicToolBridgeSocket( ); return; } + if (request.data.kind === "initialized") { + process.stderr.write( + `acp bridge: "${ACP_BRIDGE_MCP_SERVER_NAME}" answered initialize for thread "${request.data.threadId}" (${request.data.toolCount} tools)\n`, + ); + socket.end(`${JSON.stringify({ ok: true, content: "" })}\n`); + return; + } void forwardDynamicToolCall(request.data).then((response) => { socket.end(`${JSON.stringify(response)}\n`); }); @@ -452,18 +466,20 @@ async function buildSessionMcpServers( return []; } const bridge = await ensureDynamicToolBridge(); - return [ - buildAcpMcpServerConfig({ - bridgeArgs: resolveBridgeProcessArgsForMcpServer(), - command: process.execPath, - dynamicTools, - host: bridge.host, - port: bridge.port, - runtimeEnv: resolveBridgeProcessEnvForMcpServer(), - threadId: params.threadId, - token: bridge.token, - }), - ]; + const config = buildAcpMcpServerConfig({ + bridgeArgs: resolveBridgeProcessArgsForMcpServer(), + command: process.execPath, + dynamicTools, + host: bridge.host, + port: bridge.port, + runtimeEnv: resolveBridgeProcessEnvForMcpServer(), + threadId: params.threadId, + token: bridge.token, + }); + process.stderr.write( + `acp bridge: built "${config.name}" session MCP config for thread "${params.threadId}" (${dynamicTools.length} tools)\n`, + ); + return [config]; } // --------------------------------------------------------------------------- @@ -699,13 +715,22 @@ interface AcpDynamicToolBridge { token: string; } -const dynamicToolBridgeRequestSchema = z.object({ - arguments: z.record(z.string(), z.unknown()).default({}), - callId: z.string().min(1), - threadId: z.string().min(1), - token: z.string().min(1), - tool: z.string().min(1), -}); +const dynamicToolBridgeRequestSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("initialized"), + threadId: z.string().min(1), + token: z.string().min(1), + toolCount: z.number().int().nonnegative(), + }), + z.object({ + kind: z.literal("toolCall"), + arguments: z.record(z.string(), z.unknown()).default({}), + callId: z.string().min(1), + threadId: z.string().min(1), + token: z.string().min(1), + tool: z.string().min(1), + }), +]); let cachedModelCatalog: { key: string; catalog: AgentModelCatalog } | null = null; @@ -1539,6 +1564,25 @@ function removeSession(session: AcpThreadSession): void { } } +async function releaseCursorMcpApproval( + session: AcpThreadSession, +): Promise { + const approval = session.cursorMcpApproval; + session.cursorMcpApproval = undefined; + if (!approval) { + return; + } + try { + await revokeCursorSessionMcpServer(approval); + } catch (error) { + process.stderr.write( + `acp bridge: failed to remove Cursor session MCP approval for thread "${session.bbThreadId}": ${ + error instanceof Error ? error.message : String(error) + }\n`, + ); + } +} + function getSessionByProviderThreadId( providerThreadId: string, ): AcpThreadSession | undefined { @@ -1618,6 +1662,7 @@ async function startAgentSession( if (!wasCurrent || session.stopping) { return; } + void releaseCursorMcpApproval(session); emitSessionError( session, `ACP agent "${agentLabel}" exited unexpectedly` + @@ -1651,6 +1696,7 @@ async function startAgentSession( stopping: false, turnSettled: undefined, pendingPermissions: new Set(), + cursorMcpApproval: undefined, }; try { @@ -1684,6 +1730,20 @@ async function startAgentSession( } session.supportsLoadSession = supportsLoadSession; const mcpServers = await buildSessionMcpServers(params); + const mcpServer = mcpServers[0]; + if (mcpServer) { + session.cursorMcpApproval = await approveCursorSessionMcpServer({ + agentCommand: params.agent.command, + config: mcpServer, + cwd: params.cwd, + env: childEnv, + }); + if (session.cursorMcpApproval?.installedByBb) { + process.stderr.write( + `acp bridge: installed Cursor session MCP approval for thread "${bbThreadId}"\n`, + ); + } + } let sessionId: string | undefined; let loadedConfigOptions: readonly AcpConfigOption[] | undefined; @@ -1804,6 +1864,7 @@ async function startAgentSession( session.stopping = true; connection.kill(); removeSession(session); + await releaseCursorMcpApproval(session); throw error; } } @@ -1835,6 +1896,7 @@ async function stopSession(session: AcpThreadSession): Promise { session.connection.kill(); removeSession(session); + await releaseCursorMcpApproval(session); } /** @@ -1843,7 +1905,7 @@ async function stopSession(session: AcpThreadSession): Promise { * any in-flight prompt rejection is swallowed by the turn loop because * `stopping` is already set. */ -function releaseSession(session: AcpThreadSession): void { +async function releaseSession(session: AcpThreadSession): Promise { if (session.stopping) { return; } @@ -1855,6 +1917,7 @@ function releaseSession(session: AcpThreadSession): void { cancelPendingPermissions(session); session.connection.kill(); removeSession(session); + await releaseCursorMcpApproval(session); } // --------------------------------------------------------------------------- @@ -2462,7 +2525,7 @@ async function handleRequest( const session = sessionsByBbThreadId.get(request.params.threadId); if (session) { if (request.params.intent === "release") { - releaseSession(session); + await releaseSession(session); } else { await stopSession(session); } diff --git a/plugins/provider-acp/src/bridge/cursor-mcp-approval.test.ts b/plugins/provider-acp/src/bridge/cursor-mcp-approval.test.ts new file mode 100644 index 0000000000..fee8ad3c81 --- /dev/null +++ b/plugins/provider-acp/src/bridge/cursor-mcp-approval.test.ts @@ -0,0 +1,140 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + approveCursorSessionMcpServer, + buildCursorMcpApprovalIdentifier, + revokeCursorSessionMcpServer, +} from "./cursor-mcp-approval.js"; +import type { AcpMcpServerConfig } from "./tool-proxy-mcp.js"; + +const tempDirs: string[] = []; + +function makeTempDir(prefix: string): string { + const path = mkdtempSync(join(tmpdir(), prefix)); + tempDirs.push(path); + return path; +} + +function mcpConfig(threadId = "thread-1"): AcpMcpServerConfig { + return { + name: "bb-bridge", + command: "/usr/local/bin/node", + args: ["/app/bridge.js", "--mcp-stdio"], + env: [ + { name: "BB_TOKEN", value: "secret" }, + { name: "BB_THREAD", value: threadId }, + ], + }; +} + +afterEach(() => { + for (const path of tempDirs.splice(0)) { + rmSync(path, { recursive: true, force: true }); + } +}); + +describe("Cursor ACP session MCP approvals", () => { + it("matches Cursor's approval fingerprint for a normalized stdio server", () => { + expect( + buildCursorMcpApprovalIdentifier({ + config: mcpConfig(), + projectRoot: "/workspace/project", + }), + ).toBe("bb-bridge-d4709a3db84ddb48"); + }); + + it("does not touch Cursor data for other ACP agents", async () => { + const cursorDataDir = makeTempDir("bb-cursor-data-"); + const workspace = makeTempDir("bb-cursor-workspace-"); + + await expect( + approveCursorSessionMcpServer({ + agentCommand: "opencode", + config: mcpConfig(), + cwd: workspace, + env: { CURSOR_DATA_DIR: cursorDataDir }, + }), + ).resolves.toBeUndefined(); + expect(() => readFileSync(join(cursorDataDir, "projects"))).toThrow(); + }); + + it("preserves unrelated approvals and removes its session approval on release", async () => { + const cursorDataDir = makeTempDir("bb-cursor-data-"); + const workspace = makeTempDir("bb-cursor-workspace-"); + const env = { CURSOR_DATA_DIR: cursorDataDir }; + const first = await approveCursorSessionMcpServer({ + agentCommand: "/opt/cursor/cursor-agent", + config: mcpConfig(), + cwd: workspace, + env, + }); + if (!first) { + throw new Error("Cursor approval was not installed"); + } + await revokeCursorSessionMcpServer(first); + writeFileSync(first.path, '["user-approved-server"]\n', "utf8"); + + const approvals = await Promise.all([ + approveCursorSessionMcpServer({ + agentCommand: "cursor-agent", + config: mcpConfig("thread-a"), + cwd: workspace, + env, + }), + approveCursorSessionMcpServer({ + agentCommand: "cursor-agent.exe", + config: mcpConfig("thread-b"), + cwd: workspace, + env, + }), + ]); + expect(approvals.every(Boolean)).toBe(true); + const installed = approvals.flatMap((approval) => + approval ? [approval] : [], + ); + const stored = JSON.parse(readFileSync(first.path, "utf8")) as string[]; + expect(stored[0]).toBe("user-approved-server"); + expect(stored.slice(1).sort()).toEqual( + installed.map((approval) => approval.approval).sort(), + ); + + await Promise.all(installed.map(revokeCursorSessionMcpServer)); + expect(JSON.parse(readFileSync(first.path, "utf8")) as unknown).toEqual([ + "user-approved-server", + ]); + expect(() => readFileSync(`${first.path}.bb-lock`)).toThrow(); + }); + + it("does not remove an approval Cursor already had", async () => { + const cursorDataDir = makeTempDir("bb-cursor-data-"); + const workspace = makeTempDir("bb-cursor-workspace-"); + const env = { CURSOR_DATA_DIR: cursorDataDir }; + const installed = await approveCursorSessionMcpServer({ + agentCommand: "cursor-agent", + config: mcpConfig(), + cwd: workspace, + env, + }); + if (!installed) { + throw new Error("Cursor approval was not installed"); + } + await revokeCursorSessionMcpServer(installed); + writeFileSync(installed.path, JSON.stringify([installed.approval]), "utf8"); + + const existing = await approveCursorSessionMcpServer({ + agentCommand: "cursor-agent", + config: mcpConfig(), + cwd: workspace, + env, + }); + expect(existing?.installedByBb).toBe(false); + if (existing) { + await revokeCursorSessionMcpServer(existing); + } + expect(JSON.parse(readFileSync(installed.path, "utf8")) as unknown).toEqual( + [installed.approval], + ); + }); +}); diff --git a/plugins/provider-acp/src/bridge/cursor-mcp-approval.ts b/plugins/provider-acp/src/bridge/cursor-mcp-approval.ts new file mode 100644 index 0000000000..cfd3d694c1 --- /dev/null +++ b/plugins/provider-acp/src/bridge/cursor-mcp-approval.ts @@ -0,0 +1,236 @@ +import { execFile } from "node:child_process"; +import { createHash, randomBytes } from "node:crypto"; +import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { + ACP_BRIDGE_MCP_SERVER_NAME, + type AcpMcpServerConfig, +} from "./tool-proxy-mcp.js"; + +const CURSOR_MCP_APPROVAL_FILE = "mcp-approvals.json"; +const CURSOR_APPROVAL_LOCK_STALE_MS = 30_000; +const CURSOR_APPROVAL_LOCK_TIMEOUT_MS = 5_000; + +export interface CursorMcpApproval { + approval: string; + installedByBb: boolean; + path: string; +} + +function errorCode(error: unknown): unknown { + return error instanceof Error && "code" in error ? error.code : undefined; +} + +function cursorAgentCommand(command: string): boolean { + return ( + basename(command) + .toLowerCase() + .replace(/\.(?:bat|cmd|exe)$/u, "") === "cursor-agent" + ); +} + +function cursorProjectSlug(projectRoot: string): string { + return projectRoot + .replace(/[^a-zA-Z0-9]/gu, "-") + .replace(/-+/gu, "-") + .replace(/^-+|-+$/gu, ""); +} + +function cursorDataDirectory( + env: Readonly>, +): string { + const configured = env.CURSOR_DATA_DIR?.trim(); + if (configured) { + return configured; + } + const home = env.HOME?.trim() || env.USERPROFILE?.trim() || homedir(); + return join(home, ".cursor"); +} + +function cursorMcpServerConfig(config: AcpMcpServerConfig): { + command: string; + args: string[]; + env: Record; +} { + return { + command: config.command, + args: config.args, + env: Object.fromEntries(config.env.map(({ name, value }) => [name, value])), + }; +} + +/** + * Cursor hashes the project root and normalized MCP server config into the + * approval identifier stored in its project data. Its ACP adapter applies + * this approval gate to client-supplied session MCP servers, but ACP has no + * permission round-trip through which bb can answer that gate (#2018). + */ +export function buildCursorMcpApprovalIdentifier(args: { + config: AcpMcpServerConfig; + projectRoot: string; +}): string { + const fingerprint = createHash("sha256") + .update( + JSON.stringify({ + path: args.projectRoot, + server: cursorMcpServerConfig(args.config), + }), + ) + .digest("hex") + .slice(0, 16); + return `${args.config.name}-${fingerprint}`; +} + +async function resolveCursorProjectRoot(args: { + cwd: string; + env: Readonly>; +}): Promise { + return new Promise((resolveRoot) => { + execFile( + "git", + ["rev-parse", "--show-toplevel"], + { cwd: args.cwd, env: args.env, windowsHide: true }, + (error, stdout) => { + const root = stdout.trim(); + resolveRoot(error === null && root !== "" ? root : resolve(args.cwd)); + }, + ); + }); +} + +async function readApprovals(path: string): Promise { + let text: string; + try { + text = await readFile(path, "utf8"); + } catch (error) { + if (errorCode(error) === "ENOENT") { + return []; + } + throw error; + } + const value: unknown = JSON.parse(text); + if ( + !Array.isArray(value) || + !value.every((item) => typeof item === "string") + ) { + throw new Error(`Cursor MCP approval file is not a string array: ${path}`); + } + return value; +} + +async function writeApprovals(path: string, approvals: readonly string[]) { + await mkdir(dirname(path), { recursive: true }); + const tempPath = `${path}.bb-${process.pid}-${randomBytes(6).toString("hex")}.tmp`; + try { + await writeFile(tempPath, `${JSON.stringify(approvals, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + await rename(tempPath, path); + } catch (error) { + await rm(tempPath, { force: true }); + throw error; + } +} + +async function acquireApprovalLock(path: string): Promise<() => Promise> { + const lockPath = `${path}.bb-lock`; + const deadline = Date.now() + CURSOR_APPROVAL_LOCK_TIMEOUT_MS; + await mkdir(dirname(path), { recursive: true }); + for (;;) { + try { + await mkdir(lockPath, { mode: 0o700 }); + return () => rm(lockPath, { recursive: true, force: true }); + } catch (error) { + if (errorCode(error) !== "EEXIST") { + throw error; + } + } + + try { + const lockStat = await stat(lockPath); + if (Date.now() - lockStat.mtimeMs > CURSOR_APPROVAL_LOCK_STALE_MS) { + await rm(lockPath, { recursive: true, force: true }); + continue; + } + } catch (error) { + if (errorCode(error) === "ENOENT") { + continue; + } + throw error; + } + + if (Date.now() >= deadline) { + throw new Error(`Timed out updating Cursor MCP approvals: ${path}`); + } + await new Promise((resolveDelay) => setTimeout(resolveDelay, 25)); + } +} + +async function mutateApprovals( + path: string, + mutate: (approvals: string[]) => string[], +): Promise { + const releaseLock = await acquireApprovalLock(path); + try { + const approvals = await readApprovals(path); + const nextApprovals = mutate(approvals); + if ( + approvals.length !== nextApprovals.length || + approvals.some((approval, index) => approval !== nextApprovals[index]) + ) { + await writeApprovals(path, nextApprovals); + } + } finally { + await releaseLock(); + } +} + +export async function approveCursorSessionMcpServer(args: { + agentCommand: string; + config: AcpMcpServerConfig; + cwd: string; + env: Readonly>; +}): Promise { + if ( + !cursorAgentCommand(args.agentCommand) || + args.config.name !== ACP_BRIDGE_MCP_SERVER_NAME + ) { + return undefined; + } + const projectRoot = await resolveCursorProjectRoot({ + cwd: args.cwd, + env: args.env, + }); + const path = join( + cursorDataDirectory(args.env), + "projects", + cursorProjectSlug(projectRoot), + CURSOR_MCP_APPROVAL_FILE, + ); + const approval = buildCursorMcpApprovalIdentifier({ + config: args.config, + projectRoot, + }); + let installedByBb = false; + await mutateApprovals(path, (approvals) => { + if (approvals.includes(approval)) { + return approvals; + } + installedByBb = true; + return [...approvals, approval]; + }); + return { approval, installedByBb, path }; +} + +export async function revokeCursorSessionMcpServer( + approval: CursorMcpApproval, +): Promise { + if (!approval.installedByBb) { + return; + } + await mutateApprovals(approval.path, (approvals) => + approvals.filter((candidate) => candidate !== approval.approval), + ); +} diff --git a/plugins/provider-acp/src/bridge/mcp-server-entry.test.ts b/plugins/provider-acp/src/bridge/mcp-server-entry.test.ts index 5ae6b2cea3..09d9f15f1a 100644 --- a/plugins/provider-acp/src/bridge/mcp-server-entry.test.ts +++ b/plugins/provider-acp/src/bridge/mcp-server-entry.test.ts @@ -45,6 +45,7 @@ interface AdvertisedMcpServer { const children: ChildProcess[] = []; const tempDirs: string[] = []; const bridgeLines: BridgeLine[] = []; +let bridgeStderr = ""; let nextRequestId = 1; function makeTempDir(prefix: string): string { @@ -104,6 +105,7 @@ function spawnBridgeLikeTheAgentRuntime(dataDir: string): ChildProcess { ); children.push(bridge); bridge.stderr?.on("data", (chunk: Buffer) => { + bridgeStderr += chunk.toString(); process.stderr.write(`[bridge] ${chunk.toString()}`); }); if (!bridge.stdout) { @@ -261,6 +263,7 @@ afterEach(() => { rmSync(dir, { recursive: true, force: true }); } bridgeLines.length = 0; + bridgeStderr = ""; }); describe("bb-bridge MCP server entry point (#1918)", () => { @@ -279,6 +282,15 @@ describe("bb-bridge MCP server entry point (#1918)", () => { expect(stdoutLines[0]).toContain( `"serverInfo":{"name":"${ACP_BRIDGE_MCP_SERVER_NAME}"`, ); + await waitFor( + () => + bridgeStderr.includes( + `"${ACP_BRIDGE_MCP_SERVER_NAME}" answered initialize`, + ) + ? true + : undefined, + "bridge-side MCP initialize diagnostic", + ); // The entry must be the bridge module itself, never the bootstrap. expect(config.args.some((arg) => arg.includes("bridge-worker"))).toBe( false, diff --git a/plugins/provider-acp/src/bridge/tool-proxy-mcp.test.ts b/plugins/provider-acp/src/bridge/tool-proxy-mcp.test.ts index 77d3dcacc5..90074b3bc4 100644 --- a/plugins/provider-acp/src/bridge/tool-proxy-mcp.test.ts +++ b/plugins/provider-acp/src/bridge/tool-proxy-mcp.test.ts @@ -43,7 +43,14 @@ async function listenFakeBridge(args: { buffer += chunk; const newline = buffer.indexOf("\n"); if (newline === -1) return; - requests.push(JSON.parse(buffer.slice(0, newline))); + const request = JSON.parse(buffer.slice(0, newline)) as { + kind?: unknown; + }; + if (request.kind === "initialized") { + socket.end(`${JSON.stringify({ ok: true, content: "" })}\n`); + return; + } + requests.push(request); setTimeout(() => { socket.end( `${JSON.stringify({ ok: true, content: '{"answers":{"Which?":"B"}}' })}\n`, diff --git a/plugins/provider-acp/src/bridge/tool-proxy-mcp.ts b/plugins/provider-acp/src/bridge/tool-proxy-mcp.ts index a5d20c97f4..4501a679c5 100644 --- a/plugins/provider-acp/src/bridge/tool-proxy-mcp.ts +++ b/plugins/provider-acp/src/bridge/tool-proxy-mcp.ts @@ -34,14 +34,31 @@ export interface BuildAcpMcpServerConfigArgs { token: string; } -interface BridgeToolCallRequest { - arguments: Record; - callId: string; +interface BridgeRequestBase { threadId: string; token: string; - tool: string; } +type BridgeRequest = BridgeRequestBase & + ( + | { kind: "initialized"; toolCount: number } + | { + kind: "toolCall"; + arguments: Record; + callId: string; + tool: string; + } + ); + +type BridgeRequestPayload = + | { kind: "initialized"; toolCount: number } + | { + kind: "toolCall"; + arguments: Record; + callId: string; + tool: string; + }; + type BridgeToolCallResponse = | { ok: true; content: string; isError?: boolean } | { ok: false; error: string }; @@ -147,14 +164,14 @@ function mcpToolCallId(toolName: string): string { function callBridge( env: McpServerEnvironment, - request: Omit, + request: BridgeRequestPayload, ): Promise { return new Promise((resolve, reject) => { const socket = createConnection({ host: env.host, port: env.port }); let buffer = ""; socket.setEncoding("utf8"); socket.on("connect", () => { - const payload: BridgeToolCallRequest = { + const payload: BridgeRequest = { ...request, threadId: env.threadId, token: env.token, @@ -237,6 +254,16 @@ async function handleRequest( capabilities: { tools: {} }, serverInfo: { name: ACP_BRIDGE_MCP_SERVER_NAME, version: "1.0.0" }, }); + void callBridge(env, { + kind: "initialized", + toolCount: env.tools.length, + }).catch((error) => { + process.stderr.write( + `bb-bridge MCP: failed to report initialize: ${ + error instanceof Error ? error.message : String(error) + }\n`, + ); + }); return; case "tools/list": @@ -274,6 +301,7 @@ async function handleRequest( }); try { const result = await callBridge(env, { + kind: "toolCall", arguments: toolArguments, callId: mcpToolCallId(tool.name), tool: tool.name, From 78cd3c48aa877f93cc71ed24f2d285f25c833260 Mon Sep 17 00:00:00 2001 From: Michael Yong <610102+ymichael@users.noreply.github.com> Date: Thu, 20 Aug 2026 01:29:15 -0700 Subject: [PATCH 016/232] Fix Pi context usage updates during tool loops (#2024) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What was wrong Pi sampled context-window usage only on SDK `agent_end`, which fires after the entire agent run. A tool-heavy run contains multiple SDK `turn_end` events—each after an assistant response and its tool results—so bb's context meter stayed stale throughout the tool loop even though Pi's underlying context estimate was changing. See #2023. ## What changed - Sample and emit Pi context-window usage on every `turn_end`, while retaining the existing `compaction_end` update. - Stop sampling again at `agent_end`; that event is still forwarded normally for completion and checkpoint handling, but the final `turn_end` already emitted the same context snapshot. - Add a bridge regression test with an intermediate tool-result turn and a final response. It asserts that both usage snapshots arrive and that `agent_end` does not duplicate the final one. - Bump `HOST_DAEMON_PROTOCOL_VERSION` from 141 to 142 because the bundled Pi bridge's daemon-to-server event cadence changed and enrolled daemons need to update. ## How you verified - `pnpm exec turbo run test --filter=@bb/agent-runtime --force -- src/pi/bridge/__tests__/bridge.test.ts` — 27 passed. The new regression fails before the fix because only the `agent_end` sample is emitted. - `pnpm exec turbo run typecheck --filter=@bb/agent-runtime --filter=@bb/host-daemon-contract --force` — passed. - `pnpm exec turbo run test --filter=@bb/host-daemon-contract --force -- test/contract.test.ts` — 38 passed. - The full host-daemon-contract suite was also run locally: 51 tests passed and its unrelated fixed gzip-byte measurement test differed under local Node 26.3.1/zlib (`payload-size.test.ts`); the protocol contract itself passed. Fixes #2023 > AGENT GENERATED: by GPT-5 --- .../src/pi/bridge/__tests__/bridge.test.ts | 118 ++++++++++++++++++ .../agent-runtime/src/pi/bridge/bridge.ts | 4 +- packages/host-daemon-contract/src/protocol.ts | 7 +- .../test/contract.test.ts | 2 +- 4 files changed, 128 insertions(+), 3 deletions(-) diff --git a/packages/agent-runtime/src/pi/bridge/__tests__/bridge.test.ts b/packages/agent-runtime/src/pi/bridge/__tests__/bridge.test.ts index 30bd551c4f..8a2fc0ac00 100644 --- a/packages/agent-runtime/src/pi/bridge/__tests__/bridge.test.ts +++ b/packages/agent-runtime/src/pi/bridge/__tests__/bridge.test.ts @@ -337,6 +337,59 @@ function createAgentEndEvent(): AgentSessionEvent { }; } +function createTurnEndEvent( + stopReason: "toolUse" | "stop", +): AgentSessionEvent { + const hasToolResult = stopReason === "toolUse"; + return { + type: "turn_end", + message: { + role: "assistant", + content: hasToolResult + ? [ + { + type: "toolCall", + id: "call-read-1", + name: "read", + arguments: { path: "/tmp/example.txt" }, + }, + ] + : [{ type: "text", text: "done" }], + api: "openai-responses", + provider: "openai", + model: "gpt-5.4", + usage: { + input: 10, + output: 2, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 12, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }, + stopReason, + timestamp: 0, + }, + toolResults: hasToolResult + ? [ + { + role: "toolResult", + toolCallId: "call-read-1", + toolName: "read", + content: [{ type: "text", text: "tool output" }], + isError: false, + timestamp: 0, + }, + ] + : [], + }; +} + describe("pi bridge", () => { beforeEach(() => { vi.clearAllMocks(); @@ -1218,6 +1271,71 @@ describe("pi bridge", () => { } }); + it("reports context usage after every Pi turn_end without duplicating agent_end", async () => { + const bridge = createBridgeJsonRpcTestHarness(handleLine); + const piSession = createControlledPiAgentSession(); + piSession.getContextUsage + .mockReturnValueOnce({ + tokens: 10_500, + contextWindow: 1_050_000, + percent: 1, + }) + .mockReturnValueOnce({ + tokens: 11_750, + contextWindow: 1_050_000, + percent: (11_750 / 1_050_000) * 100, + }); + piSession.prompt.mockImplementation(async () => { + piSession.emit({ type: "agent_start" }); + piSession.emit(createTurnEndEvent("toolUse")); + piSession.emit(createTurnEndEvent("stop")); + piSession.emit(createAgentEndEvent()); + }); + mockCreateAgentSession.mockResolvedValue({ session: piSession }); + + try { + bridge.sendRequest( + 52, + "thread/start", + sessionParams({ threadId: "thread-context-usage" }), + ); + await bridge.waitForResponse(52); + + bridge.sendRequest( + 53, + "turn/start", + turnStartParams("thread-context-usage", [ + { type: "text", text: "read and respond" }, + ]), + ); + await bridge.waitForResponse(53); + await bridge.flushWork(); + + const usageEvents = threadEvents(bridge.messages).filter( + (event) => event.type === "thread/contextWindowUsage/updated", + ); + expect(usageEvents).toEqual([ + expect.objectContaining({ + contextWindowUsage: { + usedTokens: 10_500, + modelContextWindow: 1_050_000, + estimated: true, + }, + }), + expect.objectContaining({ + contextWindowUsage: { + usedTokens: 11_750, + modelContextWindow: 1_050_000, + estimated: true, + }, + }), + ]); + expect(piSession.getContextUsage).toHaveBeenCalledTimes(2); + } finally { + bridge.restore(); + } + }); + it("compacts the session instead of prompting for a standalone /compact command", async () => { const bridge = createBridgeJsonRpcTestHarness(handleLine); const piSession = createControlledPiAgentSession(); diff --git a/packages/agent-runtime/src/pi/bridge/bridge.ts b/packages/agent-runtime/src/pi/bridge/bridge.ts index e446a1fb5b..e97a6863d3 100644 --- a/packages/agent-runtime/src/pi/bridge/bridge.ts +++ b/packages/agent-runtime/src/pi/bridge/bridge.ts @@ -470,7 +470,9 @@ function createOnPiEvent( ? event : { ...event, providerCheckpointId }, }); - if (event.type === "agent_end" || event.type === "compaction_end") { + // Pi emits turn_end only after the assistant response and its tool results + // have entered session context, so this samples what the next request sees. + if (event.type === "turn_end" || event.type === "compaction_end") { emitContextWindowUsage(args.threadId); } }; diff --git a/packages/host-daemon-contract/src/protocol.ts b/packages/host-daemon-contract/src/protocol.ts index 0b30a18894..98924174f5 100644 --- a/packages/host-daemon-contract/src/protocol.ts +++ b/packages/host-daemon-contract/src/protocol.ts @@ -1,3 +1,8 @@ +// Version 142 ships Pi context-window usage after every SDK turn ends, once +// its assistant response and tool results are both reflected in the session. +// Older bundled bridges report only after the full agent run ends, leaving the +// meter stale throughout multi-tool turns. +// // Version 141 extends the consumed-not-queued acceptance rule to the remaining // providers. Pi reports `input.accepted` for a turn only once it read the // input: a prompt pi queues behind a live run stays unaccepted, and the @@ -80,7 +85,7 @@ // // The version mismatch is what triggers the enrolled daemon's automatic update // instead of an `invalid-message` reconnect loop. -export const HOST_DAEMON_PROTOCOL_VERSION = 141 as const; +export const HOST_DAEMON_PROTOCOL_VERSION = 142 as const; /** * Absolute ceiling for any executable artifact delivered to a host daemon — diff --git a/packages/host-daemon-contract/test/contract.test.ts b/packages/host-daemon-contract/test/contract.test.ts index e808f2b4c7..8100442870 100644 --- a/packages/host-daemon-contract/test/contract.test.ts +++ b/packages/host-daemon-contract/test/contract.test.ts @@ -1138,7 +1138,7 @@ describe("host-daemon command schemas", () => { // mixed version. Version 113 carried the Devin Desktop open target rename // and remains part of the protocol lineage. it("uses the current host-daemon protocol version", () => { - expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(141); + expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(142); expect(HOST_ARTIFACT_MAX_BYTES).toBe(256 * 1024 * 1024); }); From 2ff5828b81d761bcf77f77d4953c173977a37799 Mon Sep 17 00:00:00 2001 From: Michael Yong <610102+ymichael@users.noreply.github.com> Date: Thu, 20 Aug 2026 01:47:40 -0700 Subject: [PATCH 017/232] Fix long-thread database and event-loop stalls (#2025) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What was wrong Long-lived threads repeatedly scanned JSON payloads for todo tool names, rebuilt the full conversation outline for unrelated command and reasoning events, and pruned arbitrarily large sets of resolved deltas in one synchronous SQLite write. Those paths blocked the server event loop and delayed otherwise small event inserts. Separately, stall diagnostics attributed awaited RPC wall time as event-loop work, treated laptop suspension as a runtime stall, and warned on fresh 512-event bursts before there was evidence that delivery was stuck. ## What changed - Add a guarded generated tool-name column and partial todo lookup index through Drizzle migration 0104. - Key the conversation-outline cache by the latest outline-relevant event while still returning the current thread sequence. - Use the materialized parent-tool-call column in remaining event queries and cap each resolved-delta prune pass at 500 rows. - Attribute event-loop stalls only to completed synchronous work; keep awaited routes visible only as current work. - Reset server and host event-loop samples after likely system suspension and report the host heartbeat wake as informational. - Require a depth-512 daemon event queue to remain queued for five seconds before warning, while retaining the unconditional thirty-second age warning. The timeline byte limit and default event budget are intentionally unchanged. There are no server/host wire changes, so HOST_DAEMON_PROTOCOL_VERSION is unchanged. ## How you verified - pnpm exec turbo run test --filter=@bb/db --force: 406 tests passed. - pnpm exec turbo run test --filter=@bb/host-daemon --force: 586 tests passed. - Affected server suites: 91 current tests passed, including outline caching, event-loop attribution, and timeline-window regression coverage. - pnpm exec turbo run test --filter=@bb/config --filter=@bb/domain --force: 108 config and 137 domain tests passed. - Affected app tests passed: 43 tests. - pnpm exec turbo run typecheck for @bb/db, @bb/domain, @bb/config, @bb/host-daemon, @bb/server, and @bb/app: all passed. - Reproduced the todo lookup on a copied 41k-event thread: median 11.46ms to 0.04ms. Reproduced a 5k-delta prune: median 10.05ms to 1.22ms per bounded pass. The full server suite was also attempted, but existing npm-artifact packaging tests do not produce a clean signal in this sandbox; all suites covering changed server paths passed. Fixes: N/A — log-driven performance investigation. > AGENT GENERATED: by GPT-5 --- .../src/event-loop-stall-monitor.test.ts | 56 + .../src/event-loop-stall-monitor.ts | 13 +- apps/host-daemon/src/event-sink.test.ts | 32 +- apps/host-daemon/src/event-sink.ts | 13 +- .../host-daemon/src/server-connection.test.ts | 27 + apps/host-daemon/src/server-connection.ts | 19 +- apps/host-daemon/src/system-suspension.ts | 14 + apps/server/src/routes/threads/data.ts | 35 +- .../system/event-loop-stall-monitor.ts | 21 +- .../src/services/system/event-loop-work.ts | 20 +- .../test/public/public-thread-data.test.ts | 88 + .../system/event-loop-stall-monitor.test.ts | 24 +- packages/db/drizzle/0104_chunky_redwing.sql | 2 + packages/db/drizzle/meta/0104_snapshot.json | 3733 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/data/events.ts | 205 +- packages/db/src/data/index.ts | 1 + packages/db/src/schema.ts | 12 + packages/db/test/data/events.test.ts | 42 + packages/db/test/migrate.test.ts | 16 + packages/db/test/query-plans.test.ts | 42 +- 21 files changed, 4304 insertions(+), 118 deletions(-) create mode 100644 apps/host-daemon/src/event-loop-stall-monitor.test.ts create mode 100644 apps/host-daemon/src/system-suspension.ts create mode 100644 packages/db/drizzle/0104_chunky_redwing.sql create mode 100644 packages/db/drizzle/meta/0104_snapshot.json diff --git a/apps/host-daemon/src/event-loop-stall-monitor.test.ts b/apps/host-daemon/src/event-loop-stall-monitor.test.ts new file mode 100644 index 0000000000..37fa5fc1b1 --- /dev/null +++ b/apps/host-daemon/src/event-loop-stall-monitor.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const perfHooksMock = vi.hoisted(() => ({ + histogram: { + disable: vi.fn(), + enable: vi.fn(), + max: 600_000_000_000, + mean: 1_000_000, + percentile: vi.fn(() => 1_000_000), + reset: vi.fn(), + }, +})); + +vi.mock("node:perf_hooks", () => ({ + monitorEventLoopDelay: vi.fn(() => perfHooksMock.histogram), +})); + +import { startEventLoopStallMonitor } from "./event-loop-stall-monitor.js"; + +describe("host event-loop stall monitor", () => { + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it("suppresses histogram delays accumulated while the system was suspended", () => { + vi.useFakeTimers(); + let now = 0; + const logger = { warn: vi.fn() }; + const monitor = startEventLoopStallMonitor({ logger, now: () => now }); + + now = 300_000; + vi.advanceTimersByTime(5_000); + + expect(logger.warn).not.toHaveBeenCalled(); + expect(perfHooksMock.histogram.reset).toHaveBeenCalledOnce(); + monitor.stop(); + }); + + it("still reports a sub-minute event-loop stall", () => { + vi.useFakeTimers(); + perfHooksMock.histogram.max = 600_000_000; + let now = 0; + const logger = { warn: vi.fn() }; + const monitor = startEventLoopStallMonitor({ logger, now: () => now }); + + now = 5_000; + vi.advanceTimersByTime(5_000); + + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ maxDelayMs: 600 }), + "Host daemon event loop stalled", + ); + monitor.stop(); + }); +}); diff --git a/apps/host-daemon/src/event-loop-stall-monitor.ts b/apps/host-daemon/src/event-loop-stall-monitor.ts index 9651576fbc..631d02241f 100644 --- a/apps/host-daemon/src/event-loop-stall-monitor.ts +++ b/apps/host-daemon/src/event-loop-stall-monitor.ts @@ -1,8 +1,11 @@ import { monitorEventLoopDelay } from "node:perf_hooks"; import type { HostDaemonLogger } from "./logger.js"; +import { isLikelySystemSuspensionDelay } from "./system-suspension.js"; interface EventLoopStallMonitorOptions { logger: Pick; + /** Injectable monotonic-enough wall clock for tests. */ + now?: () => number; } interface EventLoopStallMonitor { @@ -28,12 +31,20 @@ export function startEventLoopStallMonitor( const thresholdMs = DEFAULT_EVENT_LOOP_STALL_LOG_THRESHOLD_MS; const intervalMs = DEFAULT_EVENT_LOOP_STALL_MONITOR_INTERVAL_MS; const resolutionMs = DEFAULT_EVENT_LOOP_STALL_MONITOR_RESOLUTION_MS; + const now = options.now ?? (() => Date.now()); const histogram = monitorEventLoopDelay({ resolution: resolutionMs }); histogram.enable(); + let lastSampleAt = now(); const timer = setInterval(() => { + const sampledAt = now(); + const sampleGapMs = sampledAt - lastSampleAt; + lastSampleAt = sampledAt; const maxDelayMs = nanosecondsToMilliseconds(histogram.max); - if (maxDelayMs >= thresholdMs) { + if ( + !isLikelySystemSuspensionDelay({ gapMs: sampleGapMs, intervalMs }) && + maxDelayMs >= thresholdMs + ) { options.logger.warn( { intervalMs, diff --git a/apps/host-daemon/src/event-sink.test.ts b/apps/host-daemon/src/event-sink.test.ts index 9bafbb7787..1189b87323 100644 --- a/apps/host-daemon/src/event-sink.test.ts +++ b/apps/host-daemon/src/event-sink.test.ts @@ -144,11 +144,13 @@ describe("event sink", () => { expect(postEvents).toHaveBeenCalledTimes(1); }); - it("warns once when the queue grows large while undelivered", () => { + it("warns once when a large queue remains undelivered", () => { const logger = createLogger(); + let now = 0; const sink = createEventSink({ isSessionOpen: () => false, logger, + now: () => now, postEvents: acceptingPostEvents(), }); @@ -157,12 +159,36 @@ describe("event sink", () => { } expect(logger.warn).not.toHaveBeenCalled(); - // Crossing the depth threshold fires the tripwire once... + // A fresh event burst is throughput, not evidence of a stalled delivery. sink.emit({ threadId: "thr_1", event: systemErrorEvent("thr_1") }); + expect(logger.warn).not.toHaveBeenCalled(); + + // Remaining above the depth threshold for five seconds fires once. + now = 5_000; sink.emit({ threadId: "thr_1", event: systemErrorEvent("thr_1") }); expect(logger.warn).toHaveBeenCalledTimes(1); expect(logger.warn).toHaveBeenCalledWith( - expect.objectContaining({ queueDepth: 512 }), + expect.objectContaining({ queueAgeMs: 5_000, queueDepth: 513 }), + expect.any(String), + ); + }); + + it("warns when even a small queue is stalled for thirty seconds", () => { + const logger = createLogger(); + let now = 0; + const sink = createEventSink({ + isSessionOpen: () => false, + logger, + now: () => now, + postEvents: acceptingPostEvents(), + }); + + sink.emit({ threadId: "thr_1", event: systemErrorEvent("thr_1") }); + now = 30_000; + sink.emit({ threadId: "thr_1", event: systemErrorEvent("thr_1") }); + + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ queueAgeMs: 30_000, queueDepth: 2 }), expect.any(String), ); }); diff --git a/apps/host-daemon/src/event-sink.ts b/apps/host-daemon/src/event-sink.ts index 4280556341..1d04a698c5 100644 --- a/apps/host-daemon/src/event-sink.ts +++ b/apps/host-daemon/src/event-sink.ts @@ -14,6 +14,7 @@ const DEFAULT_DEBOUNCE_MS = 100; // growing. These only warn — they never drop, fault, or bound the queue. If // they fire in practice, that is the signal to add real backpressure. const QUEUE_DEPTH_WARN_THRESHOLD = 512; +const QUEUE_DEPTH_WARN_MIN_AGE_MS = 5_000; const QUEUE_AGE_WARN_THRESHOLD_MS = 30_000; export interface EventSinkInput { @@ -30,6 +31,8 @@ export interface EventPostResult { export interface CreateEventSinkOptions { isSessionOpen: () => boolean; logger: Pick; + /** Injectable wall clock for queue-age tests. */ + now?: () => number; postEvents: (events: HostDaemonEventEnvelope[]) => Promise; } @@ -116,6 +119,7 @@ function summarizeRejectedEvents( } export function createEventSink(options: CreateEventSinkOptions): EventSink { + const now = options.now ?? (() => Date.now()); const queue: HostDaemonEventEnvelope[] = []; let flushTimer: ReturnType | null = null; let flushPromise: Promise | null = null; @@ -130,10 +134,11 @@ export function createEventSink(options: CreateEventSinkOptions): EventSink { return; } const queueDepth = queue.length; - const queueAgeMs = Date.now() - backedUpSinceMs; + const queueAgeMs = now() - backedUpSinceMs; if ( - queueDepth < QUEUE_DEPTH_WARN_THRESHOLD && - queueAgeMs < QUEUE_AGE_WARN_THRESHOLD_MS + queueAgeMs < QUEUE_AGE_WARN_THRESHOLD_MS && + (queueDepth < QUEUE_DEPTH_WARN_THRESHOLD || + queueAgeMs < QUEUE_DEPTH_WARN_MIN_AGE_MS) ) { return; } @@ -281,7 +286,7 @@ export function createEventSink(options: CreateEventSinkOptions): EventSink { throw new EventSinkDisposedError(); } if (backedUpSinceMs === null) { - backedUpSinceMs = Date.now(); + backedUpSinceMs = now(); } queue.push({ threadId: input.threadId, diff --git a/apps/host-daemon/src/server-connection.test.ts b/apps/host-daemon/src/server-connection.test.ts index ed0ba79216..9784121a5d 100644 --- a/apps/host-daemon/src/server-connection.test.ts +++ b/apps/host-daemon/src/server-connection.test.ts @@ -354,6 +354,33 @@ describe("ServerConnection", () => { } }); + it("reports a system-suspension gap without calling it a heartbeat stall", async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const { connection, logger } = createConnectionFixture({ + heartbeatIntervalMs: 5_000, + leaseTimeoutMs: 30_000, + }); + try { + await connection.start(); + await vi.advanceTimersByTimeAsync(5_000); + + vi.setSystemTime(300_000); + await vi.advanceTimersByTimeAsync(5_000); + + expect(logger.warn).not.toHaveBeenCalledWith( + expect.anything(), + "Host daemon heartbeat timer delayed", + ); + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ gapMs: 300_000 }), + "Host daemon resumed after likely system suspension", + ); + } finally { + await connection.shutdown(); + } + }); + it("queues output above high water and flushes it before lifecycle messages", async () => { vi.useFakeTimers(); const { connection, webSocket } = createConnectionFixture(); diff --git a/apps/host-daemon/src/server-connection.ts b/apps/host-daemon/src/server-connection.ts index 1efb980382..846ce89908 100644 --- a/apps/host-daemon/src/server-connection.ts +++ b/apps/host-daemon/src/server-connection.ts @@ -24,6 +24,7 @@ import { type ReconnectingWebSocketLike, type ServerConnectionOptions, } from "./server-connection-support.js"; +import { isLikelySystemSuspensionDelay } from "./system-suspension.js"; import { normalizeCaughtError, runtimeErrorLogFields } from "./error-utils.js"; import { ServerResponseError } from "./server-client.js"; @@ -756,7 +757,23 @@ export class ServerConnection { if (lastTickAt !== null) { const gapMs = now - lastTickAt; const thresholdMs = session.leaseTimeoutMs / 2; - if (gapMs > thresholdMs) { + if ( + isLikelySystemSuspensionDelay({ + gapMs, + intervalMs: session.heartbeatIntervalMs, + }) + ) { + this.options.logger.info( + { + gapMs, + heartbeatIntervalMs: session.heartbeatIntervalMs, + leaseTimeoutMs: session.leaseTimeoutMs, + sessionId: session.sessionId, + websocketReadyState: this.websocket?.readyState ?? null, + }, + "Host daemon resumed after likely system suspension", + ); + } else if (gapMs > thresholdMs) { this.options.logger.warn( { gapMs, diff --git a/apps/host-daemon/src/system-suspension.ts b/apps/host-daemon/src/system-suspension.ts new file mode 100644 index 0000000000..d6d8b70351 --- /dev/null +++ b/apps/host-daemon/src/system-suspension.ts @@ -0,0 +1,14 @@ +const LIKELY_SYSTEM_SUSPENSION_MIN_DELAY_MS = 60_000; + +/** + * Long timer gaps on a laptop are overwhelmingly process suspension during + * system sleep, not JavaScript monopolizing the event loop. Keep sub-minute + * delays visible as real stalls while preventing a wake from flooding the log + * with event-loop and heartbeat warnings for time the process did not run. + */ +export function isLikelySystemSuspensionDelay(args: { + gapMs: number; + intervalMs: number; +}): boolean { + return args.gapMs - args.intervalMs >= LIKELY_SYSTEM_SUSPENSION_MIN_DELAY_MS; +} diff --git a/apps/server/src/routes/threads/data.ts b/apps/server/src/routes/threads/data.ts index 51b2c8fb34..16a728e51f 100644 --- a/apps/server/src/routes/threads/data.ts +++ b/apps/server/src/routes/threads/data.ts @@ -3,6 +3,7 @@ import { formatCustomAcpAgentProviderId } from "@bb/config/bb-app-managed-config import { getAppSettings, getLatestThreadSequence, + getLatestStoredConversationOutlineSequence, listQueuedThreadMessages, } from "@bb/db"; import type { Hono } from "hono"; @@ -315,19 +316,15 @@ export function registerThreadDataRoutes(app: Hono, deps: AppDeps): void { const slowTimelineBuildLogger = createSlowThreadTimelineBuildLogger({ logger: deps.logger, }); - // The conversation outline reprojects the entire thread, so memoize it per - // (thread, maxSeq): repeated polls at a stable revision are served from - // cache. Any appended event bumps maxSeq and forces a rebuild, so a thread - // streaming many deltas rebuilds per batch — acceptable because the client - // only fetches the outline when the minimap is mounted and refetches are - // driven by the (debounced) realtime invalidation, not per token. The key - // omits the provider/env inputs the timeline cache tracks because the outline - // emits only event-derived fields (id/role/preview/attachment counts); add - // them here if the outline ever surfaces a provider- or workspace-derived - // value. A small LRU bounds memory across many viewed threads. + // The conversation outline reprojects the entire thread, so memoize it by + // the newest event that can affect the outline. Command output, reasoning, + // and usage events still advance maxSeq in the response but do not invalidate + // the expensive projection. The key includes thread metadata that can affect + // grouping; add provider/env inputs if the outline ever surfaces them. A + // small LRU bounds memory across many viewed threads. const conversationOutlineCache = new Map< string, - ThreadConversationOutlineResponse + ThreadConversationOutlineResponse["items"] >(); const CONVERSATION_OUTLINE_CACHE_MAX_ENTRIES = 128; @@ -422,13 +419,23 @@ export function registerThreadDataRoutes(app: Hono, deps: AppDeps): void { get(routes.conversationOutline, (context) => { const thread = requirePublicThread(deps.db, context.req.param("id")); const maxSeq = getLatestThreadSequence(deps.db, { threadId: thread.id }); - const cacheKey = `${thread.id}:${maxSeq}`; + const outlineSequence = getLatestStoredConversationOutlineSequence( + deps.db, + { threadId: thread.id }, + ); + const cacheKey = JSON.stringify([ + thread.id, + outlineSequence, + thread.status, + thread.title, + thread.titleFallback, + ]); const cached = conversationOutlineCache.get(cacheKey); if (cached !== undefined) { // Re-insert to mark most-recently-used. conversationOutlineCache.delete(cacheKey); conversationOutlineCache.set(cacheKey, cached); - return context.json(cached); + return context.json({ items: cached, maxSeq }); } const response = buildThreadConversationOutline(deps.db, thread, { maxSeq, @@ -437,7 +444,7 @@ export function registerThreadDataRoutes(app: Hono, deps: AppDeps): void { thread.providerId, ), }); - conversationOutlineCache.set(cacheKey, response); + conversationOutlineCache.set(cacheKey, response.items); while ( conversationOutlineCache.size > CONVERSATION_OUTLINE_CACHE_MAX_ENTRIES ) { diff --git a/apps/server/src/services/system/event-loop-stall-monitor.ts b/apps/server/src/services/system/event-loop-stall-monitor.ts index 8a604a77d8..6dcfd0254f 100644 --- a/apps/server/src/services/system/event-loop-stall-monitor.ts +++ b/apps/server/src/services/system/event-loop-stall-monitor.ts @@ -5,6 +5,8 @@ import { takeEventLoopWorkWindowSnapshot } from "./event-loop-work.js"; export interface EventLoopStallMonitorOptions { logger: Pick; + /** Injectable wall clock for timer-gap tests. */ + now?: () => number; } export interface EventLoopStallMonitor { @@ -14,6 +16,7 @@ export interface EventLoopStallMonitor { const DEFAULT_EVENT_LOOP_STALL_LOG_THRESHOLD_MS = 500; const DEFAULT_EVENT_LOOP_STALL_MONITOR_INTERVAL_MS = 5_000; const DEFAULT_EVENT_LOOP_STALL_MONITOR_RESOLUTION_MS = 20; +const LIKELY_SYSTEM_SUSPENSION_MIN_DELAY_MS = 60_000; const NANOSECONDS_PER_MILLISECOND = 1_000_000; function nanosecondsToMilliseconds(durationNs: number): number { @@ -23,15 +26,26 @@ function nanosecondsToMilliseconds(durationNs: number): number { export function startEventLoopStallMonitor( options: EventLoopStallMonitorOptions, ): EventLoopStallMonitor { + const now = options.now ?? (() => Date.now()); const histogram = monitorEventLoopDelay({ resolution: DEFAULT_EVENT_LOOP_STALL_MONITOR_RESOLUTION_MS, }); histogram.enable(); + let lastSampleAt = now(); const interval = setInterval(() => { + const sampledAt = now(); + const sampleGapMs = sampledAt - lastSampleAt; + lastSampleAt = sampledAt; const maxDelayMs = nanosecondsToMilliseconds(histogram.max); const work = takeEventLoopWorkWindowSnapshot(); - if (maxDelayMs >= DEFAULT_EVENT_LOOP_STALL_LOG_THRESHOLD_MS) { + const resumedAfterLikelySystemSuspension = + sampleGapMs - DEFAULT_EVENT_LOOP_STALL_MONITOR_INTERVAL_MS >= + LIKELY_SYSTEM_SUSPENSION_MIN_DELAY_MS; + if ( + !resumedAfterLikelySystemSuspension && + maxDelayMs >= DEFAULT_EVENT_LOOP_STALL_LOG_THRESHOLD_MS + ) { // `info`, not `debug`: the packaged app runs at `info`, so a `debug` line // here is unreachable in production — which is exactly where a stalled // loop matters. A stall this long blocks the daemon-facing @@ -40,8 +54,9 @@ export function startEventLoopStallMonitor( // work, not just UI refreshes. Threshold-gated, so a healthy server // stays silent. // currentWork is still in flight. lastWork is the latest finish. - // slowestWork is the longest unit in this histogram window, so a later - // heartbeat cannot hide the block that produced histogram.max. + // slowestWork is the longest synchronous unit in this histogram window, + // so time spent awaiting a daemon RPC cannot be mistaken for the block + // that produced histogram.max. options.logger.info( { intervalMs: DEFAULT_EVENT_LOOP_STALL_MONITOR_INTERVAL_MS, diff --git a/apps/server/src/services/system/event-loop-work.ts b/apps/server/src/services/system/event-loop-work.ts index ad774a1549..db027bf854 100644 --- a/apps/server/src/services/system/event-loop-work.ts +++ b/apps/server/src/services/system/event-loop-work.ts @@ -3,6 +3,7 @@ import { performance } from "node:perf_hooks"; import { roundDurationMs } from "../lib/duration.js"; interface EventLoopWorkFrame { + blocksEventLoop: boolean; id: number; label: string; parentId: number | null; @@ -10,6 +11,7 @@ interface EventLoopWorkFrame { } interface CompletedEventLoopWork { + blocksEventLoop: boolean; durationMs: number; label: string; } @@ -28,10 +30,11 @@ const completedInWindow: CompletedEventLoopWork[] = []; let nextFrameId = 1; let lastCompleted: CompletedEventLoopWork | null = null; -function enterEventLoopWork(label: string): number { +function enterEventLoopWork(label: string, blocksEventLoop: boolean): number { const id = nextFrameId; nextFrameId += 1; activeFrames.set(id, { + blocksEventLoop, id, label, parentId: currentFrameId.getStore() ?? null, @@ -47,6 +50,7 @@ function leaveEventLoopWork(id: number): void { return; } const completed: CompletedEventLoopWork = { + blocksEventLoop: frame.blocksEventLoop, durationMs: performance.now() - frame.startedAt, label: frame.label, }; @@ -86,17 +90,13 @@ function formatActiveWork(): string | null { function selectSlowestWork(): CompletedEventLoopWork | null { let slowest: CompletedEventLoopWork | null = null; for (const completed of completedInWindow) { + if (!completed.blocksEventLoop) { + continue; + } if (slowest === null || completed.durationMs > slowest.durationMs) { slowest = completed; } } - const now = performance.now(); - for (const frame of activeFrames.values()) { - const durationMs = now - frame.startedAt; - if (slowest === null || durationMs > slowest.durationMs) { - slowest = { durationMs, label: frame.label }; - } - } return slowest; } @@ -120,7 +120,7 @@ export function takeEventLoopWorkWindowSnapshot(): EventLoopWorkSnapshot { } export function runEventLoopWorkSync(label: string, work: () => T): T { - const id = enterEventLoopWork(label); + const id = enterEventLoopWork(label, true); return currentFrameId.run(id, () => { try { return work(); @@ -134,7 +134,7 @@ export async function runEventLoopWork( label: string, work: () => Promise | T, ): Promise { - const id = enterEventLoopWork(label); + const id = enterEventLoopWork(label, false); return currentFrameId.run(id, async () => { try { return await work(); diff --git a/apps/server/test/public/public-thread-data.test.ts b/apps/server/test/public/public-thread-data.test.ts index 3d9e103bef..79fcb5c54a 100644 --- a/apps/server/test/public/public-thread-data.test.ts +++ b/apps/server/test/public/public-thread-data.test.ts @@ -603,6 +603,94 @@ describe("public thread data routes", () => { }); }); + it("reuses the conversation outline across timeline-only events", async () => { + await withTestHarness(async (harness) => { + const { environment, thread } = seedThreadFixture(harness); + seedEvent(harness.deps, { + threadId: thread.id, + environmentId: environment.id, + sequence: 1, + type: "system/manager/user_message", + scope: threadScope(), + data: { text: "Visible response" }, + }); + const prepareSpy = vi.spyOn(harness.db.$client, "prepare"); + const countFullOutlineQueries = () => + prepareSpy.mock.calls.filter(([source]) => { + return ( + typeof source === "string" && + source.includes('"created_at"') && + source.includes('"data"') && + source.includes("union all") + ); + }).length; + + const firstResponse = await harness.app.request( + `/api/v1/threads/${thread.id}/conversation-outline`, + ); + expect(firstResponse.status).toBe(200); + const first = threadConversationOutlineResponseSchema.parse( + await readJson(firstResponse), + ); + expect(first.maxSeq).toBe(1); + expect(countFullOutlineQueries()).toBe(1); + + seedEvent(harness.deps, { + threadId: thread.id, + environmentId: environment.id, + providerThreadId: "provider-thread-1", + sequence: 2, + type: "item/completed", + scope: turnScope("turn-1"), + data: { + item: { + id: "command-1", + type: "commandExecution", + command: "pwd", + cwd: "/tmp/test", + status: "completed", + approvalStatus: null, + aggregatedOutput: "/tmp/test", + exitCode: 0, + }, + }, + }); + + const cachedResponse = await harness.app.request( + `/api/v1/threads/${thread.id}/conversation-outline`, + ); + expect(cachedResponse.status).toBe(200); + const cached = threadConversationOutlineResponseSchema.parse( + await readJson(cachedResponse), + ); + expect(cached).toEqual({ items: first.items, maxSeq: 2 }); + expect(countFullOutlineQueries()).toBe(1); + + seedEvent(harness.deps, { + threadId: thread.id, + environmentId: environment.id, + sequence: 3, + type: "system/manager/user_message", + scope: threadScope(), + data: { text: "New visible response" }, + }); + + const changedResponse = await harness.app.request( + `/api/v1/threads/${thread.id}/conversation-outline`, + ); + expect(changedResponse.status).toBe(200); + const changed = threadConversationOutlineResponseSchema.parse( + await readJson(changedResponse), + ); + expect(changed.maxSeq).toBe(3); + expect(changed.items.map((item) => item.preview)).toEqual([ + "Visible response", + "New visible response", + ]); + expect(countFullOutlineQueries()).toBe(2); + }); + }); + it("summarizes attachment-only messages in the conversation outline", async () => { await withTestHarness(async (harness) => { const { environment, thread } = seedThreadFixture(harness); diff --git a/apps/server/test/system/event-loop-stall-monitor.test.ts b/apps/server/test/system/event-loop-stall-monitor.test.ts index ff33bf347b..321c58f24f 100644 --- a/apps/server/test/system/event-loop-stall-monitor.test.ts +++ b/apps/server/test/system/event-loop-stall-monitor.test.ts @@ -141,6 +141,25 @@ describe("event loop stall monitor", () => { monitor.stop(); }); + it("suppresses histogram delays accumulated while the system was suspended", () => { + const histogram = installHistogram({ + maxDelayMs: 300_000, + meanDelayMs: 25, + p99DelayMs: 450, + }); + const logger = { info: vi.fn() }; + let now = 0; + + const monitor = startEventLoopStallMonitor({ logger, now: () => now }); + now = 300_000; + vi.advanceTimersByTime(EVENT_LOOP_STALL_MONITOR_INTERVAL_MS); + + expect(logger.info).not.toHaveBeenCalled(); + expect(histogram.reset).toHaveBeenCalledTimes(1); + + monitor.stop(); + }); + it("stops sampling after stop", () => { const histogram = installHistogram({ maxDelayMs: 500, @@ -158,7 +177,7 @@ describe("event loop stall monitor", () => { expect(logger.info).not.toHaveBeenCalled(); }); - it("includes the in-flight unit of work on the stall report", async () => { + it("does not attribute an in-flight async wait as the event loop block", async () => { installHistogram({ maxDelayMs: 500, meanDelayMs: 25, @@ -182,7 +201,8 @@ describe("event loop stall monitor", () => { currentWork: "GET /api/v1/threads/thr_example/timeline", lastWork: null, lastWorkMs: null, - slowestWork: "GET /api/v1/threads/thr_example/timeline", + slowestWork: null, + slowestWorkMs: null, }), "Event loop stalled", ); diff --git a/packages/db/drizzle/0104_chunky_redwing.sql b/packages/db/drizzle/0104_chunky_redwing.sql new file mode 100644 index 0000000000..0629852f0e --- /dev/null +++ b/packages/db/drizzle/0104_chunky_redwing.sql @@ -0,0 +1,2 @@ +ALTER TABLE `events` ADD `tool_name` text GENERATED ALWAYS AS (CASE WHEN json_valid(data) THEN json_extract(data, '$.item.tool') END) VIRTUAL;--> statement-breakpoint +CREATE INDEX `events_todo_tool_call_thread_tool_sequence_idx` ON `events` (`thread_id`,`tool_name`,`sequence`) WHERE "events"."item_kind" = 'toolCall' AND "events"."type" IN ('item/started', 'item/completed'); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0104_snapshot.json b/packages/db/drizzle/meta/0104_snapshot.json new file mode 100644 index 0000000000..de22001952 --- /dev/null +++ b/packages/db/drizzle/meta/0104_snapshot.json @@ -0,0 +1,3733 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "4b28b004-f79b-40fd-8d7d-60fd4543c92e", + "prevId": "67842c38-c4d3-43d1-ac01-87a19a58edd4", + "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", + "tableTo": "user", + "columnsFrom": [ + "referenceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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": {} + }, + "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 + }, + "managed": { + "name": "managed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 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 + }, + "destroy_attempt_id": { + "name": "destroy_attempt_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retire_requested_at": { + "name": "retire_requested_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_provision_type": { + "name": "workspace_provision_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "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_project_idx": { + "name": "environments_project_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "environments_status_idx": { + "name": "environments_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "environments_project_id_projects_id_fk": { + "name": "environments_project_id_projects_id_fk", + "tableFrom": "environments", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environments_host_id_hosts_id_fk": { + "name": "environments_host_id_hosts_id_fk", + "tableFrom": "environments", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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": "'{}'" + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "generated": { + "as": "(CASE WHEN json_valid(data) THEN json_extract(data, '$.item.tool') END)", + "type": "virtual" + } + }, + "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_tool_call_parent_lookup_idx": { + "name": "events_tool_call_parent_lookup_idx", + "columns": [ + "thread_id", + "item_id", + "sequence" + ], + "isUnique": false, + "where": "\"events\".\"item_kind\" = 'toolCall'" + }, + "events_todo_tool_call_thread_tool_sequence_idx": { + "name": "events_todo_tool_call_thread_tool_sequence_idx", + "columns": [ + "thread_id", + "tool_name", + "sequence" + ], + "isUnique": false, + "where": "\"events\".\"item_kind\" = 'toolCall' AND \"events\".\"type\" IN ('item/started', 'item/completed')" + }, + "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" + ], + "isUnique": false, + "where": "\"events\".\"parent_tool_call_id\" IS NOT NULL" + }, + "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" + ], + "isUnique": false, + "where": "\"events\".\"item_kind\" = 'backgroundTask'" + }, + "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" + ], + "isUnique": false, + "where": "\"events\".\"type\" IN ('item/started', 'item/completed', 'item/backgroundTask/completed')" + }, + "events_environment_idx": { + "name": "events_environment_idx", + "columns": [ + "environment_id" + ], + "isUnique": false + }, + "events_completed_item_truncation_idx": { + "name": "events_completed_item_truncation_idx", + "columns": [ + "item_kind", + "created_at", + "id" + ], + "isUnique": false, + "where": "\"events\".\"type\" = 'item/completed'" + }, + "events_goal_thread_sequence_idx": { + "name": "events_goal_thread_sequence_idx", + "columns": [ + "thread_id", + "sequence" + ], + "isUnique": false, + "where": "\"events\".\"type\" IN ('thread/goal/updated', 'thread/goal/cleared')" + } + }, + "foreignKeys": { + "events_thread_id_threads_id_fk": { + "name": "events_thread_id_threads_id_fk", + "tableFrom": "events", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "events_environment_id_environments_id_fk": { + "name": "events_environment_id_environments_id_fk", + "tableFrom": "events", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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 + }, + "host_type": { + "name": "host_type", + "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", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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 + }, + "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 + } + }, + "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", + "tableTo": "plugin_artifacts", + "columnsFrom": [ + "active_artifact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "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", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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 + }, + "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_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", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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 + }, + "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", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_sources_host_id_hosts_id_fk": { + "name": "project_sources_host_id_hosts_id_fk", + "tableFrom": "project_sources", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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" + ], + "isUnique": true, + "where": "\"projects\".\"kind\" = 'personal'" + } + }, + "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", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "prompt_history_entries_thread_id_threads_id_fk": { + "name": "prompt_history_entries_thread_id_threads_id_fk", + "tableFrom": "prompt_history_entries", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "queued_thread_messages": { + "name": "queued_thread_messages", + "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 + }, + "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 + }, + "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 + }, + "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 + } + }, + "foreignKeys": { + "queued_thread_messages_thread_id_threads_id_fk": { + "name": "queued_thread_messages_thread_id_threads_id_fk", + "tableFrom": "queued_thread_messages", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "terminal_sessions_environment_id_environments_id_fk": { + "name": "terminal_sessions_environment_id_environments_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "terminal_sessions_host_id_hosts_id_fk": { + "name": "terminal_sessions_host_id_hosts_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "terminal_sessions_daemon_session_id_host_daemon_sessions_id_fk": { + "name": "terminal_sessions_daemon_session_id_host_daemon_sessions_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "host_daemon_sessions", + "columnsFrom": [ + "daemon_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "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", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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'" + }, + "parent_thread_id": { + "name": "parent_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 + }, + "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_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" + ], + "isUnique": false, + "where": "\"threads\".\"pinned_at\" IS NOT NULL" + }, + "threads_environment_idx": { + "name": "threads_environment_idx", + "columns": [ + "environment_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" + ], + "isUnique": false, + "where": "\"threads\".\"deleted_at\" IS NULL" + } + }, + "foreignKeys": { + "threads_project_id_projects_id_fk": { + "name": "threads_project_id_projects_id_fk", + "tableFrom": "threads", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "threads_environment_id_environments_id_fk": { + "name": "threads_environment_id_environments_id_fk", + "tableFrom": "threads", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "threads_section_id_thread_sections_id_fk": { + "name": "threads_section_id_thread_sections_id_fk", + "tableFrom": "threads", + "tableTo": "thread_sections", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "threads_parent_thread_id_threads_id_fk": { + "name": "threads_parent_thread_id_threads_id_fk", + "tableFrom": "threads", + "tableTo": "threads", + "columnsFrom": [ + "parent_thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "threads_source_thread_id_threads_id_fk": { + "name": "threads_source_thread_id_threads_id_fk", + "tableFrom": "threads", + "tableTo": "threads", + "columnsFrom": [ + "source_thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "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 737f48e3d3..dd70b64157 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -729,6 +729,13 @@ "when": 1787181956957, "tag": "0103_wandering_mongoose", "breakpoints": true + }, + { + "idx": 104, + "version": "6", + "when": 1787212680694, + "tag": "0104_chunky_redwing", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/data/events.ts b/packages/db/src/data/events.ts index 9cb653af56..fe9cab25db 100644 --- a/packages/db/src/data/events.ts +++ b/packages/db/src/data/events.ts @@ -62,7 +62,7 @@ import { const STORED_EVENT_SEQUENCE_LOOKUP_CHUNK_SIZE = 250; /** - * Keep the scalar byte-total fast path above the default 1,500-event timeline + * Keep the scalar byte-total fast path above the default timeline event * window without letting a client-selected details range aggregate an entire * thread before the byte-limited iterator gets a chance to stop early. */ @@ -71,6 +71,10 @@ const SQLITE_MAX_VARIABLE_NUMBER = 32_766; // This OR query prepares with 995 keys. A 996th key reaches the configured // SQLite expression-depth limit of 1,000. const CLIENT_TURN_REQUEST_KEY_BATCH_SIZE = 995; +// Pruning is output-preserving maintenance on the synchronous SQLite writer. +// Bound each pass so a delta-heavy completed turn cannot stall event ingestion +// for seconds while deleting thousands of redundant rows at once. +const RESOLVED_ITEM_DELTA_PRUNE_BATCH_SIZE = 500; interface QueryInSqliteVariableBatchesArgs { dedupeKey: (value: TValue) => string; @@ -1135,6 +1139,9 @@ export interface ListStoredConversationOutlineEventRowsArgs { threadId: string; } +export type GetLatestStoredConversationOutlineSequenceArgs = + ListStoredConversationOutlineEventRowsArgs; + export interface ListStoredTimelineWindowEventRowsArgs { beforeSequence?: number; excludedTypes?: readonly ThreadEventType[]; @@ -2108,22 +2115,23 @@ export function listTodoSnapshotEventRowsForThread( db: DbConnection, args: ListTodoSnapshotEventRowsForThreadArgs, ): StoredEventRow[] { - const itemTypes = [ - "item/started", - "item/completed", - ] satisfies ThreadEventType[]; - const rows = db .select(storedEventRowFields) .from(events) .where( and( eq(events.threadId, args.threadId), - inArray(events.type, itemTypes), - eq(events.itemKind, "toolCall"), - sql`json_extract(${events.data}, '$.item.tool') IN ( - 'TodoWrite', 'TaskCreate', 'TaskUpdate', 'TaskList', 'TaskGet' - )`, + // Keep the partial-index predicates literal. SQLite cannot prove that + // bound parameters imply the index WHERE clause at prepare time. + sql`${events.type} IN ('item/started', 'item/completed')`, + sql`${events.itemKind} = 'toolCall'`, + inArray(events.toolName, [ + "TodoWrite", + "TaskCreate", + "TaskUpdate", + "TaskList", + "TaskGet", + ]), ), ) .all(); @@ -2522,64 +2530,102 @@ export function listRecentStoredEventRows( * dominate long-lived histories. Keep only conversation-producing rows plus * the small set of structural lifecycle/error rows that affect their grouping. */ +const conversationOutlineLifecycleTypes = [ + "client/turn/requested", + "turn/input/accepted", + "turn/started", + "turn/completed", + "system/manager/user_message", + "system/thread/interrupted", + "system/error", + "provider/error", + "item/agentMessage/delta", + "item/plan/delta", +] satisfies ThreadEventType[]; +const conversationOutlineItemKinds = [ + "agentMessage", + "plan", +] satisfies ThreadEventItemType[]; +const conversationOutlineStructuralItemKinds = [ + "backgroundTask", + "toolCall", +] satisfies ThreadEventItemType[]; +const conversationOutlineStructuralLifecycleTypes = [ + "item/started", + "item/completed", + "item/backgroundTask/progress", + "item/backgroundTask/completed", +] satisfies ThreadEventType[]; + +function storedConversationOutlineLifecycleWhere(threadId: string): SQL { + return and( + eq(events.threadId, threadId), + inArray(events.type, conversationOutlineLifecycleTypes), + )!; +} + +function storedConversationOutlineCompletedWhere(threadId: string): SQL { + return and( + eq(events.threadId, threadId), + eq(events.type, "item/completed"), + inArray(events.itemKind, conversationOutlineItemKinds), + )!; +} + +function storedConversationOutlineStructuralWhere(threadId: string): SQL { + return and( + eq(events.threadId, threadId), + inArray(events.type, conversationOutlineStructuralLifecycleTypes), + inArray(events.itemKind, conversationOutlineStructuralItemKinds), + )!; +} + +/** + * Sequence revision of the event subset that can change the conversation + * outline. Generic command/reasoning/file events advance the thread high-water + * sequence without changing this revision, allowing the server to reuse the + * expensive full-history outline projection during streaming work. + */ +export function getLatestStoredConversationOutlineSequence( + db: DbConnection, + args: GetLatestStoredConversationOutlineSequenceArgs, +): number { + const lifecycle = db + .select({ sequence: max(events.sequence) }) + .from(events) + .where(storedConversationOutlineLifecycleWhere(args.threadId)); + const completedConversation = db + .select({ sequence: max(events.sequence) }) + .from(events) + .where(storedConversationOutlineCompletedWhere(args.threadId)); + const structural = db + .select({ sequence: max(events.sequence) }) + .from(events) + .where(storedConversationOutlineStructuralWhere(args.threadId)); + + return unionAll(lifecycle, completedConversation, structural) + .all() + .reduce((latest, row) => Math.max(latest, row.sequence ?? 0), 0); +} + export function listStoredConversationOutlineEventRows( db: DbConnection, args: ListStoredConversationOutlineEventRowsArgs, ): StoredEventRow[] { - const lifecycleTypes = [ - "client/turn/requested", - "turn/input/accepted", - "turn/started", - "turn/completed", - "system/manager/user_message", - "system/thread/interrupted", - "system/error", - "provider/error", - "item/agentMessage/delta", - "item/plan/delta", - ] satisfies ThreadEventType[]; - const conversationItemKinds = [ - "agentMessage", - "plan", - ] satisfies ThreadEventItemType[]; - const structuralItemKinds = [ - "backgroundTask", - "toolCall", - ] satisfies ThreadEventItemType[]; - const structuralItemLifecycleTypes = [ - "item/started", - "item/completed", - "item/backgroundTask/progress", - "item/backgroundTask/completed", - ] satisfies ThreadEventType[]; - const lifecycleRows = db .select(storedEventRowFields) .from(events) - .where( - and( - eq(events.threadId, args.threadId), - inArray(events.type, lifecycleTypes), - ), - ); + .where(storedConversationOutlineLifecycleWhere(args.threadId)); const completedConversationRows = db .select(storedEventRowFields) .from(events) - .where( - and( - eq(events.threadId, args.threadId), - eq(events.type, "item/completed"), - inArray(events.itemKind, conversationItemKinds), - ), - ); + .where(storedConversationOutlineCompletedWhere(args.threadId)); const structuralRows = db .select(storedEventRowFields) .from(events) .where( and( - eq(events.threadId, args.threadId), - inArray(events.type, structuralItemLifecycleTypes), - inArray(events.itemKind, structuralItemKinds), + storedConversationOutlineStructuralWhere(args.threadId), isNotSupersededBackgroundTaskProgress, ), ); @@ -3277,7 +3323,7 @@ export function listThreadTurnInterruptionEventStates( WHERE latest.thread_id = ${events.threadId} AND latest.type = 'turn/started' AND latest.turn_id IS NOT NULL - AND COALESCE(json_extract(latest.data, '$.parentToolCallId'), '') = '' + AND latest.parent_tool_call_id IS NULL )`, sql`NOT EXISTS ( SELECT 1 @@ -3476,7 +3522,7 @@ function pruneLatestRowsForContextWindowUsageBeforeSequence( WHERE nested_turn_started.thread_id = usage.thread_id AND nested_turn_started.turn_id = usage.turn_id AND nested_turn_started.type = 'turn/started' - AND COALESCE(json_extract(nested_turn_started.data, '$.parentToolCallId'), '') <> '' + AND nested_turn_started.parent_tool_call_id IS NOT NULL ) ) DELETE FROM events @@ -3558,50 +3604,53 @@ export function pruneResolvedItemDeltas( const result = db.run( sql`DELETE FROM events - WHERE ${events.threadId} = ${args.threadId} - AND ${events.type} IN ( + WHERE rowid IN ( + SELECT candidate.rowid + FROM events candidate + WHERE candidate.thread_id = ${args.threadId} + AND candidate.type IN ( ${"item/agentMessage/delta" satisfies PrunableResolvedDeltaEventType}, ${"item/commandExecution/outputDelta" satisfies PrunableResolvedDeltaEventType}, ${"item/reasoning/summaryTextDelta" satisfies PrunableResolvedDeltaEventType}, ${"item/reasoning/textDelta" satisfies PrunableResolvedDeltaEventType} ) - AND ${events.itemId} IS NOT NULL - AND ${events.turnId} IS NOT NULL + AND candidate.item_id IS NOT NULL + AND candidate.turn_id IS NOT NULL AND EXISTS ( SELECT 1 FROM events completed - WHERE completed.thread_id = ${events.threadId} - AND completed.turn_id = ${events.turnId} + WHERE completed.thread_id = candidate.thread_id + AND completed.turn_id = candidate.turn_id AND completed.type = ${itemCompletedType} AND completed.item_kind = CASE - WHEN ${events.type} = ${"item/agentMessage/delta" satisfies PrunableResolvedDeltaEventType} + WHEN candidate.type = ${"item/agentMessage/delta" satisfies PrunableResolvedDeltaEventType} THEN ${prunableDeltaMatches["item/agentMessage/delta"]} - WHEN ${events.type} = ${"item/commandExecution/outputDelta" satisfies PrunableResolvedDeltaEventType} + WHEN candidate.type = ${"item/commandExecution/outputDelta" satisfies PrunableResolvedDeltaEventType} THEN ${prunableDeltaMatches["item/commandExecution/outputDelta"]} - WHEN ${events.type} = ${"item/reasoning/summaryTextDelta" satisfies PrunableResolvedDeltaEventType} + WHEN candidate.type = ${"item/reasoning/summaryTextDelta" satisfies PrunableResolvedDeltaEventType} THEN ${prunableDeltaMatches["item/reasoning/summaryTextDelta"]} - WHEN ${events.type} = ${"item/reasoning/textDelta" satisfies PrunableResolvedDeltaEventType} + WHEN candidate.type = ${"item/reasoning/textDelta" satisfies PrunableResolvedDeltaEventType} THEN ${prunableDeltaMatches["item/reasoning/textDelta"]} END - AND completed.item_id = ${events.itemId} + AND completed.item_id = candidate.item_id AND ( - ${events.type} <> ${"item/commandExecution/outputDelta" satisfies PrunableResolvedDeltaEventType} + candidate.type <> ${"item/commandExecution/outputDelta" satisfies PrunableResolvedDeltaEventType} OR json_type(completed.data, '$.item.aggregatedOutput') IS NOT NULL ) - AND COALESCE(json_extract(completed.data, '$.item.parentToolCallId'), '') = - COALESCE(json_extract(${events.data}, '$.parentToolCallId'), '') + AND completed.parent_tool_call_id IS candidate.parent_tool_call_id ) AND EXISTS ( SELECT 1 FROM events earlier_delta - WHERE earlier_delta.thread_id = ${events.threadId} - AND earlier_delta.turn_id = ${events.turnId} - AND earlier_delta.type = ${events.type} - AND earlier_delta.item_id = ${events.itemId} - AND COALESCE(json_extract(earlier_delta.data, '$.parentToolCallId'), '') = - COALESCE(json_extract(${events.data}, '$.parentToolCallId'), '') - AND earlier_delta.sequence < ${events.sequence} - )`, + WHERE earlier_delta.thread_id = candidate.thread_id + AND earlier_delta.turn_id = candidate.turn_id + AND earlier_delta.type = candidate.type + AND earlier_delta.item_id = candidate.item_id + AND earlier_delta.parent_tool_call_id IS candidate.parent_tool_call_id + AND earlier_delta.sequence < candidate.sequence + ) + LIMIT ${RESOLVED_ITEM_DELTA_PRUNE_BATCH_SIZE} + )`, ); return result.changes; diff --git a/packages/db/src/data/index.ts b/packages/db/src/data/index.ts index 6e599d7185..b0253b7fd7 100644 --- a/packages/db/src/data/index.ts +++ b/packages/db/src/data/index.ts @@ -338,6 +338,7 @@ export { getLastStoredTurnRequestEvent, getStoredTurnRequestEventForTurn, getLatestThreadOutputEventRow, + getLatestStoredConversationOutlineSequence, getLatestThreadSystemErrorEventRow, getLatestThreadSequence, getLatestStoredEventRowByType, diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 5d31ad2a17..cea1666dd8 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -739,6 +739,10 @@ export const events = sqliteTable( itemKind: text("item_kind").$type(), parentToolCallId: text("parent_tool_call_id"), data: text("data").notNull().default("{}"), + toolName: text("tool_name").generatedAlwaysAs( + sql`CASE WHEN json_valid(data) THEN json_extract(data, '$.item.tool') END`, + { mode: "virtual" }, + ), createdAt: integer("created_at").notNull(), }, (table) => [ @@ -753,6 +757,14 @@ export const events = sqliteTable( index("events_tool_call_parent_lookup_idx") .on(table.threadId, table.itemId, table.sequence) .where(sql`${table.itemKind} = 'toolCall'`), + // The latest timeline page restores todo/task head state by tool name. + // Keep that lookup on a tiny generated-column index instead of parsing every + // tool-call payload in a long-running thread on every timeline refresh. + index("events_todo_tool_call_thread_tool_sequence_idx") + .on(table.threadId, table.toolName, table.sequence) + .where( + sql`${table.itemKind} = 'toolCall' AND ${table.type} IN ('item/started', 'item/completed')`, + ), index("events_parent_tool_call_thread_parent_sequence_idx") .on(table.threadId, table.parentToolCallId, table.sequence) .where(sql`${table.parentToolCallId} IS NOT NULL`), diff --git a/packages/db/test/data/events.test.ts b/packages/db/test/data/events.test.ts index bb446bb107..10a3e091ec 100644 --- a/packages/db/test/data/events.test.ts +++ b/packages/db/test/data/events.test.ts @@ -2927,6 +2927,48 @@ describe("events", () => { ).toEqual([1, 4]); }); + it("bounds each resolved delta prune pass", () => { + const { db, thread } = setup(); + const deltas = Array.from({ length: 502 }, (_, index) => ({ + threadId: thread.id, + sequence: index + 1, + scope: turnScope("turn-bounded-prune"), + type: "item/agentMessage/delta" as const, + itemId: "msg-bounded-prune", + itemKind: null, + parentToolCallId: null, + data: JSON.stringify({ + itemId: "msg-bounded-prune", + delta: `chunk-${index}`, + }), + })); + insertEvents(db, noopNotifier, [ + ...deltas, + { + threadId: thread.id, + sequence: 503, + scope: turnScope("turn-bounded-prune"), + type: "item/completed", + itemId: "msg-bounded-prune", + itemKind: "agentMessage", + parentToolCallId: null, + data: JSON.stringify({ + item: { + id: "msg-bounded-prune", + type: "agentMessage", + text: "Complete response", + }, + }), + }, + ]); + + expect(pruneResolvedItemDeltas(db, { threadId: thread.id })).toBe(500); + expect(pruneResolvedItemDeltas(db, { threadId: thread.id })).toBe(1); + expect( + listEvents(db, { threadId: thread.id }).map((event) => event.sequence), + ).toEqual([1, 503]); + }); + it("keeps unresolved assistant deltas", () => { const { db, thread } = setup(); diff --git a/packages/db/test/migrate.test.ts b/packages/db/test/migrate.test.ts index 152b3519f8..f85fffe7d4 100644 --- a/packages/db/test/migrate.test.ts +++ b/packages/db/test/migrate.test.ts @@ -677,7 +677,22 @@ function dropMarketplaceCatalogSchema(db: DbConnection): void { } } +function dropEventToolNameColumn(db: DbConnection): void { + // Generated columns are omitted from table_info but included in table_xinfo. + const columns = db.$client + .prepare<[], TableInfoRow>("PRAGMA table_xinfo(events)") + .all(); + if (columns.some((column) => column.name === "tool_name")) { + db.$client.exec( + "DROP INDEX IF EXISTS events_todo_tool_call_thread_tool_sequence_idx", + ); + db.$client.prepare("ALTER TABLE events DROP COLUMN tool_name").run(); + } +} + function dropEventParentToolCallIdColumn(db: DbConnection): void { + // Every rewind before 0103 also rewinds the later generated tool-name column. + dropEventToolNameColumn(db); const columns = db.$client .prepare<[], TableInfoRow>("PRAGMA table_info(events)") .all(); @@ -3974,6 +3989,7 @@ describe("migrate", () => { "events_thread_turn_type_item_sequence_idx", "events_thread_type_item_kind_sequence_idx", "events_thread_type_sequence_idx", + "events_todo_tool_call_thread_tool_sequence_idx", "events_tool_call_parent_lookup_idx", ]); diff --git a/packages/db/test/query-plans.test.ts b/packages/db/test/query-plans.test.ts index 86530018f2..3a7a0d90dd 100644 --- a/packages/db/test/query-plans.test.ts +++ b/packages/db/test/query-plans.test.ts @@ -22,6 +22,7 @@ import { listStoredConversationOutlineEventRows, listStoredEventRows, listStoredEventRowsByParentToolCallIds, + listTodoSnapshotEventRowsForThread, pruneContextWindowUsageEventsBeforeSequence, pruneResolvedItemDeltas, } from "../src/data/events.js"; @@ -466,6 +467,34 @@ describe("slow query index plans", () => { db.$client.close(); }); + it("loads todo tool calls through the generated tool-name index", () => { + const { db, thread } = setup(); + + const captured = captureStatements(db, () => { + expect( + listTodoSnapshotEventRowsForThread(db, { threadId: thread.id }), + ).toEqual([]); + }); + const query = captured.find((entry) => entry.sql.includes('"tool_name"')); + if (!query) { + throw new Error("Expected the todo snapshot SQL"); + } + expect(query.sql).not.toContain("json_extract"); + expect(query.params).toEqual([ + thread.id, + "TodoWrite", + "TaskCreate", + "TaskUpdate", + "TaskList", + "TaskGet", + ]); + expect( + queryPlanDetails({ db, params: query.params, sql: query.sql }), + ).toContain("events_todo_tool_call_thread_tool_sequence_idx"); + + db.$client.close(); + }); + it("resolves open background-task state without per-row subqueries", () => { const { db, logger, thread } = setup(); @@ -793,8 +822,8 @@ describe("slow query index plans", () => { db.$client.close(); }); - it("uses the consolidated turn/item event index for resolved delta pruning", () => { - const { db, thread } = setup(); + it("uses materialized parent ids and the consolidated index for delta pruning", () => { + const { db, logger, thread } = setup(); const turnId = "turn_resolved_delta_query_plan"; const itemId = "call_resolved_delta_query_plan"; insertEvents(db, noopNotifier, [ @@ -836,8 +865,17 @@ describe("slow query index plans", () => { type: "item/completed", }, ]); + logger.clear(); expect(pruneResolvedItemDeltas(db, { threadId: thread.id })).toBe(1); + const pruneQuery = findOnlyDebugLog({ + logger, + predicate: (fields) => + fields.operation === "run" && + fields.sql.startsWith("DELETE FROM events"), + }); + expect(pruneQuery.fields.sql).toContain("parent_tool_call_id IS"); + expect(pruneQuery.fields.sql).not.toContain("json_extract"); const completedLookupPlan = queryPlanDetails({ db, From 0b2723a28058943d86ad32f53fe0b6057873107e Mon Sep 17 00:00:00 2001 From: Michael Yong <610102+ymichael@users.noreply.github.com> Date: Thu, 20 Aug 2026 01:49:46 -0700 Subject: [PATCH 018/232] Let stale daemons reach protocol self-update (#2028) ## What was wrong Session-open validation required the newer `localApiPort` field before comparing daemon and server protocol versions. Daemons from before that field existed therefore received `400 invalid_request: Required` instead of `protocol_version_mismatch`; because the daemon only invokes its protocol self-updater for the latter response, an enrolled older daemon could retry forever while the server reported it offline. ## What changed - Default a missing `localApiPort` to `null` at the server boundary so pre-field session payloads reach the protocol-version check. - Keep the current daemon-side request type explicit by exporting the schema's parsed output type. - Add a regression request frozen to the pre-`localApiPort` wire shape, which protects future required session fields from bypassing the mismatch response. - Bump `HOST_DAEMON_PROTOCOL_VERSION` from 142 to 143 for the wire-boundary behavior change. ## How you verified The new server regression reproduces the old daemon payload without `localApiPort` and now receives `protocol_version_mismatch`; before the fix, the live equivalent received `invalid_request: Required`. - `pnpm exec turbo run test --filter=@bb/host-daemon-contract --force -- --run test/contract.test.ts` - `pnpm exec turbo run test --filter=@bb/server --force -- --run test/internal/internal-session-protocol-version.test.ts` - `pnpm exec turbo run test --filter=@bb/host-daemon --force -- --run src/server-client.test.ts src/protocol-self-update.test.ts` - `pnpm exec turbo run test --filter=@bb/scripts --force -- --run test/request-dev-restart.test.ts` - `pnpm exec turbo run typecheck --filter=@bb/host-daemon-contract --filter=@bb/host-daemon --filter=@bb/server` - `pnpm exec prettier --check packages/host-daemon-contract/src/session.ts packages/host-daemon-contract/src/protocol.ts packages/host-daemon-contract/test/contract.test.ts apps/server/test/internal/internal-session-protocol-version.test.ts` - `git diff --check` Fixes: N/A (no linked issue). > AGENT GENERATED: by GPT-5 --- .../internal-session-protocol-version.test.ts | 45 +++++++++++++++++++ packages/host-daemon-contract/src/protocol.ts | 7 ++- packages/host-daemon-contract/src/session.ts | 12 +++-- .../test/contract.test.ts | 2 +- 4 files changed, 61 insertions(+), 5 deletions(-) diff --git a/apps/server/test/internal/internal-session-protocol-version.test.ts b/apps/server/test/internal/internal-session-protocol-version.test.ts index 79c6e98f74..15ff33b4ec 100644 --- a/apps/server/test/internal/internal-session-protocol-version.test.ts +++ b/apps/server/test/internal/internal-session-protocol-version.test.ts @@ -21,6 +21,47 @@ describe("internal session protocol version", () => { }); const daemonClient = createHostDaemonClient(server.baseUrl, hostKey); const staleProtocolVersion = HOST_DAEMON_PROTOCOL_VERSION - 1; + + // Keep this request shaped like a daemon from before protocol 140 added + // localApiPort. It must reach the version check instead of failing full + // payload validation, because only protocol_version_mismatch activates + // the daemon's self-updater. + const preLocalApiPortProtocolVersion = 139; + const oldDaemonResponse = await fetch( + `${server.baseUrl}/internal/session/open`, + { + method: "POST", + headers: { + authorization: `Bearer ${hostKey}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + hostId: "host-protocol", + instanceId: "instance-pre-local-api-port", + hostName: "Protocol Host", + hostType: "persistent", + hasMachineCredential: false, + platform: "darwin", + dataDir: "/tmp/host-protocol-data", + protocolVersion: preLocalApiPortProtocolVersion, + activeThreads: [], + loadedEnvironments: [], + }), + }, + ); + expect(oldDaemonResponse.status).toBe(400); + expect(await oldDaemonResponse.json()).toMatchObject({ + code: "protocol_version_mismatch", + details: { + retryUpdate: false, + serverProtocolVersion: HOST_DAEMON_PROTOCOL_VERSION, + }, + message: `Daemon protocol version ${preLocalApiPortProtocolVersion} does not match server protocol version ${HOST_DAEMON_PROTOCOL_VERSION}`, + }); + expect( + getHost(server.db, "host-protocol")?.lastRejectedProtocolVersion, + ).toBe(preLocalApiPortProtocolVersion); + const response = await daemonClient.session.open.$post({ json: { hostId: "host-protocol", @@ -33,6 +74,7 @@ describe("internal session protocol version", () => { localApiPort: 38_888, protocolVersion: staleProtocolVersion, activeThreads: [], + loadedEnvironments: [], }, }); @@ -69,6 +111,7 @@ describe("internal session protocol version", () => { localApiPort: 38_888, protocolVersion: staleProtocolVersion, activeThreads: [], + loadedEnvironments: [], }, }); expect(await forcedRetry.json()).toMatchObject({ @@ -87,6 +130,7 @@ describe("internal session protocol version", () => { localApiPort: 38_888, protocolVersion: staleProtocolVersion, activeThreads: [], + loadedEnvironments: [], }, }); expect(await consumedRetry.json()).toMatchObject({ @@ -105,6 +149,7 @@ describe("internal session protocol version", () => { localApiPort: 38_888, protocolVersion: HOST_DAEMON_PROTOCOL_VERSION, activeThreads: [], + loadedEnvironments: [], }, }); expect(accepted.status).toBe(201); diff --git a/packages/host-daemon-contract/src/protocol.ts b/packages/host-daemon-contract/src/protocol.ts index 98924174f5..d4e52b3223 100644 --- a/packages/host-daemon-contract/src/protocol.ts +++ b/packages/host-daemon-contract/src/protocol.ts @@ -1,3 +1,8 @@ +// Version 143 lets daemons from before session-open's `localApiPort` field +// reach the protocol-version check by defaulting that field at the server +// boundary. Without it, those daemons receive `invalid_request` instead of +// `protocol_version_mismatch`, so their protocol self-updater never runs. +// // Version 142 ships Pi context-window usage after every SDK turn ends, once // its assistant response and tool results are both reflected in the session. // Older bundled bridges report only after the full agent run ends, leaving the @@ -85,7 +90,7 @@ // // The version mismatch is what triggers the enrolled daemon's automatic update // instead of an `invalid-message` reconnect loop. -export const HOST_DAEMON_PROTOCOL_VERSION = 142 as const; +export const HOST_DAEMON_PROTOCOL_VERSION = 143 as const; /** * Absolute ceiling for any executable artifact delivered to a host daemon — diff --git a/packages/host-daemon-contract/src/session.ts b/packages/host-daemon-contract/src/session.ts index e1266a33df..03e85da13d 100644 --- a/packages/host-daemon-contract/src/session.ts +++ b/packages/host-daemon-contract/src/session.ts @@ -114,15 +114,21 @@ export const hostDaemonSessionOpenRequestSchema = z.object({ hasMachineCredential: z.boolean(), platform: hostPlatformSchema, dataDir: z.string().min(1), - /** Loopback editor-helper port, or null when this daemon exposes no full local API. */ - localApiPort: z.number().int().min(1).max(65_535).nullable(), + /** + * Loopback editor-helper port, or null when this daemon exposes no full + * local API. The default preserves the protocol-mismatch response for + * daemons from before this field existed, so they can reach self-update. + */ + localApiPort: z.number().int().min(1).max(65_535).nullable().default(null), // Accept any version at the schema boundary so the server can return an // actionable protocol mismatch instead of an opaque validation failure. protocolVersion: z.number().int().positive(), activeThreads: z.array(hostDaemonActiveThreadSchema), loadedEnvironments: z.array(hostDaemonLoadedEnvironmentSchema).default([]), }); -export type HostDaemonSessionOpenRequest = z.input< +// Current daemon code must send every server-defaulted field explicitly. The +// schema's wider input remains a compatibility boundary for older daemons. +export type HostDaemonSessionOpenRequest = z.output< typeof hostDaemonSessionOpenRequestSchema >; diff --git a/packages/host-daemon-contract/test/contract.test.ts b/packages/host-daemon-contract/test/contract.test.ts index 8100442870..a6c232256e 100644 --- a/packages/host-daemon-contract/test/contract.test.ts +++ b/packages/host-daemon-contract/test/contract.test.ts @@ -1138,7 +1138,7 @@ describe("host-daemon command schemas", () => { // mixed version. Version 113 carried the Devin Desktop open target rename // and remains part of the protocol lineage. it("uses the current host-daemon protocol version", () => { - expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(142); + expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(143); expect(HOST_ARTIFACT_MAX_BYTES).toBe(256 * 1024 * 1024); }); From c7c66423d55c320bab9103218f0ffef1a8191331 Mon Sep 17 00:00:00 2001 From: Michael Yong <610102+ymichael@users.noreply.github.com> Date: Thu, 20 Aug 2026 01:56:06 -0700 Subject: [PATCH 019/232] Reduce conversation outline refetches during streaming (#2027) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What was wrong The active thread timeline and the full-history conversation outline shared the same realtime invalidation group, so every events-appended streaming batch sent another /conversation-outline request. The server cache hardening now on main from #2025 avoids rebuilding for outline-irrelevant events, but the client still performs redundant HTTP reads at streaming cadence, and assistant text deltas can still invalidate the full projection. The outline does not need sub-second route refreshes because the incremental timeline already carries the live conversation rows. ## What changed - Split realtime timeline-window invalidation from conversation-outline invalidation. - Refresh the full outline at the terminal turn boundary instead of for every streaming delta; unknown lifecycle notifications still invalidate it conservatively, and history rewrites retain the existing full invalidation path. - Overlay live timeline conversation rows onto the cached full outline so current user and assistant messages remain fresh while a turn streams. - Reconciled the server cache documentation with the outline-aware cache added by #2025 and the new client refresh policy. - Added regressions proving streaming deltas do not refetch the outline, turn completion does, and live timeline labels replace or extend a cached outline. This implements the client-side pacing direction from #1972 using a turn boundary plus live-row overlay instead of a timed debounce. It does not change the API contract or the server/daemon wire format, so HOST_DAEMON_PROTOCOL_VERSION is unchanged. ## How you verified The new realtime invalidation test fails before the change because an assistant delta refetches the active outline query. The TOC merge test also fails before the change because a loaded outline always wins over newer timeline rows. After rebasing onto origin/main at 0b2723a28: - pnpm exec turbo run test --filter=@bb/app -- src/hooks/cache-owners/cache-owner-registry.test.ts src/hooks/realtime-cache-effects.test.ts src/components/thread/toc/ThreadTableOfContents.test.tsx — 80 tests passed - pnpm exec turbo run typecheck --filter=@bb/app — passed - git diff --check origin/main...HEAD — passed Fixes #1972 > AGENT GENERATED: by GPT-5 --- .../thread/toc/ThreadTableOfContents.test.tsx | 39 ++++++++++++ .../thread/toc/ThreadTableOfContents.tsx | 35 +++++++++-- .../cache-owners/cache-invalidation-groups.ts | 33 +++++----- .../cache-owners/realtime-cache-registry.ts | 27 +++++++-- apps/app/src/hooks/queries/thread-queries.ts | 5 +- .../src/hooks/realtime-cache-effects.test.ts | 60 +++++++++++++++++++ apps/server/src/routes/threads/data.ts | 8 ++- 7 files changed, 178 insertions(+), 29 deletions(-) diff --git a/apps/app/src/components/thread/toc/ThreadTableOfContents.test.tsx b/apps/app/src/components/thread/toc/ThreadTableOfContents.test.tsx index b29581ed8c..832ebf548b 100644 --- a/apps/app/src/components/thread/toc/ThreadTableOfContents.test.tsx +++ b/apps/app/src/components/thread/toc/ThreadTableOfContents.test.tsx @@ -687,6 +687,45 @@ describe("ThreadTableOfContents", () => { expect(screen.getByText("Agent messages")).not.toBeNull(); }); + it("merges live timeline messages into the cached full outline", async () => { + setOutline([ + { + id: "row_user_1", + role: "user", + preview: "First cached question", + attachmentSummary: null, + }, + { + id: "row_user_2", + role: "user", + preview: "Second cached question", + attachmentSummary: null, + }, + { + id: "row_user_3", + role: "user", + preview: "Stale third question", + attachmentSummary: null, + }, + ]); + + render( + , + ); + openTocPanel(); + + expect(await screen.findByText("First cached question")).not.toBeNull(); + expect( + screen.getByText("Loaded after client-side navigation 3"), + ).not.toBeNull(); + expect( + screen.getByText("Loaded after client-side navigation 4"), + ).not.toBeNull(); + expect(screen.queryByText("Stale third question")).toBeNull(); + }); + it("renders an agent-to-agent message source as a thread mention", async () => { setOutline([ { diff --git a/apps/app/src/components/thread/toc/ThreadTableOfContents.tsx b/apps/app/src/components/thread/toc/ThreadTableOfContents.tsx index 8bcb7c4396..3d625d450c 100644 --- a/apps/app/src/components/thread/toc/ThreadTableOfContents.tsx +++ b/apps/app/src/components/thread/toc/ThreadTableOfContents.tsx @@ -102,6 +102,20 @@ function outlineItemToTocItem(item: ThreadConversationOutlineItem): TocItem { }; } +function mergeLiveTocItems( + outlineItems: readonly TocItem[], + timelineItems: readonly TocItem[], +): TocItem[] { + const timelineItemsById = new Map( + timelineItems.map((item) => [item.id, item]), + ); + const outlineItemIds = new Set(outlineItems.map((item) => item.id)); + return [ + ...outlineItems.map((item) => timelineItemsById.get(item.id) ?? item), + ...timelineItems.filter((item) => !outlineItemIds.has(item.id)), + ]; +} + export function selectTocRailItems({ activeId, items, @@ -224,9 +238,10 @@ function TocItemPreview({ /** * Builds the user/agent item lists for the minimap. Prefers the full - * conversation outline (the whole thread, independent of pagination); falls - * back to the loaded timeline window so the minimap still renders on first - * paint and in environments without the outline endpoint (e.g. stories). + * conversation outline (the whole thread, independent of pagination), then + * overlays the loaded timeline window so the current turn stays live between + * full-outline refreshes. Falls back to the timeline alone on first paint and + * in environments without the outline endpoint (e.g. stories). */ function useConversationTocItems({ outlineItems, @@ -270,7 +285,19 @@ function useConversationTocItems({ return { agentItems, userItems }; }, [timelineRows]); - return outlineTocItems ?? timelineTocItems; + return useMemo(() => { + if (!outlineTocItems) return timelineTocItems; + return { + agentItems: mergeLiveTocItems( + outlineTocItems.agentItems, + timelineTocItems.agentItems, + ), + userItems: mergeLiveTocItems( + outlineTocItems.userItems, + timelineTocItems.userItems, + ), + }; + }, [outlineTocItems, timelineTocItems]); } /** diff --git a/apps/app/src/hooks/cache-owners/cache-invalidation-groups.ts b/apps/app/src/hooks/cache-owners/cache-invalidation-groups.ts index c808854a83..05156802de 100644 --- a/apps/app/src/hooks/cache-owners/cache-invalidation-groups.ts +++ b/apps/app/src/hooks/cache-owners/cache-invalidation-groups.ts @@ -117,28 +117,29 @@ export function getThreadTimelineInvalidationQueryKeys({ ]; } +export function getThreadConversationOutlineInvalidationQueryKeys({ + threadId, +}: ThreadScopedInvalidationArgs): QueryKey[] { + return threadId + ? [threadConversationOutlineQueryKeyPrefix(threadId)] + : [allThreadConversationOutlineQueryKeyPrefix()]; +} + /** - * Timeline-window-only invalidation for realtime `events-appended` / - * `thread-created` / `thread-deleted`. Deliberately excludes the - * turn-summary-details prefix: a completed turn's expanded detail is a fixed - * `sourceSeqStart..sourceSeqEnd` range and never changes once the turn is done, - * so re-fetching every open detail panel on every appended-event batch is pure - * waste during streaming. Mutations that can rewrite history (fork/retry/edit) - * still use {@link getThreadTimelineInvalidationQueryKeys}, which invalidates - * both prefixes. + * Timeline-window-only invalidation for realtime `events-appended`. The full + * conversation outline is refreshed separately at turn boundaries because + * rebuilding it for every streaming delta is disproportionately expensive. + * Turn-summary details are also excluded: a completed turn's expanded detail + * is a fixed `sourceSeqStart..sourceSeqEnd` range and never changes once the + * turn is done. Mutations that can rewrite history (fork/retry/edit) still use + * {@link getThreadTimelineInvalidationQueryKeys}, which invalidates all three. */ export function getThreadTimelineWindowInvalidationQueryKeys({ threadId, }: ThreadScopedInvalidationArgs): QueryKey[] { return threadId - ? [ - threadTimelineQueryKeyPrefix(threadId), - threadConversationOutlineQueryKeyPrefix(threadId), - ] - : [ - allThreadTimelineQueryKeyPrefix(), - allThreadConversationOutlineQueryKeyPrefix(), - ]; + ? [threadTimelineQueryKeyPrefix(threadId)] + : [allThreadTimelineQueryKeyPrefix()]; } export function getThreadQueueContentInvalidationQueryKeys({ diff --git a/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts b/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts index 7fd6e37b6a..df951fea53 100644 --- a/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts +++ b/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts @@ -101,6 +101,7 @@ import { getProjectListInvalidationQueryKeys, getProjectPromptHistoryInvalidationQueryKeys, getProjectSourceDependentInvalidationQueryKeys, + getThreadConversationOutlineInvalidationQueryKeys, getThreadDetailInvalidationQueryKeys, getThreadListInvalidationQueryKeys, getThreadPendingInteractionInvalidationQueryKeys, @@ -902,7 +903,13 @@ function dirtyThreadTimelineQueries({ }: ThreadRealtimeDirtyContext): void { // Window only: completed turn-summary-details are immutable, so realtime // event batches must not refetch open detail panels (see helper docs). - const queryKeys = getThreadTimelineWindowInvalidationQueryKeys({ threadId }); + const timelineQueryKeys = getThreadTimelineWindowInvalidationQueryKeys({ + threadId, + }); + const outlineQueryKeys = + getThreadConversationOutlineInvalidationQueryKeys({ threadId }); + const outlineMayHaveChanged = + eventTypes === undefined || eventTypes.includes("turn/completed"); if ( threadId !== undefined && !hasActiveQueries(queryClient, threadTimelineQueryKeyPrefix(threadId)) @@ -910,16 +917,28 @@ function dirtyThreadTimelineQueries({ // Nobody is viewing this thread: mark the cached window stale so a remount // refetches, but skip the fetch pacing/cancel machinery. List // subscriptions deliver every streaming thread's batches to every client. - for (const queryKey of queryKeys) { + for (const queryKey of [...timelineQueryKeys, ...outlineQueryKeys]) { queryClient.invalidateQueries({ queryKey, refetchType: "none" }); } return; } if (eventTypes?.includes("turn/completed")) { - invalidateTerminalTimelineQueryKeys({ queryClient, queryKeys }); + invalidateTerminalTimelineQueryKeys({ + queryClient, + queryKeys: [...timelineQueryKeys, ...outlineQueryKeys], + }); return; } - invalidateQueryKeysWithoutCancelingActiveFetches({ queryClient, queryKeys }); + invalidateQueryKeysWithoutCancelingActiveFetches({ + queryClient, + queryKeys: timelineQueryKeys, + }); + if (outlineMayHaveChanged) { + invalidateQueryKeysWithoutCancelingActiveFetches({ + queryClient, + queryKeys: outlineQueryKeys, + }); + } } function dirtyThreadTimelineRewriteQueries({ diff --git a/apps/app/src/hooks/queries/thread-queries.ts b/apps/app/src/hooks/queries/thread-queries.ts index f4b2cb4fd3..a5e2f024cd 100644 --- a/apps/app/src/hooks/queries/thread-queries.ts +++ b/apps/app/src/hooks/queries/thread-queries.ts @@ -997,8 +997,9 @@ export function useThreadTimeline( * table-of-contents minimap. Unlike {@link useThreadTimeline}, this is not * paginated — it always reflects the whole thread — so the minimap can show * messages that have not yet been scrolled/paged into the loaded window. It is - * invalidated by the same realtime `events-appended` signal as the timeline - * window, so it stays in sync as new messages arrive. + * refreshed when a turn completes. The table of contents merges the live + * timeline window into this full-history snapshot while a turn is streaming, + * avoiding a full outline request for every appended text delta. */ export function useThreadConversationOutline( id: string, diff --git a/apps/app/src/hooks/realtime-cache-effects.test.ts b/apps/app/src/hooks/realtime-cache-effects.test.ts index 06fb92bf31..0a8170617f 100644 --- a/apps/app/src/hooks/realtime-cache-effects.test.ts +++ b/apps/app/src/hooks/realtime-cache-effects.test.ts @@ -26,6 +26,7 @@ import { systemExecutionOptionsQueryKey, systemProvidersQueryKey, threadDefaultExecutionOptionsQueryKey, + threadConversationOutlineQueryKey, threadQueuedMessagesQueryKey, threadListQueryKey, threadPromptHistoryQueryKey, @@ -608,6 +609,65 @@ describe("createRealtimeCacheEffects", () => { effects.dispose(); }); + it("refreshes the full conversation outline once at turn completion, not for streaming deltas", async () => { + vi.useFakeTimers(); + const { effects, queryClient } = createRealtimeEffectsTestContext(); + const threadId = "thr_outline"; + const timelineKey = threadTimelineQueryKey(threadId); + const outlineKey = threadConversationOutlineQueryKey(threadId); + const timelineQueryFn = vi.fn(async () => ({ rows: [] })); + const outlineQueryFn = vi.fn(async () => ({ items: [], maxSeq: 1 })); + const timelineObserver = new QueryObserver(queryClient, { + queryKey: timelineKey, + queryFn: timelineQueryFn, + staleTime: Infinity, + }); + const outlineObserver = new QueryObserver(queryClient, { + queryKey: outlineKey, + queryFn: outlineQueryFn, + staleTime: Infinity, + }); + const unsubscribeTimeline = timelineObserver.subscribe(() => {}); + const unsubscribeOutline = outlineObserver.subscribe(() => {}); + await vi.advanceTimersByTimeAsync(0); + timelineQueryFn.mockClear(); + outlineQueryFn.mockClear(); + + effects.handleChanged({ + type: "changed", + entity: "thread", + id: threadId, + metadata: { + eventTypes: ["item/agentMessage/delta"], + projectId: "project-1", + }, + changes: ["events-appended"], + }); + await vi.advanceTimersByTimeAsync(50); + + expect(timelineQueryFn).toHaveBeenCalledTimes(1); + expect(outlineQueryFn).not.toHaveBeenCalled(); + + effects.handleChanged({ + type: "changed", + entity: "thread", + id: threadId, + metadata: { + eventTypes: ["item/completed", "turn/completed"], + projectId: "project-1", + }, + changes: ["events-appended"], + }); + await vi.advanceTimersByTimeAsync(50); + + expect(timelineQueryFn).toHaveBeenCalledTimes(2); + expect(outlineQueryFn).toHaveBeenCalledTimes(1); + + unsubscribeOutline(); + unsubscribeTimeline(); + effects.dispose(); + }); + it("marks archived thread lists stale without refetching them for status changes", async () => { vi.useFakeTimers(); const { effects, queryClient } = createRealtimeEffectsTestContext(); diff --git a/apps/server/src/routes/threads/data.ts b/apps/server/src/routes/threads/data.ts index 16a728e51f..e64433a9b4 100644 --- a/apps/server/src/routes/threads/data.ts +++ b/apps/server/src/routes/threads/data.ts @@ -319,9 +319,11 @@ export function registerThreadDataRoutes(app: Hono, deps: AppDeps): void { // The conversation outline reprojects the entire thread, so memoize it by // the newest event that can affect the outline. Command output, reasoning, // and usage events still advance maxSeq in the response but do not invalidate - // the expensive projection. The key includes thread metadata that can affect - // grouping; add provider/env inputs if the outline ever surfaces them. A - // small LRU bounds memory across many viewed threads. + // the expensive projection. The client also refreshes this full projection + // at turn boundaries and overlays the live timeline window while a turn + // streams. The key includes thread metadata that can affect grouping; add + // provider/env inputs if the outline ever surfaces them. A small LRU bounds + // memory across many viewed threads. const conversationOutlineCache = new Map< string, ThreadConversationOutlineResponse["items"] From 5f4172be3b0c95fafa4a20885c493660889e167b Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Thu, 20 Aug 2026 07:30:06 -0700 Subject: [PATCH 020/232] Remove the working status from the mobile thread header (#2032) ## What was wrong The mobile thread header showed a "Working" subtitle with a spinner under the title while a thread ran. The timeline already shows a working indicator, so the header line was noise. ## What changed - `apps/mobile/src/screens/thread/ThreadDetailHeader.tsx`: `headerSubtitle` hides working-tone statuses (Working, Provisioning, Starting, Stopping, Reconnecting). The header keeps "Needs input", "Error", "Waiting for host", "Archived", and the child / side chat label. The spinner is gone. - `apps/mobile/src/screens/thread/thread-detail-header-model.ts`: removed the unused `spinning` field from `ThreadStatusPill`. ## How you verified - `pnpm exec turbo run typecheck --filter=@bb/mobile` passes. - Manual check in the iOS simulator against the mobile e2e backend: an active thread shows only the title in the header, and the timeline still shows "Working...". Fixes # > AGENT GENERATED: by Claude Opus 5 Co-authored-by: Claude --- .../src/screens/thread/ThreadDetailHeader.tsx | 32 +++++++++---------- .../thread/thread-detail-header-model.ts | 29 ++++++++--------- 2 files changed, 29 insertions(+), 32 deletions(-) diff --git a/apps/mobile/src/screens/thread/ThreadDetailHeader.tsx b/apps/mobile/src/screens/thread/ThreadDetailHeader.tsx index 75ebdbe3f1..604a047631 100644 --- a/apps/mobile/src/screens/thread/ThreadDetailHeader.tsx +++ b/apps/mobile/src/screens/thread/ThreadDetailHeader.tsx @@ -1,13 +1,13 @@ import { Pressable, View } from "react-native"; import { useTheme } from "@/theme"; -import { cn, Icon, Spinner, Text } from "@/ui"; +import { cn, Icon, Text } from "@/ui"; import { PanelToggleButton } from "../panel/PanelToggleButton"; import type { ThreadStatusPill } from "./thread-detail-header-model"; /** * The thread screen's native header pieces. There is one header only: the * title (tap to rename) with a status subtitle while the thread needs - * attention or works, and two buttons on the right — the workspace panel + * attention, has an error, or waits on a host, and two buttons on the right — the workspace panel * and the "…" menu. Everything else the old two-layer header carried * (environment line, child roll-up, git action) lives in the menu sheet. */ @@ -21,13 +21,18 @@ export interface ThreadHeaderTitleProps { onPressTitle: (() => void) | null; } -/** Subtitle shown under the title; idle / archived threads show none. */ +/** + * Subtitle shown under the title. Idle threads show none, and working threads + * show none either: the timeline's working indicator already carries that. + */ export function headerSubtitle( statusPill: ThreadStatusPill, childPillLabel: ThreadHeaderTitleProps["childPillLabel"], ): string | null { const parts: string[] = []; - if (statusPill.tone !== "idle") parts.push(statusPill.label); + if (statusPill.tone !== "idle" && statusPill.tone !== "working") { + parts.push(statusPill.label); + } if (childPillLabel) parts.push(childPillLabel); return parts.length > 0 ? parts.join(" · ") : null; } @@ -68,21 +73,14 @@ export function ThreadHeaderTitle({ {title} {subtitle ? ( - - {statusPill.spinning ? ( - - ) : null} - - {subtitle} - - + {subtitle} + ) : null} ); diff --git a/apps/mobile/src/screens/thread/thread-detail-header-model.ts b/apps/mobile/src/screens/thread/thread-detail-header-model.ts index 087cce4e39..add23552f0 100644 --- a/apps/mobile/src/screens/thread/thread-detail-header-model.ts +++ b/apps/mobile/src/screens/thread/thread-detail-header-model.ts @@ -10,7 +10,8 @@ import { assertNever } from "@bb/thread-view"; /** * Pure header facts for the thread detail screen: the status pill (from the * client-core runtime display status, with the thread's own status and - * pending input layered on) and the one-line environment summary. + * pending input layered on) and the one-line environment summary. The header + * hides "working" tones; the timeline's working indicator already shows them. */ export type ThreadStatusPillTone = @@ -23,8 +24,6 @@ export type ThreadStatusPillTone = export interface ThreadStatusPill { label: string; tone: ThreadStatusPillTone; - /** Shows a spinner glyph instead of a static one. */ - spinning: boolean; } export function describeThreadStatusPill({ @@ -39,33 +38,33 @@ export function describeThreadStatusPill({ archived: boolean; }): ThreadStatusPill { if (hasPendingInteraction) { - return { label: "Needs input", tone: "attention", spinning: false }; + return { label: "Needs input", tone: "attention" }; } if (threadStatus === "stopping") { - return { label: "Stopping", tone: "working", spinning: true }; + return { label: "Stopping", tone: "working" }; } switch (runtimeDisplayStatus) { case "active": - return { label: "Working", tone: "working", spinning: true }; + return { label: "Working", tone: "working" }; case "provisioning": - return { label: "Provisioning", tone: "working", spinning: true }; + return { label: "Provisioning", tone: "working" }; case "starting": - return { label: "Starting", tone: "working", spinning: true }; + return { label: "Starting", tone: "working" }; case "stopping": - return { label: "Stopping", tone: "working", spinning: true }; + return { label: "Stopping", tone: "working" }; case "host-reconnecting": - return { label: "Reconnecting", tone: "working", spinning: true }; + return { label: "Reconnecting", tone: "working" }; case "waiting-for-host": - return { label: "Waiting for host", tone: "muted", spinning: false }; + return { label: "Waiting for host", tone: "muted" }; case "error": - return { label: "Error", tone: "error", spinning: false }; + return { label: "Error", tone: "error" }; case "idle": if (threadStatus === "error") { - return { label: "Error", tone: "error", spinning: false }; + return { label: "Error", tone: "error" }; } return archived - ? { label: "Archived", tone: "muted", spinning: false } - : { label: "Idle", tone: "idle", spinning: false }; + ? { label: "Archived", tone: "muted" } + : { label: "Idle", tone: "idle" }; default: return assertNever(runtimeDisplayStatus); } From c4a3dc5fb1b0239cba999de7946db7215fb4e9c3 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Thu, 20 Aug 2026 07:33:43 -0700 Subject: [PATCH 021/232] Match mobile unread divider to web styling (#2033) ## What was wrong The iOS unread divider used the `attention` token (yellow), a centered label, and two rules. The web app uses `timeline-accent` (blue), a left label, and one rule. The two apps did not match. ## What changed `apps/mobile/src/screens/thread/timeline/TimelineList.tsx`: the divider now uses `text-timeline-accent` / `bg-timeline-accent`, an uppercase medium-weight "New" label on the left, and one rule on the right. This matches `UnreadDivider` in `apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx`. ## How you verified `pnpm exec turbo run typecheck --filter=@bb/mobile` passes. Visual change only; no new tests. > AGENT GENERATED: by Claude Opus 5 Co-authored-by: Claude --- apps/mobile/src/screens/thread/timeline/TimelineList.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/screens/thread/timeline/TimelineList.tsx b/apps/mobile/src/screens/thread/timeline/TimelineList.tsx index bdb7745b0e..aed1c3f0ff 100644 --- a/apps/mobile/src/screens/thread/timeline/TimelineList.tsx +++ b/apps/mobile/src/screens/thread/timeline/TimelineList.tsx @@ -90,11 +90,14 @@ function UnreadDividerRow() { className="flex-row items-center gap-2 px-4 py-1" testID="timeline-unread-divider" > - - + New - + ); } From f4ab03f468fed7f4e105814a95b0c6f26c7aaf7e Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Thu, 20 Aug 2026 07:37:36 -0700 Subject: [PATCH 022/232] Port the web sound-wave recording bar to the mobile composer (#2034) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What was wrong The mobile composer's voice bar (`apps/mobile/src/composer/VoiceBar.tsx`) showed a red dot, a "Listening…" label, and an elapsed timer. It gave no live audio feedback and did not look like the web `VoiceRecordingBar`, which draws scrolling sound-wave bars from the microphone level. ## What changed - `apps/mobile/src/composer/voice-waveform-model.ts` (new): pure port of the web `WaveformVisualizer` math. `meteringToAmplitude` converts expo-audio metering (dBFS) to a bar amplitude with the same noise floor, gain, and gamma as the web RMS path; plus the scrolling bar buffer and the SVG path builder. - `apps/mobile/src/composer/VoiceWaveform.tsx` (new): draws the bars as one `react-native-svg` path (3px bars, 2px gaps, round caps, newest at the right, oldest fading on the left via a gradient stroke). Samples `readLevel()` at ~30 Hz while active, freezes when inactive, shows flat idle bars under reduce-motion. - `apps/mobile/src/composer/VoiceBar.tsx`: web layout — round ghost cancel · waveform · round primary confirm. While transcribing the bars freeze and breathe (the `animate-shine-icon` stand-in) and the confirm button shows a spinner. - `apps/mobile/src/composer/useComposerVoice.ts`: records with `isMeteringEnabled: true` and exposes `readLevel()`; the elapsed-seconds ticker is removed. - `apps/mobile/app/dev/ui.tsx`: a "Voice bar (synthetic levels)" gallery section so the bar can be exercised without a mic. No wire changes. ## How you verified - New `voice-waveform-model.test.ts` (dB mapping floor/clamp/monotonic, scroll buffer, path geometry). `pnpm exec turbo run test typecheck lint --filter=@bb/mobile` pass. - iOS Simulator (iPhone 17 Pro) through the dev client and the UI gallery: recording scrolls right→left with the left-edge fade; Check → transcribing freezes and breathes with a spinner; X → recording resumes. Checked dark and light. Fixes # > AGENT GENERATED: by Claude Opus 5 Co-authored-by: Claude --- apps/mobile/app/dev/ui.tsx | 40 +++++- apps/mobile/src/composer/VoiceBar.tsx | 110 +++++++++------- apps/mobile/src/composer/VoiceWaveform.tsx | 124 ++++++++++++++++++ apps/mobile/src/composer/index.ts | 3 + apps/mobile/src/composer/useComposerVoice.ts | 42 +++--- .../src/composer/voice-waveform-model.test.ts | 75 +++++++++++ .../src/composer/voice-waveform-model.ts | 96 ++++++++++++++ 7 files changed, 421 insertions(+), 69 deletions(-) create mode 100644 apps/mobile/src/composer/VoiceWaveform.tsx create mode 100644 apps/mobile/src/composer/voice-waveform-model.test.ts create mode 100644 apps/mobile/src/composer/voice-waveform-model.ts diff --git a/apps/mobile/app/dev/ui.tsx b/apps/mobile/app/dev/ui.tsx index 29317c35b1..286d9f04ac 100644 --- a/apps/mobile/app/dev/ui.tsx +++ b/apps/mobile/app/dev/ui.tsx @@ -2,10 +2,11 @@ // can be eyeballed per palette × mode on the simulator. Not product UI. import { BUILTIN_THEME_IDS } from "@bb/domain"; import { Redirect } from "expo-router"; -import { useState, type ReactNode } from "react"; +import { useMemo, useState, type ReactNode } from "react"; import { ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { e2eModeEnabled } from "@/app-shell"; +import { VoiceBar, type VoiceBarController } from "@/composer"; import { useTheme } from "@/theme/ThemeProvider"; import type { ThemeModePreference } from "@/theme/theme-preference"; import { @@ -41,12 +42,37 @@ function Section({ title, children }: { title: string; children: ReactNode }) { const MODES: ThemeModePreference[] = ["system", "light", "dark"]; +/** + * Speech-like synthetic input levels for the voice bar showcase: a slow + * syllable envelope with jitter, so the waveform scrolls without a mic. + */ +function syntheticVoiceLevel(): number { + const t = Date.now() / 1000; + const syllable = + Math.max(0, Math.sin(t * 5.3)) * (0.6 + 0.4 * Math.sin(t * 0.7)); + const pause = Math.sin(t * 0.45) > 0.75 ? 0 : 1; + const jitter = 0.75 + Math.random() * 0.25; + return Math.min(1, 0.06 + syllable * jitter * pause); +} + function UiGalleryScreen() { const insets = useSafeAreaInsets(); const theme = useTheme(); const [checked, setChecked] = useState(true); const [text, setText] = useState(""); const [pressed, setPressed] = useState(false); + const [voiceState, setVoiceState] = useState<"recording" | "transcribing">( + "recording", + ); + const voice = useMemo( + (): VoiceBarController => ({ + state: voiceState, + readLevel: syntheticVoiceLevel, + stop: async () => setVoiceState("transcribing"), + cancel: () => setVoiceState("recording"), + }), + [voiceState], + ); const sheet = useSheet(); const scrollSheet = useSheet(); const menu = useSheet(); @@ -278,6 +304,18 @@ function UiGalleryScreen() { +
+ + + + + Check → transcribing (frozen, breathing); X → back to recording. + +
+
diff --git a/apps/mobile/src/composer/VoiceBar.tsx b/apps/mobile/src/composer/VoiceBar.tsx index cea81c6920..38f8b1440a 100644 --- a/apps/mobile/src/composer/VoiceBar.tsx +++ b/apps/mobile/src/composer/VoiceBar.tsx @@ -1,63 +1,83 @@ +import { useEffect } from "react"; import { View } from "react-native"; -import { useTheme } from "@/theme"; -import { Button, Spinner, Text } from "@/ui"; +import Animated, { + useAnimatedStyle, + useSharedValue, + withRepeat, + withSequence, + withTiming, +} from "react-native-reanimated"; +import { Button } from "@/ui"; import type { ComposerVoiceController } from "./useComposerVoice"; +import { VoiceWaveform } from "./VoiceWaveform"; -function formatElapsed(seconds: number): string { - const minutes = Math.floor(seconds / 60); - const rest = seconds % 60; - return `${minutes}:${rest.toString().padStart(2, "0")}`; -} +export type VoiceBarController = Pick< + ComposerVoiceController, + "state" | "readLevel" | "stop" | "cancel" +>; + +/** + * Replaces the footer while recording / transcribing (web `VoiceRecordingBar`): + * cancel · the live sound-wave bars · confirm. While transcribing the bars + * freeze and breathe (the web `animate-shine-icon`) and the confirm button + * shows a spinner. + */ +export function VoiceBar({ voice }: { voice: VoiceBarController }) { + const transcribing = voice.state === "transcribing"; + const opacity = useSharedValue(1); + useEffect(() => { + if (!transcribing) { + opacity.set(withTiming(1, { duration: 150 })); + return; + } + opacity.set( + withRepeat( + withSequence( + withTiming(0.35, { duration: 700 }), + withTiming(1, { duration: 700 }), + ), + -1, + ), + ); + }, [opacity, transcribing]); + const breathe = useAnimatedStyle(() => ({ opacity: opacity.get() })); -/** Replaces the footer while recording / transcribing (web `VoiceRecordingBar`). */ -export function VoiceBar({ voice }: { voice: ComposerVoiceController }) { - const { tokens } = useTheme(); - const recording = voice.state === "recording"; return ( - - Add machine - - + } > {hosts === undefined ? ( diff --git a/apps/app/src/components/ui/settings-section.tsx b/apps/app/src/components/ui/settings-section.tsx index 3d385d9668..ecc3e28e6d 100644 --- a/apps/app/src/components/ui/settings-section.tsx +++ b/apps/app/src/components/ui/settings-section.tsx @@ -27,8 +27,8 @@ export function SettingsSection({
@@ -44,7 +44,7 @@ export function SettingsSection({

) : null}
- {action ?
{action}
: null} + {action ?
{action}
: null}
{ renderView(); - expect( - await screen.findByRole("heading", { name: /dev-vm/u }), - ).toBeDefined(); + const machineHeading = await screen.findByRole("heading", { + name: /dev-vm/u, + }); + expect(machineHeading.tagName).toBe("H1"); const checkedByMode = Object.fromEntries( (await screen.findAllByRole("radio")).map((option) => [ option.textContent?.startsWith("Accept Edits") @@ -197,21 +198,23 @@ describe("MachineSettingsView", () => { }); expect( screen - .getByRole("radio", { name: /Accept Edits/u }) - .querySelector('[data-icon="FolderEdit"]'), - ).not.toBeNull(); + .getAllByRole("radio") + .every((option) => option.querySelector("[data-icon]") === null), + ).toBe(true); + const machineSubtitle = screen.getByText(/^Online ·/u); + expect(machineSubtitle.closest("section")).toBeNull(); + expect(screen.queryByRole("img", { name: "Online" })).toBeNull(); expect( screen - .getByRole("radio", { name: /Approve for me/u }) - .querySelector('[data-icon="SecurityCheck"]'), - ).not.toBeNull(); + .getByRole("heading", { name: /dev-vm/u }) + .querySelector("[data-icon]"), + ).toBeNull(); expect( screen - .getByRole("radio", { name: /Full Access/u }) - .querySelector('[data-icon="SquareUnlock02"]'), - ).not.toBeNull(); - expect(screen.getByRole("img", { name: "Online" })).toBeDefined(); - expect(document.querySelector('[data-icon="FolderGit"]')).not.toBeNull(); + .getByRole("heading", { name: "Machine information" }) + .closest("section") + ?.querySelector("[data-icon]"), + ).toBeNull(); expect( document.querySelector('[data-provider-icon="codex"]'), ).not.toBeNull(); @@ -226,6 +229,15 @@ describe("MachineSettingsView", () => { .getByRole("heading", { name: "Provider CLIs" }) .querySelector("[data-icon]"), ).toBeNull(); + const installedLabel = screen.getByText("Installed"); + expect(installedLabel.parentElement?.className).toContain("flex-col"); + expect(installedLabel.parentElement?.className).toContain("sm:flex-row"); + expect(installedLabel.nextElementSibling?.className).toContain( + "justify-start", + ); + expect(installedLabel.nextElementSibling?.className).toContain( + "sm:justify-end", + ); // The page exists so the modes can explain themselves. expect(screen.getByText(/No sandbox and no approvals/u)).toBeDefined(); }); @@ -247,7 +259,7 @@ describe("MachineSettingsView", () => { expect(screen.queryByRole("button", { name: "Rename" })).toBeNull(); }); - it("names an offline machine's status icon", async () => { + it("shows an offline machine's status as text", async () => { vi.mocked(sdk.system.config).mockResolvedValue(systemConfig()); vi.mocked(sdk.hosts.list).mockResolvedValue([ host({ status: "disconnected", lastSeenAt: Date.now() - 60_000 }), @@ -256,8 +268,8 @@ describe("MachineSettingsView", () => { renderView(); - expect(await screen.findByRole("img", { name: "Offline" })).toBeDefined(); - expect(screen.queryByText(/^Offline ·/u)).toBeNull(); + expect(await screen.findByText(/^Offline · last seen/u)).toBeDefined(); + expect(screen.queryByRole("img", { name: "Offline" })).toBeNull(); }); it("links update issues to Updates in a warning pill", async () => { diff --git a/apps/app/src/views/MachineSettingsView.tsx b/apps/app/src/views/MachineSettingsView.tsx index 9dfa27efe9..f7836ff486 100644 --- a/apps/app/src/views/MachineSettingsView.tsx +++ b/apps/app/src/views/MachineSettingsView.tsx @@ -1,11 +1,6 @@ import { useMemo, useState, type ReactNode } from "react"; import { Link, useNavigate, useParams } from "react-router-dom"; -// Route views render icons outside the shell's core set. Importing the -// extended registry here ships it as a static dependency of this route chunk, -// so those icons never flash blank waiting for an on-demand load. -import "@bb/shared-ui/icon-extended"; import type { Host, PermissionMode } from "@bb/domain"; -import { UPDATE_ACTION_ICON } from "@bb/domain/update-state"; import { providerCliKeyValues, type HostPlatform, @@ -14,12 +9,12 @@ import { import { Button } from "@bb/shared-ui/button"; import { DialogFooter, DialogHeader, DialogTitle } from "@bb/shared-ui/dialog"; import { DialogDescription } from "@bb/shared-ui/dialog"; -import { Icon, type IconName } from "@bb/shared-ui/icon"; +import { Icon } from "@bb/shared-ui/icon"; import { cn } from "@bb/shared-ui/lib/utils"; import { Pill } from "@bb/shared-ui/pill"; import { ResourceOverflowMenu } from "@bb/shared-ui/resource-list"; import { ConfirmDeleteDialog } from "@/components/dialogs/ConfirmDeleteDialog"; -import { MachineStatusIcon } from "@/components/machines/MachineStatusDot"; +import { MachineStatusDot } from "@/components/machines/MachineStatusDot"; import { PageShell } from "@/components/ui/page-shell.js"; import { SettingsBadge, @@ -46,7 +41,6 @@ import { hostCanRetryUpdate, } from "@/lib/host-update-status"; import { getMutationErrorMessage } from "@/lib/mutation-errors"; -import { PersistentHostIconName } from "@/lib/host-display"; import { PERMISSION_MODE_OPTIONS } from "@/lib/permission-mode-options"; import { formatRelativeTime } from "@/lib/relative-time"; import { @@ -90,7 +84,7 @@ function headerMeta({ platformLabel: string | null; now: number; }): string { - const parts: string[] = []; + const parts: string[] = [host.status === "connected" ? "Online" : "Offline"]; if (host.status !== "connected" && host.lastSeenAt !== null) { parts.push( `last seen ${formatRelativeTime({ timestamp: host.lastSeenAt, now })}`, @@ -145,14 +139,6 @@ function PermissionLimitCards({ ) : null} - {option.label} @@ -174,24 +160,14 @@ function PermissionLimitCards({ interface DetailRowProps { label: string; - icon?: IconName; children: ReactNode; } -function DetailRow({ label, icon, children }: DetailRowProps) { +function DetailRow({ label, children }: DetailRowProps) { return ( - - - {icon ? ( - - ) : null} - {label} - -
+ + {label} +
{children}
@@ -308,48 +284,40 @@ export function MachineSettingsView() { Machines - - - {host.name} +
+
+
+

+ {host.name} +

{isThisMachine ? ( This machine ) : null} {showMachineIdentityBadges && isPrimary ? ( Primary ) : null} - - } - titleAction={ - { - renameHost.reset(); - setRenameOpen(true); - }, - }, - ]} - /> - } - > - - - +
+
+

{headerMeta({ host, platformLabel, now })}

- - - +
+
+ { + renameHost.reset(); + setRenameOpen(true); + }, + }, + ]} + /> +
{installedProviders.length > 0 ? ( - + {installedProviders.map((entry) => ( - + {projects.length === 0 ? ( None ) : ( @@ -457,7 +425,7 @@ export function MachineSettingsView() { )} - + {updateStatus ?? "Up to date"} {hostCanRetryUpdate(host) ? ( + ), + experimental_PermissionModePicker: ({ + providerId, + value, + onChange, + routing, + disabled, + }: ExperimentalPermissionModePickerProps) => ( + + ), +})); + +import { AutomationDetailView } from "../detail-view.js"; + +afterEach(cleanup); + +const automation: AutomationResponse = { + id: "auto_test", + projectId: "proj_test", + name: "Digest", + enabled: true, + trigger: { triggerType: "schedule", cron: "0 9 * * *", timezone: "UTC" }, + execution: { + mode: "agent", + prompt: "Summarize the inbox", + providerId: "codex", + model: "gpt-5.6-codex", + reasoningLevel: "medium", + permissionMode: "accept-edits", + environment: { type: "reuse", environmentId: "env_test" }, + }, + origin: "human", + createdByThreadId: null, + nextRunAt: Date.now() + 60_000, + lastRunAt: null, + runCount: 0, + lastRunStatus: null, + lastRunThreadId: null, + lastError: null, + createdAt: Date.now(), + updatedAt: Date.now(), +}; + +describe("automation provider and model picker", () => { + it("persists the host picker's coherent tuple and reconciles permissions", () => { + const onUpdate = vi.fn(async (_update: AgentExecutionUpdate) => {}); + render( + , + ); + + const picker = screen.getByRole("button", { name: "Choose Claude" }); + expect(picker.getAttribute("data-routing-kind")).toBe("environment"); + expect(picker.getAttribute("data-routing-id")).toBe("env_test"); + expect(picker.getAttribute("data-provider-change-allowed")).toBe("true"); + fireEvent.click(picker); + const permission = screen.getByRole("button", { + name: "Permission mode", + }); + expect(permission.getAttribute("data-provider-id")).toBe("claude"); + expect(permission.getAttribute("data-routing-kind")).toBe("environment"); + fireEvent.click(permission); + fireEvent.click(screen.getByRole("button", { name: "Save Prompt" })); + + expect(onUpdate).toHaveBeenCalledWith({ + prompt: "Summarize the inbox", + providerId: "claude", + model: "claude-opus-5", + reasoningLevel: "ultra", + serviceTier: "fast", + permissionMode: "auto", + }); + }); +}); diff --git a/plugins/automations/src/automations.test.ts b/plugins/automations/src/automations.test.ts index 95f80480fa..32b8a428fd 100644 --- a/plugins/automations/src/automations.test.ts +++ b/plugins/automations/src/automations.test.ts @@ -73,6 +73,7 @@ function createScheduledAutomation( prompt: "do it", providerId: "codex", model: "gpt-5", + reasoningLevel: "medium", permissionMode: "accept-edits", environment: { type: "project-default" }, }, @@ -98,6 +99,7 @@ function createOnceAutomation(db: Db, nextRunAt: number, id = "auto_once") { prompt: "do it once", providerId: "codex", model: "gpt-5", + reasoningLevel: "medium", permissionMode: "accept-edits", environment: { type: "project-default" }, }, @@ -907,6 +909,7 @@ describe("automation service", () => { prompt: "hello", providerId: "codex", model: "gpt-5", + reasoningLevel: "medium", permissionMode: "accept-edits", environment: { type: "project-default" }, }, @@ -954,6 +957,7 @@ describe("automation service", () => { prompt: "do it", providerId: "codex", model: "gpt-5", + reasoningLevel: "medium", permissionMode: "accept-edits", environment: { type: "project-default" }, }, diff --git a/plugins/automations/src/cli.ts b/plugins/automations/src/cli.ts index 4c00b774d1..5225be6d23 100644 --- a/plugins/automations/src/cli.ts +++ b/plugins/automations/src/cli.ts @@ -14,7 +14,9 @@ import type { AutomationScriptInterpreter, CreateAutomationInput, PermissionMode, + ReasoningLevel, ResolvedCreateAutomationInput, + ServiceTier, UpdateAutomationInput, } from "./rpc-types.js"; import { @@ -157,6 +159,30 @@ function parsePermissionMode( ); } +function parseReasoningLevel(value: string): ReasoningLevel { + if ( + value === "none" || + value === "low" || + value === "medium" || + value === "high" || + value === "xhigh" || + value === "ultracode" || + value === "max" || + value === "ultra" + ) { + return value; + } + throw new Error( + "Invalid --reasoning. Expected none, low, medium, high, xhigh, ultracode, max, or ultra.", + ); +} + +function parseServiceTier(value: string): ServiceTier | null { + if (value === "default" || value === "fast") return value; + if (value === "none") return null; + throw new Error("Invalid --service-tier. Expected default, fast, or none."); +} + function validateAgentTargetOptions(args: ParsedArgs): void { const targetOptionNames = [ "target-thread", @@ -441,12 +467,21 @@ async function buildExecution( } validateAgentTargetOptions(args); const environment = await buildAgentEnvironment(bb, args); + const reasoning = flag(args, "reasoning"); + const serviceTier = flag(args, "service-tier"); + const parsedServiceTier = + serviceTier === undefined ? undefined : parseServiceTier(serviceTier); return { execution: { mode: "agent", prompt, providerId: provider, model, + reasoningLevel: + reasoning === undefined ? "medium" : parseReasoningLevel(reasoning), + ...(parsedServiceTier === null || parsedServiceTier === undefined + ? {} + : { serviceTier: parsedServiceTier }), permissionMode: await resolvePermissionMode( bb, provider, @@ -463,6 +498,8 @@ async function buildExecution( if ( args.flags.has("provider") || args.flags.has("model") || + args.flags.has("reasoning") || + args.flags.has("service-tier") || args.flags.has("permission-mode") || args.flags.has("target-thread") || args.flags.has("environment") || @@ -497,8 +534,6 @@ async function buildExecution( } const COMPLETE_EXECUTION_FLAG_NAMES = [ - "provider", - "model", "script", "script-file", "interpreter", @@ -512,6 +547,10 @@ async function buildAgentExecutionUpdate( ): Promise { const agentOptionNames = [ "prompt", + "provider", + "model", + "reasoning", + "service-tier", "permission-mode", "target-thread", "environment", @@ -523,6 +562,16 @@ async function buildAgentExecutionUpdate( validateAgentTargetOptions(args); const update: AgentExecutionUpdate = {}; if (args.flags.has("prompt")) update.prompt = requireFlag(args, "prompt"); + if (args.flags.has("provider")) { + update.providerId = requireFlag(args, "provider"); + } + if (args.flags.has("model")) update.model = requireFlag(args, "model"); + if (args.flags.has("reasoning")) { + update.reasoningLevel = parseReasoningLevel(requireFlag(args, "reasoning")); + } + if (args.flags.has("service-tier")) { + update.serviceTier = parseServiceTier(requireFlag(args, "service-tier")); + } if (args.flags.has("permission-mode")) { update.permissionMode = parsePermissionMode( requireFlag(args, "permission-mode"), @@ -568,7 +617,14 @@ async function buildUpdateRequest( request.trigger = buildTrigger(args); } let scriptSource: ScriptFileSource | undefined; - if (COMPLETE_EXECUTION_FLAG_NAMES.some((name) => args.flags.has(name))) { + const replacesAgentExecution = + args.flags.has("prompt") && + args.flags.has("provider") && + args.flags.has("model"); + if ( + replacesAgentExecution || + COMPLETE_EXECUTION_FLAG_NAMES.some((name) => args.flags.has(name)) + ) { const built = await buildExecution(bb, args, ctx); request.execution = built.execution; scriptSource = built.scriptSource; @@ -621,6 +677,15 @@ function printAutomation(automation: AutomationResponse): string { ) { lines.push(` Script: ${automation.execution.storedScriptPath}`); } + if (automation.execution.mode === "agent") { + lines.push( + ` Provider: ${automation.execution.providerId}`, + ` Model: ${automation.execution.model}`, + ` Reasoning: ${automation.execution.reasoningLevel}`, + ` Tier: ${automation.execution.serviceTier ?? "-"}`, + ` Permission: ${automation.execution.permissionMode}`, + ); + } if (automation.lastError) lines.push(` Error: ${automation.lastError}`); lines.push(""); return `${lines.join("\n")}\n`; @@ -733,9 +798,9 @@ function helpText(): string { return `Automation commands bb automation list --project -bb automation create --project --name (--cron --timezone | --at | --in ) (--prompt --provider --model | --script | --script-file [--host ]) +bb automation create --project --name (--cron --timezone | --at | --in ) (--prompt --provider --model [--reasoning ] [--service-tier default|fast] | --script | --script-file [--host ]) bb automation show --project -bb automation update --project [--name ] [schedule flags] [complete agent/script execution flags | partial agent update flags] +bb automation update --project [--name ] [schedule flags] [complete agent/script execution flags | --provider --model --reasoning --service-tier default|fast|none] bb automation pause --project bb automation resume --project bb automation run --project [--idempotency-key ] diff --git a/plugins/automations/src/model-label.test.ts b/plugins/automations/src/model-label.test.ts deleted file mode 100644 index 0934a81572..0000000000 --- a/plugins/automations/src/model-label.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - formatAutomationModelLabel, - formatAutomationProviderLabel, -} from "../lib/model-label.js"; - -describe("automation model labels", () => { - it.each([ - ["codex", "gpt-5", "5"], - ["codex", "gpt-5.6-sol", "5.6 Sol"], - ["codex", "gpt-5.4-mini", "5.4 Mini"], - ["claude-code", "claude-sonnet-4-6", "Sonnet 4.6"], - ["claude-code", "claude-opus-5[1m]", "Opus 5 (1M)"], - ["claude-code", "claude-opus-4-8[1m]", "Opus 4.8 (1M)"], - ["claude-code", "claude-sonnet-4-6[200k]", "Sonnet 4.6 (200K)"], - ["claude-code", "claude-opus-5[beta]", "Opus 5[beta]"], - ["custom-provider", "custom-model-v2", "Custom Model v2"], - ])("formats %s/%s as %s", (providerId, model, expected) => { - expect(formatAutomationModelLabel(model, providerId)).toBe(expected); - }); - - it.each([ - ["codex", "Codex"], - ["claude-code", "Claude"], - ["openai-compatible", "OpenAI"], - ["pi", "Pi"], - ["acp-cursor", "Cursor"], - ["custom-provider", "Custom-provider"], - ])("formats provider %s as %s", (providerId, expected) => { - expect(formatAutomationProviderLabel(providerId)).toBe(expected); - }); -}); diff --git a/plugins/automations/src/option-request-gate.test.ts b/plugins/automations/src/option-request-gate.test.ts deleted file mode 100644 index feaf084e66..0000000000 --- a/plugins/automations/src/option-request-gate.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { OptionRequestGate } from "./option-request-gate.js"; - -describe("OptionRequestGate", () => { - it("starts a replacement after a pending request is cancelled", () => { - const gate = new OptionRequestGate(); - const first = gate.begin("automation:execution"); - - expect(first).not.toBeNull(); - expect(gate.begin("automation:execution")).toBeNull(); - - first?.cancel(); - const replacement = gate.begin("automation:execution"); - expect(replacement).not.toBeNull(); - - // A late completion from the cancelled request must not disturb the - // replacement request or its eventual cache entry. - first?.complete(); - replacement?.complete(); - replacement?.cancel(); - expect(gate.begin("automation:execution")).toBeNull(); - }); -}); diff --git a/plugins/automations/src/option-request-gate.ts b/plugins/automations/src/option-request-gate.ts deleted file mode 100644 index 40654f3e68..0000000000 --- a/plugins/automations/src/option-request-gate.ts +++ /dev/null @@ -1,39 +0,0 @@ -interface OptionRequestLease { - complete(): void; - fail(): void; - cancel(): void; -} - -interface ActiveRequest { - key: string; - token: symbol; - complete: boolean; -} - -export class OptionRequestGate { - private active: ActiveRequest | null = null; - - reset(): void { - this.active = null; - } - - begin(key: string): OptionRequestLease | null { - if (this.active?.key === key) return null; - - const token = Symbol(key); - this.active = { key, token, complete: false }; - const releasePending = () => { - if (this.active?.token === token && !this.active.complete) { - this.active = null; - } - }; - - return { - complete: () => { - if (this.active?.token === token) this.active.complete = true; - }, - fail: releasePending, - cancel: releasePending, - }; - } -} diff --git a/plugins/automations/src/rpc-types.ts b/plugins/automations/src/rpc-types.ts index 41be2cb59b..3d45f569fc 100644 --- a/plugins/automations/src/rpc-types.ts +++ b/plugins/automations/src/rpc-types.ts @@ -14,6 +14,19 @@ export const AUTOMATION_RUNS_LIMIT_MAX = 200; export const permissionModeSchema = z.enum(["accept-edits", "auto", "full"]); export type PermissionMode = z.infer; +export const reasoningLevelSchema = z.enum([ + "none", + "low", + "medium", + "high", + "xhigh", + "ultracode", + "max", + "ultra", +]); +export type ReasoningLevel = z.infer; +export const serviceTierSchema = z.enum(["default", "fast"]); +export type ServiceTier = z.infer; export const unmanagedBranchSpecSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("existing"), name: z.string().min(1) }).strict(), @@ -125,6 +138,8 @@ const automationAgentExecutionSchema = z prompt: z.string().min(1).max(AUTOMATION_PROMPT_MAX_LENGTH), providerId: z.string().min(1), model: z.string().min(1), + reasoningLevel: reasoningLevelSchema.default("medium"), + serviceTier: serviceTierSchema.optional(), permissionMode: permissionModeSchema, environment: agentEnvironmentSchema, targetThreadId: z.string().min(1).optional(), @@ -208,7 +223,11 @@ const agentExecutionTargetSchema = z.discriminatedUnion("type", [ const agentExecutionUpdateSchema = z .object({ prompt: z.string().min(1).max(AUTOMATION_PROMPT_MAX_LENGTH).optional(), + providerId: z.string().min(1).optional(), model: z.string().min(1).optional(), + reasoningLevel: reasoningLevelSchema.optional(), + /** Null explicitly clears a tier that the previous provider supported. */ + serviceTier: serviceTierSchema.nullable().optional(), permissionMode: permissionModeSchema.optional(), target: agentExecutionTargetSchema.optional(), }) @@ -216,40 +235,16 @@ const agentExecutionUpdateSchema = z .refine( (value) => value.prompt !== undefined || + value.providerId !== undefined || value.model !== undefined || + value.reasoningLevel !== undefined || + value.serviceTier !== undefined || value.permissionMode !== undefined || value.target !== undefined, { message: "at least one agent execution field is required" }, ); export type AgentExecutionUpdate = z.infer; -export const automationExecutionOptionsResponseSchema = z - .object({ - models: z.array( - z - .object({ - id: z.string().min(1), - model: z.string().min(1), - displayName: z.string().min(1), - }) - .strict(), - ), - permissionModes: z.array(permissionModeSchema), - }) - .strict(); -export type AutomationExecutionOptionsResponse = z.infer< - typeof automationExecutionOptionsResponseSchema ->; - -export const automationPermissionOptionsResponseSchema = z - .object({ - permissionModes: z.array(permissionModeSchema), - }) - .strict(); -export type AutomationPermissionOptionsResponse = z.infer< - typeof automationPermissionOptionsResponseSchema ->; - export const automationResponseSchema = z .object({ id: z.string(), diff --git a/plugins/automations/src/rpc.ts b/plugins/automations/src/rpc.ts index 0f1928a7cf..f106f14ea6 100644 --- a/plugins/automations/src/rpc.ts +++ b/plugins/automations/src/rpc.ts @@ -1,7 +1,5 @@ import { automationListResponseSchema, - automationExecutionOptionsResponseSchema, - automationPermissionOptionsResponseSchema, automationResponseSchema, automationRunListResponseSchema, automationRunRpcResponseSchema, @@ -33,14 +31,6 @@ export const automationRpcContract = defineRpcContract({ input: projectAutomationInputSchema, output: automationResponseSchema, }, - automations_execution_options: { - input: projectAutomationInputSchema, - output: automationExecutionOptionsResponseSchema, - }, - automations_permission_options: { - input: projectAutomationInputSchema, - output: automationPermissionOptionsResponseSchema, - }, automations_create: { input: createAutomationInputSchema, output: automationResponseSchema, @@ -82,16 +72,6 @@ export function createRpcHandlers(service: AutomationService) { automations_get(input: z.output) { return service.get(input); }, - automations_execution_options( - input: z.output, - ) { - return service.executionOptions(input); - }, - automations_permission_options( - input: z.output, - ) { - return service.permissionOptions(input); - }, automations_create(input: z.output) { return service.create(input); }, diff --git a/plugins/automations/src/run.ts b/plugins/automations/src/run.ts index f8543fcb23..376e923be6 100644 --- a/plugins/automations/src/run.ts +++ b/plugins/automations/src/run.ts @@ -117,6 +117,10 @@ export async function executeAgentRun( title: args.automation.name, providerId: args.execution.providerId, model: args.execution.model, + reasoningLevel: args.execution.reasoningLevel, + ...(args.execution.serviceTier === undefined + ? {} + : { serviceTier: args.execution.serviceTier }), permissionMode: args.execution.permissionMode, }), ); diff --git a/plugins/automations/src/server-harness.test.ts b/plugins/automations/src/server-harness.test.ts index 7ba32d5a6a..cb4034400d 100644 --- a/plugins/automations/src/server-harness.test.ts +++ b/plugins/automations/src/server-harness.test.ts @@ -24,8 +24,6 @@ const rpcMethods = [ "automations_overview", "automations_list", "automations_get", - "automations_execution_options", - "automations_permission_options", "automations_create", "automations_update", "automations_delete", @@ -83,28 +81,13 @@ async function bootAutomationsPlugin( routedPermissionModes !== undefined ? routedPermissionModes : declaredPermissionModes; - return [{ id: "codex", capabilities: { permissionModes } }] as never; - }, - async models() { - return { - providers: [ - { - id: "codex", - available: true, - capabilities: { permissionModes: declaredPermissionModes }, - }, - ], - permissionCeiling: "full", - models: [ - { - id: "gpt-5.6-codex", - model: "gpt-5.6-codex", - displayName: "5.6 Sol", - }, - ], - selectedOnlyModels: [], - modelLoadError: null, - } as never; + return [ + { id: "codex", capabilities: { permissionModes } }, + { + id: "claude", + capabilities: { permissionModes: ["auto", "full"] }, + }, + ] as never; }, }, threads: { @@ -351,6 +334,10 @@ describe("automations server plugin harness", () => { "codex", "--model", "gpt-5", + "--reasoning", + "ultra", + "--service-tier", + "fast", "--permission-mode", "accept-edits", "--target-thread", @@ -366,6 +353,8 @@ describe("automations server plugin harness", () => { prompt: "triage the inbox", providerId: "codex", model: "gpt-5", + reasoningLevel: "ultra", + serviceTier: "fast", permissionMode: "accept-edits", environment: { type: "project-default" }, targetThreadId: THREAD_ID, @@ -648,7 +637,10 @@ describe("automations server plugin harness", () => { automationId: created.id, agent: { prompt: "updated by RPC", - model: "gpt-5.6-codex", + providerId: "claude", + model: "claude-opus-5", + reasoningLevel: "ultra", + serviceTier: "fast", permissionMode: "full", target: { type: "target-thread", threadId: THREAD_ID }, }, @@ -659,31 +651,16 @@ describe("automations server plugin harness", () => { execution: { mode: "agent", prompt: "updated by RPC", - providerId: "codex", - model: "gpt-5.6-codex", + providerId: "claude", + model: "claude-opus-5", + reasoningLevel: "ultra", + serviceTier: "fast", permissionMode: "full", environment: { type: "project-default" }, targetThreadId: THREAD_ID, }, }); - const options = await harness.callRpc("automations_execution_options", { - projectId: PROJECT_ID, - automationId: created.id, - }); - expect(options).toMatchObject({ - models: [{ model: "gpt-5.6-codex", displayName: "5.6 Sol" }], - permissionModes: ["accept-edits", "auto", "full"], - }); - await expect( - harness.callRpc("automations_permission_options", { - projectId: PROJECT_ID, - automationId: created.id, - }), - ).resolves.toEqual({ - permissionModes: ["accept-edits", "auto", "full"], - }); - await expect( harness.callRpc("automations_update", { projectId: PROJECT_ID, diff --git a/plugins/automations/src/service.ts b/plugins/automations/src/service.ts index e5c0b6aac9..5955dc8902 100644 --- a/plugins/automations/src/service.ts +++ b/plugins/automations/src/service.ts @@ -32,8 +32,6 @@ import { automationsOverviewResponseSchema, type AgentExecutionUpdate, type AutomationExecution, - type AutomationExecutionOptionsResponse, - type AutomationPermissionOptionsResponse, type AutomationRunListResponse, type AutomationRunRpcResponse, type AutomationResponse, @@ -61,8 +59,7 @@ import { executeAgentRun, executeScriptRun } from "./run.js"; type ServiceApi = Pick & { sdk: { projects: Pick; - providers: Pick & - Partial>; + providers: Pick; threads: Pick; }; }; @@ -74,14 +71,6 @@ export interface AutomationService { projectId: string; automationId: string; }): Promise; - executionOptions(input: { - projectId: string; - automationId: string; - }): Promise; - permissionOptions(input: { - projectId: string; - automationId: string; - }): Promise; create(input: ResolvedCreateAutomationInput): Promise; update(input: UpdateAutomationInput): Promise; delete(input: { @@ -307,11 +296,22 @@ function applyAgentExecutionUpdate( const next = { ...execution, ...(update.prompt !== undefined ? { prompt: update.prompt } : {}), + ...(update.providerId !== undefined + ? { providerId: update.providerId } + : {}), ...(update.model !== undefined ? { model: update.model } : {}), + ...(update.reasoningLevel !== undefined + ? { reasoningLevel: update.reasoningLevel } + : {}), ...(update.permissionMode !== undefined ? { permissionMode: update.permissionMode } : {}), }; + if (update.serviceTier === null) { + delete next.serviceTier; + } else if (update.serviceTier !== undefined) { + next.serviceTier = update.serviceTier; + } if (update.target === undefined) return next; if (update.target.type === "target-thread") { return { ...next, targetThreadId: update.target.threadId }; @@ -440,68 +440,6 @@ export function createAutomationService(args: { }); }, - async executionOptions(input) { - const automation = requireProjectAutomation(db, input); - const execution = parseAutomationExecution(automation.execution); - if (execution.mode !== "agent") { - throw new Error( - "Execution options are only available for agent automations", - ); - } - const routing = providerRoutingForEnvironment(execution.environment); - const loadModels = bb.sdk.providers.models; - if (loadModels === undefined) { - throw new Error("Provider model discovery is unavailable."); - } - const options = await loadModels({ - ...routing, - providerId: execution.providerId, - }); - const provider = options.providers.find( - (candidate) => candidate.id === execution.providerId, - ); - if (provider === undefined || !provider.available) { - throw new Error(`Provider ${execution.providerId} is not available.`); - } - const seenModels = new Set(); - const models = [...options.selectedOnlyModels, ...options.models] - .filter((model) => { - if (seenModels.has(model.model)) return false; - seenModels.add(model.model); - return true; - }) - .map(({ id, model, displayName }) => ({ id, model, displayName })); - const permissionModes = provider.capabilities.permissionModes; - return { models, permissionModes }; - }, - - async permissionOptions(input) { - const automation = requireProjectAutomation(db, input); - const execution = parseAutomationExecution(automation.execution); - if (execution.mode !== "agent") { - throw new Error( - "Permission options are only available for agent automations", - ); - } - const environment = execution.environment; - const routing = - environment.type === "reuse" - ? { environmentId: environment.environmentId } - : environment.type === "host" && environment.hostId !== undefined - ? { hostId: environment.hostId } - : {}; - const providers = await bb.sdk.providers.list(routing); - const provider = providers.find( - (candidate) => candidate.id === execution.providerId, - ); - if (provider === undefined || provider.available === false) { - throw new Error(`Provider ${execution.providerId} is not available.`); - } - return { - permissionModes: provider.capabilities.permissionModes, - }; - }, - async create(payload) { await requireProjectAvailable(bb, payload.projectId); const now = Date.now(); @@ -592,7 +530,11 @@ export function createAutomationService(args: { currentExecution, input.agent, ); - if (input.agent.permissionMode !== undefined) { + if ( + input.agent.providerId !== undefined || + input.agent.permissionMode !== undefined || + input.agent.target?.type === "environment" + ) { if (currentExecution.mode !== "agent") { throw new Error( "Agent execution options can only update agent automations", @@ -601,7 +543,7 @@ export function createAutomationService(args: { await resolvePermissionMode( bb, updatedExecution.providerId, - input.agent.permissionMode, + updatedExecution.permissionMode, providerRoutingForEnvironment(updatedExecution.environment), ); } diff --git a/plugins/tasks/README.md b/plugins/tasks/README.md index bfe2666571..9ead4ee9ca 100644 --- a/plugins/tasks/README.md +++ b/plugins/tasks/README.md @@ -99,8 +99,8 @@ than traversing an inconsistent snapshot. Linking a Tasks project to a bb project enables delegation. Open a task, choose **Delegate**, select a preset, and optionally add instructions. A preset -defines the provider, model, reasoning level, permission mode, and reusable -instructions. Presets are user-defined, so create the worker profiles your team +defines the provider, model, reasoning level, optional service tier, permission +mode, and reusable instructions. Presets are user-defined, so create the worker profiles your team uses repeatedly before dispatching work. Delegation creates a worker thread in the linked bb project, attaches that diff --git a/plugins/tasks/api/api.test.ts b/plugins/tasks/api/api.test.ts index ce3ae9d81b..d53b4f3c07 100644 --- a/plugins/tasks/api/api.test.ts +++ b/plugins/tasks/api/api.test.ts @@ -433,85 +433,6 @@ describe("Tasks RPC domain API", () => { await harness.dispose(); }); - it("lists providers and provider models from the BB SDK", async () => { - const { bb, harness } = createFakePluginHost({ - pluginId: "tasks", - sdk: { - providers: { - list: async () => [ - { - id: "codex", - displayName: "Codex", - capabilities: { - permissionModes: ["accept-edits", "auto", "full"], - }, - }, - { - id: "claude-code", - displayName: "Claude Code", - capabilities: { - permissionModes: ["accept-edits", "auto", "full"], - }, - }, - ], - models: async () => ({ - models: [ - { - model: "gpt-5.6-sol", - displayName: "GPT-5.6", - isDefault: true, - supportedReasoningEfforts: [ - { reasoningEffort: "medium" }, - { reasoningEffort: "high" }, - { reasoningEffort: "ultra" }, - ], - }, - { - model: "gpt-5.5", - displayName: "GPT-5.5", - isDefault: false, - supportedReasoningEfforts: [ - { reasoningEffort: "low" }, - { reasoningEffort: "high" }, - ], - }, - ], - }), - }, - }, - }); - registerTasksApi(bb, createStore(bb)); - - await expect(harness.callRpc("listProviders", {})).resolves.toEqual({ - providers: [ - { - id: "codex", - name: "Codex", - permissionModes: ["accept-edits", "auto", "full"], - }, - { - id: "claude-code", - name: "Claude Code", - permissionModes: ["accept-edits", "auto", "full"], - }, - ], - }); - await expect( - harness.callRpc("listProviderModels", { providerId: "codex" }), - ).resolves.toEqual({ - models: [ - { id: "gpt-5.6-sol", name: "GPT-5.6", isDefault: true }, - { id: "gpt-5.5", name: "GPT-5.5", isDefault: false }, - ], - reasoningLevels: ["low", "medium", "high", "ultra"], - }); - expect(harness.sdk.callsTo("providers.list")).toEqual([[]]); - expect(harness.sdk.callsTo("providers.models")).toEqual([ - [{ providerId: "codex" }], - ]); - await harness.dispose(); - }); - it("lists machines as id/name options from the BB SDK", async () => { const { bb, harness } = createFakePluginHost({ pluginId: "tasks", @@ -545,34 +466,6 @@ describe("Tasks RPC domain API", () => { await harness.dispose(); }); - it("falls back to the standard reasoning levels when models omit metadata", async () => { - const { bb, harness } = createFakePluginHost({ - pluginId: "tasks", - sdk: { - providers: { - models: async () => ({ - models: [ - { - model: "model-without-efforts", - displayName: "Model without efforts", - isDefault: true, - supportedReasoningEfforts: [], - }, - ], - }), - }, - }, - }); - registerTasksApi(bb, createStore(bb)); - - await expect( - harness.callRpc("listProviderModels", { providerId: "test" }), - ).resolves.toMatchObject({ - reasoningLevels: ["low", "medium", "high", "xhigh", "max", "ultra"], - }); - await harness.dispose(); - }); - it("searches threads and returns recent threads in updated order", async () => { const thread = ( id: string, @@ -1205,6 +1098,7 @@ describe("Tasks RPC domain API", () => { providerId: "claude-code", modelId: "claude-sonnet-5", reasoningLevel: "high", + serviceTier: null, permissionMode: "full", environmentKind: "project-default", baseBranch: null, diff --git a/plugins/tasks/api/index.ts b/plugins/tasks/api/index.ts index 150071d2a5..cd982f1f95 100644 --- a/plugins/tasks/api/index.ts +++ b/plugins/tasks/api/index.ts @@ -46,15 +46,6 @@ interface SummaryRow { active_agent_count: number; } -const PRESET_REASONING_LEVELS = [ - "low", - "medium", - "high", - "xhigh", - "max", - "ultra", -] as const; - const MAX_THREAD_SEARCH_RESULTS = 10; export interface TasksApiStore { @@ -1012,44 +1003,6 @@ export function registerHandlers( listPresets() { return { presets: store.tasks.listPresets() }; }, - async listProviders() { - const providers = await bb.sdk.providers.list(); - return { - providers: providers.map((provider) => ({ - id: provider.id, - name: provider.displayName, - permissionModes: provider.capabilities.permissionModes, - })), - }; - }, - async listProviderModels(input) { - const result = await bb.sdk.providers.models({ - providerId: input.providerId, - }); - const supportedReasoningLevels = new Set( - result.models.flatMap((model) => - model.supportedReasoningEfforts.map( - (effort) => effort.reasoningEffort, - ), - ), - ); - const reasoningLevels = PRESET_REASONING_LEVELS.filter((level) => - supportedReasoningLevels.has(level), - ); - return { - models: result.models.map((model) => ({ - id: model.model, - name: model.displayName, - isDefault: model.isDefault, - })), - // The SDK has model-level reasoning metadata but no provider-level - // list. Fall back to the standard picker levels when models omit it. - reasoningLevels: - reasoningLevels.length > 0 - ? reasoningLevels - : [...PRESET_REASONING_LEVELS], - }; - }, async listMachines() { const machines = await bb.sdk.hosts.list(); return { diff --git a/plugins/tasks/cli/cli.test.ts b/plugins/tasks/cli/cli.test.ts index 9e64e0ecfc..9561f2f986 100644 --- a/plugins/tasks/cli/cli.test.ts +++ b/plugins/tasks/cli/cli.test.ts @@ -952,6 +952,8 @@ describe("bb tasks CLI", () => { "gpt-5.6-sol", "--reasoning", "high", + "--service-tier", + "fast", "--permission", "accept-edits", "--environment", @@ -969,6 +971,9 @@ describe("bb tasks CLI", () => { expect(created).toMatchObject({ name: "CLI worker", providerId: "codex", + modelId: "gpt-5.6-sol", + reasoningLevel: "high", + serviceTier: "fast", permissionMode: "accept-edits", environmentKind: "new-worktree", baseBranch: "main", @@ -981,6 +986,7 @@ describe("bb tasks CLI", () => { expect(shown).toContain("Environment worktree"); expect(shown).toContain("Base branch main"); expect(shown).toContain("Machine host_air"); + expect(shown).toContain("Service tier fast"); const updated = JSON.parse( stdout( @@ -990,6 +996,8 @@ describe("bb tasks CLI", () => { "CLI worker", "--reasoning", "ultra", + "--service-tier", + "none", "--name", "CLI reviewer", "--environment", @@ -1002,6 +1010,7 @@ describe("bb tasks CLI", () => { id: created.id, name: "CLI reviewer", reasoningLevel: "ultra", + serviceTier: null, environmentKind: "project-default", baseBranch: null, machineId: null, @@ -1011,6 +1020,7 @@ describe("bb tasks CLI", () => { expect(listTable).toContain("ENVIRONMENT"); expect(listTable).toContain("BASE BRANCH"); expect(listTable).toContain("MACHINE"); + expect(listTable).toContain("SERVICE TIER"); const listed = JSON.parse( stdout(await harness.runCli(["preset", "list", "--json"])), diff --git a/plugins/tasks/cli/index.ts b/plugins/tasks/cli/index.ts index dbc7430f7f..a39417b0bb 100644 --- a/plugins/tasks/cli/index.ts +++ b/plugins/tasks/cli/index.ts @@ -115,8 +115,8 @@ Pass --machine to target another enrolled machine explicitly.`; const PRESET_HELP = `Usage: bb tasks preset list [--json] bb tasks preset show [--json] - bb tasks preset create --name --provider --model --reasoning --permission [--environment project-default|worktree] [--base-branch ] [--machine ] [--instructions ] [--json] - bb tasks preset update [--name ] [--provider ] [--model ] [--reasoning ] [--permission ] [--environment project-default|worktree] [--base-branch ] [--machine ] [--instructions ] [--json] + bb tasks preset create --name --provider --model --reasoning --permission [--service-tier default|fast|none] [--environment project-default|worktree] [--base-branch ] [--machine ] [--instructions ] [--json] + bb tasks preset update [--name ] [--provider ] [--model ] [--reasoning ] [--permission ] [--service-tier default|fast|none] [--environment project-default|worktree] [--base-branch ] [--machine ] [--instructions ] [--json] bb tasks preset delete [--json]`; const DISPATCH_HELP = "Usage: bb tasks dispatch --preset [--instructions ] [--json]"; @@ -447,6 +447,17 @@ function parsePresetEnvironment( ); } +function parsePresetServiceTier( + value: string | undefined, +): "default" | "fast" | null | undefined { + if (value === undefined) return undefined; + if (value === "default" || value === "fast") return value; + if (value === "none") return null; + throw new CliError( + `invalid --service-tier ${value}; expected default, fast, or none`, + ); +} + async function resolveMachineId( domain: TasksDomain, address: string, @@ -1622,6 +1633,7 @@ async function runPreset(domain: TasksDomain, argv: string[]): Promise { "PROVIDER", "MODEL", "REASONING", + "SERVICE TIER", "PERMISSION", "ENVIRONMENT", "BASE BRANCH", @@ -1634,6 +1646,7 @@ async function runPreset(domain: TasksDomain, argv: string[]): Promise { preset.providerId, preset.modelId, preset.reasoningLevel, + preset.serviceTier ?? "-", preset.permissionMode, preset.environmentKind === "new-worktree" ? "worktree" @@ -1662,6 +1675,7 @@ async function runPreset(domain: TasksDomain, argv: string[]): Promise { ["Provider", preset.providerId], ["Model", preset.modelId], ["Reasoning", preset.reasoningLevel], + ["Service tier", preset.serviceTier ?? "-"], ["Permission", preset.permissionMode], [ "Environment", @@ -1683,6 +1697,7 @@ async function runPreset(domain: TasksDomain, argv: string[]): Promise { "provider", "model", "reasoning", + "service-tier", "permission", "environment", "base-branch", @@ -1704,6 +1719,8 @@ async function runPreset(domain: TasksDomain, argv: string[]): Promise { providerId: requireOption(args, "provider"), modelId: requireOption(args, "model"), reasoningLevel: requireOption(args, "reasoning"), + serviceTier: + parsePresetServiceTier(option(args, "service-tier")) ?? null, permissionMode: requireOption(args, "permission"), environmentKind, baseBranch: baseBranch ?? null, @@ -1726,6 +1743,7 @@ async function runPreset(domain: TasksDomain, argv: string[]): Promise { "provider", "model", "reasoning", + "service-tier", "permission", "environment", "base-branch", @@ -1754,6 +1772,7 @@ async function runPreset(domain: TasksDomain, argv: string[]): Promise { providerId: option(args, "provider"), modelId: option(args, "model"), reasoningLevel: option(args, "reasoning"), + serviceTier: parsePresetServiceTier(option(args, "service-tier")), permissionMode: option(args, "permission"), environmentKind: environmentOption === undefined ? undefined : environmentKind, diff --git a/plugins/tasks/db.test.ts b/plugins/tasks/db.test.ts index 3bfe3323db..2b29085ef9 100644 --- a/plugins/tasks/db.test.ts +++ b/plugins/tasks/db.test.ts @@ -59,7 +59,7 @@ describe("tasks storage", () => { { count: number } >("SELECT COUNT(*) AS count FROM schema_version") .get()?.count, - ).toBe(5); + ).toBe(6); } finally { await harness.dispose(); } @@ -69,7 +69,7 @@ describe("tasks storage", () => { const { db, harness } = setup(); try { db.exec(` - DELETE FROM schema_version WHERE version = 3; + DELETE FROM schema_version WHERE version IN (3, 6); DROP TABLE presets; CREATE TABLE presets ( id TEXT PRIMARY KEY, @@ -99,6 +99,7 @@ describe("tasks storage", () => { environmentKind: "project-default", baseBranch: null, machineId: null, + serviceTier: null, }); } finally { await harness.dispose(); @@ -787,6 +788,7 @@ describe("tasks storage", () => { providerId: "openai", modelId: "gpt-5", reasoningLevel: "high", + serviceTier: null, permissionMode: "accept-edits", environmentKind: "project-default" as const, baseBranch: null, diff --git a/plugins/tasks/db/schema.ts b/plugins/tasks/db/schema.ts index 46eb00b21f..0392a29a8b 100644 --- a/plugins/tasks/db/schema.ts +++ b/plugins/tasks/db/schema.ts @@ -235,6 +235,10 @@ const MIGRATIONS = [ END WHERE permission_mode IN ('workspace-write', 'readonly'); `, + ` + ALTER TABLE presets ADD COLUMN service_tier TEXT + CHECK (service_tier IN ('default', 'fast')); + `, ] as const; export function initializeTasksSchema(db: PluginDatabase): void { diff --git a/plugins/tasks/db/store.ts b/plugins/tasks/db/store.ts index 8af3aac2ba..b71ce6486d 100644 --- a/plugins/tasks/db/store.ts +++ b/plugins/tasks/db/store.ts @@ -7,7 +7,11 @@ import { TASKS_PAGE_MAX_LIMIT, type TaskSort, } from "../shared/pagination.js"; -import { presetPermissionModeSchema } from "../shared/contract.js"; +import { + presetPermissionModeSchema, + presetReasoningLevelSchema, + presetServiceTierSchema, +} from "../shared/contract.js"; import type { Attachment, Comment, @@ -153,6 +157,7 @@ interface PresetRow { provider_id: string; model_id: string; reasoning_level: string; + service_tier: string | null; permission_mode: string; environment_kind: PresetEnvironmentKind; base_branch: string | null; @@ -436,7 +441,11 @@ function presetFromRow(row: PresetRow): Preset { name: row.name, providerId: row.provider_id, modelId: row.model_id, - reasoningLevel: row.reasoning_level, + reasoningLevel: presetReasoningLevelSchema.parse(row.reasoning_level), + serviceTier: + row.service_tier === null + ? null + : presetServiceTierSchema.parse(row.service_tier), permissionMode: presetPermissionModeSchema.parse(row.permission_mode), environmentKind: row.environment_kind, baseBranch: row.base_branch, @@ -1714,6 +1723,7 @@ export function createTasksStore(db: PluginDatabase) { string, string, string, + "default" | "fast" | null, string, PresetEnvironmentKind, string | null, @@ -1725,10 +1735,11 @@ export function createTasksStore(db: PluginDatabase) { >( ` INSERT INTO presets ( - id, name, provider_id, model_id, reasoning_level, permission_mode, + id, name, provider_id, model_id, reasoning_level, service_tier, + permission_mode, environment_kind, base_branch, machine_id, instructions, builtin, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, ).run( id, @@ -1736,6 +1747,7 @@ export function createTasksStore(db: PluginDatabase) { requireNonEmpty(input.providerId, "Preset providerId"), requireNonEmpty(input.modelId, "Preset modelId"), requireNonEmpty(input.reasoningLevel, "Preset reasoningLevel"), + input.serviceTier, requireNonEmpty(input.permissionMode, "Preset permissionMode"), environment.environmentKind, environment.baseBranch, @@ -1772,6 +1784,7 @@ export function createTasksStore(db: PluginDatabase) { string, string, string, + "default" | "fast" | null, string, PresetEnvironmentKind, string | null, @@ -1784,7 +1797,7 @@ export function createTasksStore(db: PluginDatabase) { ` UPDATE presets SET name = ?, provider_id = ?, model_id = ?, reasoning_level = ?, - permission_mode = ?, environment_kind = ?, base_branch = ?, + service_tier = ?, permission_mode = ?, environment_kind = ?, base_branch = ?, machine_id = ?, instructions = ?, builtin = ? WHERE id = ? `, @@ -1801,6 +1814,7 @@ export function createTasksStore(db: PluginDatabase) { input.reasoningLevel === undefined ? current.reasoningLevel : requireNonEmpty(input.reasoningLevel, "Preset reasoningLevel"), + input.serviceTier === undefined ? current.serviceTier : input.serviceTier, input.permissionMode === undefined ? current.permissionMode : requireNonEmpty(input.permissionMode, "Preset permissionMode"), diff --git a/plugins/tasks/db/types.ts b/plugins/tasks/db/types.ts index 0a1d10bd16..a3c0b8f7b8 100644 --- a/plugins/tasks/db/types.ts +++ b/plugins/tasks/db/types.ts @@ -2,6 +2,8 @@ import type { TaskSort } from "../shared/pagination.js"; import type { PRESET_ENVIRONMENT_KINDS, PresetPermissionMode, + PresetReasoningLevel, + PresetServiceTier, TASK_THREAD_LIVE_STATUSES, TaskPriority, TaskStatus, @@ -101,7 +103,8 @@ export interface Preset { name: string; providerId: string; modelId: string; - reasoningLevel: string; + reasoningLevel: PresetReasoningLevel; + serviceTier: PresetServiceTier | null; permissionMode: PresetPermissionMode; environmentKind: PresetEnvironmentKind; baseBranch: string | null; @@ -263,7 +266,8 @@ export interface CreatePresetInput { name: string; providerId: string; modelId: string; - reasoningLevel: string; + reasoningLevel: PresetReasoningLevel; + serviceTier: PresetServiceTier | null; permissionMode: PresetPermissionMode; environmentKind: PresetEnvironmentKind; baseBranch: string | null; @@ -276,7 +280,8 @@ export interface UpdatePresetInput { name?: string; providerId?: string; modelId?: string; - reasoningLevel?: string; + reasoningLevel?: PresetReasoningLevel; + serviceTier?: PresetServiceTier | null; permissionMode?: PresetPermissionMode; environmentKind?: PresetEnvironmentKind; baseBranch?: string | null; diff --git a/plugins/tasks/delegate/delegate.test.ts b/plugins/tasks/delegate/delegate.test.ts index 9477fca0a3..5cbab10aba 100644 --- a/plugins/tasks/delegate/delegate.test.ts +++ b/plugins/tasks/delegate/delegate.test.ts @@ -21,6 +21,7 @@ function createTestPreset( providerId: "claude-code", modelId: "claude-sonnet-5", reasoningLevel: "high", + serviceTier: "fast", permissionMode: "full", environmentKind: overrides.environmentKind ?? "project-default", baseBranch: overrides.baseBranch ?? null, @@ -75,6 +76,7 @@ describe("task delegation", () => { providerId: "claude-code", model: "claude-sonnet-5", reasoningLevel: "high", + serviceTier: "fast", permissionMode: "full", title: "TASK-1 · Implement delegation", prompt: expect.stringContaining( diff --git a/plugins/tasks/delegate/index.ts b/plugins/tasks/delegate/index.ts index 5b6f342ece..308a242158 100644 --- a/plugins/tasks/delegate/index.ts +++ b/plugins/tasks/delegate/index.ts @@ -38,6 +38,7 @@ const presetExecutionSchema = z "max", "ultra", ]), + serviceTier: z.enum(["default", "fast"]).nullable(), permissionMode: presetPermissionModeSchema, }) .strict(); @@ -316,6 +317,7 @@ export function handlers( providerId: preset.providerId, model: preset.modelId, reasoningLevel: preset.reasoningLevel, + serviceTier: preset.serviceTier, permissionMode: preset.permissionMode, }); const prompt = buildSeedPrompt({ @@ -336,6 +338,9 @@ export function handlers( providerId: execution.providerId, model: execution.model, reasoningLevel: execution.reasoningLevel, + ...(execution.serviceTier === null + ? {} + : { serviceTier: execution.serviceTier }), permissionMode: execution.permissionMode, title, prompt, diff --git a/plugins/tasks/package.json b/plugins/tasks/package.json index ffb845b6aa..5644b91b99 100644 --- a/plugins/tasks/package.json +++ b/plugins/tasks/package.json @@ -23,7 +23,7 @@ ], "engines": { "bb": ">=0.0", - "bbPluginSdk": ">=0.4.10" + "bbPluginSdk": ">=0.4.13" }, "bb": { "name": "Tasks", diff --git a/plugins/tasks/shared/contract.ts b/plugins/tasks/shared/contract.ts index 693374c55c..2efc24c304 100644 --- a/plugins/tasks/shared/contract.ts +++ b/plugins/tasks/shared/contract.ts @@ -42,14 +42,19 @@ const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; const idSchema = z.string().regex(ULID_PATTERN, "must be a ULID"); const nonBlankStringSchema = z.string().trim().min(1, "must not be blank"); -const presetReasoningLevelSchema = z.enum([ +export const presetReasoningLevelSchema = z.enum([ + "none", "low", "medium", "high", "xhigh", + "ultracode", "max", "ultra", ]); +export type PresetReasoningLevel = z.infer; +export const presetServiceTierSchema = z.enum(["default", "fast"]); +export type PresetServiceTier = z.infer; export const PRESET_PERMISSION_MODES = [ "accept-edits", "auto", @@ -236,7 +241,8 @@ const presetSchema = z name: z.string(), providerId: z.string(), modelId: z.string(), - reasoningLevel: z.string(), + reasoningLevel: presetReasoningLevelSchema, + serviceTier: presetServiceTierSchema.nullable(), permissionMode: presetPermissionModeSchema, environmentKind: presetEnvironmentKindSchema, baseBranch: nullablePresetTargetSchema, @@ -364,6 +370,7 @@ const updatePresetInputSchema = z providerId: nonBlankStringSchema.optional(), modelId: nonBlankStringSchema.optional(), reasoningLevel: presetReasoningLevelSchema.optional(), + serviceTier: presetServiceTierSchema.nullable().optional(), permissionMode: presetPermissionModeSchema.optional(), environmentKind: presetEnvironmentKindSchema.optional(), baseBranch: nullablePresetTargetSchema.optional(), @@ -377,6 +384,7 @@ const updatePresetInputSchema = z input.providerId !== undefined || input.modelId !== undefined || input.reasoningLevel !== undefined || + input.serviceTier !== undefined || input.permissionMode !== undefined || input.environmentKind !== undefined || input.baseBranch !== undefined || @@ -648,6 +656,7 @@ export const tasksRpcContract = defineRpcContract({ providerId: nonBlankStringSchema, modelId: nonBlankStringSchema, reasoningLevel: presetReasoningLevelSchema, + serviceTier: presetServiceTierSchema.nullable().default(null), permissionMode: presetPermissionModeSchema, environmentKind: presetEnvironmentKindSchema.default("project-default"), baseBranch: nullablePresetTargetSchema.default(null), @@ -691,39 +700,6 @@ export const tasksRpcContract = defineRpcContract({ input: z.null(), output: z.object({ presets: z.array(presetSchema) }).strict(), }, - listProviders: { - input: z.object({}).strict(), - output: z - .object({ - providers: z.array( - z - .object({ - id: z.string(), - name: z.string(), - permissionModes: z.array(presetPermissionModeSchema), - }) - .strict(), - ), - }) - .strict(), - }, - listProviderModels: { - input: z.object({ providerId: nonBlankStringSchema }).strict(), - output: z - .object({ - models: z.array( - z - .object({ - id: z.string(), - name: z.string(), - isDefault: z.boolean(), - }) - .strict(), - ), - reasoningLevels: z.array(z.string()), - }) - .strict(), - }, listMachines: { input: z.object({}).strict(), output: z diff --git a/plugins/tasks/shell/shell.test.tsx b/plugins/tasks/shell/shell.test.tsx index 3d4b95282f..a5a503b70c 100644 --- a/plugins/tasks/shell/shell.test.tsx +++ b/plugins/tasks/shell/shell.test.tsx @@ -1101,6 +1101,7 @@ describe("tasks app shell", () => { providerId: "claude-code", modelId: "claude-sonnet-5", reasoningLevel: "medium", + serviceTier: null, permissionMode: "accept-edits", environmentKind: "project-default", baseBranch: null, diff --git a/plugins/tasks/skills/tasks/SKILL.md b/plugins/tasks/skills/tasks/SKILL.md index ade362db66..5cac8a4623 100644 --- a/plugins/tasks/skills/tasks/SKILL.md +++ b/plugins/tasks/skills/tasks/SKILL.md @@ -12,6 +12,18 @@ Delegation presets are user-defined; Tasks ships with none. Before dispatching work, use `bb tasks preset list` and create a preset if the required one does not already exist. Dispatch requires an existing preset. +Create or update the same execution selection exposed in the Tasks UI with +`--provider`, `--model`, `--reasoning`, and optional +`--service-tier default|fast|none`: + +```sh +bb tasks preset create --name "Codex high" --provider codex \ + --model gpt-5.6-sol --reasoning high --service-tier fast \ + --permission auto +``` + +`preset update` accepts the same flags; `--service-tier none` clears a tier. + ## Work a task 1. Find and read the task before acting: diff --git a/plugins/tasks/views/manage/manage-panel.tsx b/plugins/tasks/views/manage/manage-panel.tsx index 3e1e8e7754..72f54e79c1 100644 --- a/plugins/tasks/views/manage/manage-panel.tsx +++ b/plugins/tasks/views/manage/manage-panel.tsx @@ -312,6 +312,7 @@ function PresetsSection() { Provider Model Reasoning + Tier Permissions Environment Instructions @@ -343,6 +344,9 @@ function PresetsSection() { {preset.reasoningLevel} + + {preset.serviceTier ?? "—"} + {permission ? PERMISSION_LABELS[permission] diff --git a/plugins/tasks/views/manage/manage.test.tsx b/plugins/tasks/views/manage/manage.test.tsx index 6405e5300f..a81c98af53 100644 --- a/plugins/tasks/views/manage/manage.test.tsx +++ b/plugins/tasks/views/manage/manage.test.tsx @@ -36,7 +36,7 @@ if (!window.matchMedia) { // imported before it runs. const app = await loadPluginApp(() => import("../../app")); const { derivePrefix } = await import("./shared.js"); -const { defaultPermissionMode, describePresetEnvironment, savePresetDraft } = +const { describePresetEnvironment, savePresetDraft } = await import("./preset-dialog.js"); afterEach(cleanup); @@ -563,6 +563,7 @@ function presetRow(overrides: Record = {}) { providerId: "claude-code", modelId: "claude-sonnet-5", reasoningLevel: "medium", + serviceTier: null, permissionMode: "accept-edits", environmentKind: "new-worktree", baseBranch: "main", @@ -601,21 +602,13 @@ describe("describePresetEnvironment", () => { }); }); -describe("preset permission defaults", () => { - it("prefers Auto and otherwise falls back to Full Access", () => { - expect(defaultPermissionMode(["accept-edits", "auto", "full"])).toBe( - "auto", - ); - expect(defaultPermissionMode(["accept-edits", "full"])).toBe("full"); - }); -}); - describe("savePresetDraft", () => { const draft = { name: "FB3", providerId: "claude-code", modelId: "claude-sonnet-5", reasoningLevel: "medium", + serviceTier: undefined, permissionMode: "accept-edits", environmentKind: "new-worktree", baseBranch: " main ", @@ -679,7 +672,10 @@ describe("savePresetDraft", () => { }); describe("PresetDialog environment section", () => { - function renderManagePresets(presets: unknown[]) { + function renderManagePresets( + presets: unknown[], + rpcOverrides: Record = {}, + ) { return renderSlot( app.navPanels[0]!, { subPath: "manage" }, @@ -691,22 +687,8 @@ describe("PresetDialog environment section", () => { sidebarSummary: () => ({ projects: [] }), listTasks: () => ({ tasks: [] }), listLabels: () => ({ labels: [] }), - listProviders: () => ({ - providers: [ - { - id: "claude-code", - name: "Claude Code", - permissionModes: ["accept-edits", "auto", "full"], - }, - ], - }), - listProviderModels: () => ({ - models: [ - { id: "claude-sonnet-5", name: "Sonnet", isDefault: true }, - ], - reasoningLevels: ["low", "medium", "high", "ultra"], - }), listMachines: () => ({ machines: MACHINES }), + ...rpcOverrides, }, }, ); @@ -727,7 +709,18 @@ describe("PresetDialog environment section", () => { expect(branch.placeholder).toBe("project default base — leave empty"); expect(slot.getByLabelText("Machine")).toBeDefined(); await waitFor(() => - expect(slot.getByLabelText("Reasoning").textContent).toContain("ultra"), + expect( + (slot.getByLabelText("Reasoning level") as HTMLInputElement).value, + ).toBe("ultra"), + ); + expect( + slot.getByTestId("bb-provider-model-picker").dataset.routingKind, + ).toBe("host"); + expect(slot.getByTestId("bb-provider-model-picker").dataset.routingId).toBe( + "mach_1", + ); + expect(slot.getByTestId("bb-permission-mode-picker").dataset.align).toBe( + "start", ); }); @@ -749,6 +742,47 @@ describe("PresetDialog environment section", () => { expect(slot.queryByLabelText("Base branch")).toBeNull(); expect(slot.queryByLabelText("Machine")).toBeNull(); }); + + it("saves the host picker's provider, model, reasoning, and tier together", async () => { + const updates: Array> = []; + const slot = renderManagePresets([presetRow()], { + updatePreset: (input: Record) => { + updates.push(input); + return { preset: { ...presetRow(), ...input } }; + }, + }); + fireEvent.mouseDown(await slot.findByRole("tab", { name: "Presets" })); + fireEvent.click( + await slot.findByRole("button", { + name: "Edit preset FB3 BE live worktree", + }), + ); + + fireEvent.change(await slot.findByLabelText("Provider ID"), { + target: { value: "codex" }, + }); + fireEvent.change(slot.getByLabelText("Model"), { + target: { value: "gpt-5.6-sol" }, + }); + fireEvent.change(slot.getByLabelText("Reasoning level"), { + target: { value: "high" }, + }); + fireEvent.change(slot.getByLabelText("Service tier"), { + target: { value: "fast" }, + }); + fireEvent.click( + slot.getByRole("button", { name: "Apply execution selection" }), + ); + fireEvent.click(slot.getByRole("button", { name: "Save preset" })); + + await waitFor(() => expect(updates).toHaveLength(1)); + expect(updates[0]).toMatchObject({ + providerId: "codex", + modelId: "gpt-5.6-sol", + reasoningLevel: "high", + serviceTier: "fast", + }); + }); }); describe("Manage folders", () => { diff --git a/plugins/tasks/views/manage/preset-dialog.tsx b/plugins/tasks/views/manage/preset-dialog.tsx index d4ed2d939b..89186573ff 100644 --- a/plugins/tasks/views/manage/preset-dialog.tsx +++ b/plugins/tasks/views/manage/preset-dialog.tsx @@ -1,8 +1,10 @@ -import { useEffect, useRef, useState } from "react"; -import type { - Preset, - PresetPermissionMode, -} from "../../shared/contract.js"; +import { useState } from "react"; +import { + experimental_PermissionModePicker as PermissionModePicker, + experimental_ProviderModelPicker as ProviderModelPicker, + type ExperimentalProviderModelPickerValue, +} from "@get-bb/plugin-sdk/app"; +import type { Preset, PresetPermissionMode } from "../../shared/contract.js"; import { PRESET_ENVIRONMENT_KINDS, PRESET_PERMISSION_MODES, @@ -29,17 +31,8 @@ import { Input } from "@bb/shared-ui/input"; import { Textarea } from "@bb/shared-ui/textarea"; import { Field } from "./shared.js"; -// Enum options mirror the contract's preset create/update inputs. -const REASONING_LEVELS = [ - "low", - "medium", - "high", - "xhigh", - "max", - "ultra", -] as const; export const PERMISSION_MODES = PRESET_PERMISSION_MODES; -type ReasoningLevel = (typeof REASONING_LEVELS)[number]; +type ReasoningLevel = ExperimentalProviderModelPickerValue["reasoningLevel"]; export type PermissionMode = PresetPermissionMode; type EnvironmentKind = (typeof PRESET_ENVIRONMENT_KINDS)[number]; @@ -77,25 +70,9 @@ export function describePresetEnvironment( return `Worktree · ${branch} · ${machine}`; } -/** Sentinel Select value for the free-text provider/model escape hatch. */ -const CUSTOM_VALUE = "__custom__"; /** Sentinel Select value for "Default machine" (Radix rejects empty values). */ const DEFAULT_MACHINE_VALUE = "__default-machine__"; -function isReasoningLevel(value: string): value is ReasoningLevel { - return (REASONING_LEVELS as readonly string[]).includes(value); -} - -function isPermissionMode(value: string): value is PermissionMode { - return (PERMISSION_MODES as readonly string[]).includes(value); -} - -export function defaultPermissionMode( - modes: readonly PermissionMode[], -): PermissionMode { - return modes.includes("auto") ? "auto" : "full"; -} - export function describeError(error: unknown): string { return error instanceof Error ? error.message : String(error); } @@ -105,6 +82,7 @@ export interface PresetDraft { providerId: string; modelId: string; reasoningLevel: ReasoningLevel; + serviceTier: ExperimentalProviderModelPickerValue["serviceTier"]; permissionMode: PermissionMode; environmentKind: EnvironmentKind; /** Empty means "project default base"; only sent for new-worktree. */ @@ -119,6 +97,7 @@ const EMPTY_PRESET_DRAFT: PresetDraft = { providerId: "", modelId: "", reasoningLevel: "medium", + serviceTier: undefined, permissionMode: "auto", environmentKind: "project-default", baseBranch: "", @@ -127,9 +106,6 @@ const EMPTY_PRESET_DRAFT: PresetDraft = { }; function presetDraft(preset: Preset): PresetDraft { - const reasoning = REASONING_LEVELS.find( - (level) => level === preset.reasoningLevel, - ); const permission = PERMISSION_MODES.find( (mode) => mode === preset.permissionMode, ); @@ -137,7 +113,8 @@ function presetDraft(preset: Preset): PresetDraft { name: preset.name, providerId: preset.providerId, modelId: preset.modelId, - reasoningLevel: reasoning ?? "medium", + reasoningLevel: preset.reasoningLevel, + serviceTier: preset.serviceTier ?? undefined, permissionMode: permission ?? "full", environmentKind: preset.environmentKind, baseBranch: preset.baseBranch ?? "", @@ -163,6 +140,7 @@ export async function savePresetDraft( providerId: draft.providerId.trim(), modelId: draft.modelId.trim(), reasoningLevel: draft.reasoningLevel, + serviceTier: draft.serviceTier ?? null, permissionMode: draft.permissionMode, environmentKind: draft.environmentKind, baseBranch: worktree && baseBranch !== "" ? baseBranch : null, @@ -191,116 +169,34 @@ export function PresetDialog({ const [draft, setDraft] = useState( editing ? presetDraft(editing) : EMPTY_PRESET_DRAFT, ); - const [providerCustom, setProviderCustom] = useState(false); - const [modelCustom, setModelCustom] = useState(false); const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); const set = (key: K, value: PresetDraft[K]) => setDraft((current) => ({ ...current, [key]: value })); - const providersQuery = useTasksQuery( - async (rpc) => (await rpc.call("listProviders", {})).providers, - [], - ); - const providers = providersQuery.data; const machinesQuery = useTasksQuery( async (rpc) => (await rpc.call("listMachines", {})).machines, [], ); const machines = machinesQuery.data; - // The dialog opens before the provider list arrives; once it lands, an - // edited preset whose provider isn't offered flips to the custom input - // (keeping its value), and a fresh draft preselects the first provider. - const providerResolvedRef = useRef(false); - useEffect(() => { - if (!providers || providerResolvedRef.current) return; - providerResolvedRef.current = true; - const known = providers.some( - (provider) => provider.id === draft.providerId, - ); - if (draft.providerId === "") { - const first = providers[0]; - if (first) set("providerId", first.id); - else setProviderCustom(true); - } else if (!known) { - setProviderCustom(true); - setModelCustom(true); - } - }, [providers]); - - const providerForModels = - !providerCustom && draft.providerId !== "" ? draft.providerId : null; - const modelsQuery = useTasksQuery( - async (rpc) => - providerForModels === null - ? null - : await rpc.call("listProviderModels", { - providerId: providerForModels, - }), - [], - [providerForModels], - ); - const models = modelsQuery.data?.models; - const providerPermissionModes = - !providerCustom && draft.providerId !== "" - ? (providers - ?.find((provider) => provider.id === draft.providerId) - ?.permissionModes.filter(isPermissionMode) ?? []) - : []; - const permissionOptions: readonly PermissionMode[] = providerCustom - ? PERMISSION_MODES - : providerPermissionModes; - const serverLevels = (modelsQuery.data?.reasoningLevels ?? []).filter( - isReasoningLevel, - ); - const reasoningOptions: readonly ReasoningLevel[] = - !modelCustom && !providerCustom && serverLevels.length > 0 - ? serverLevels - : REASONING_LEVELS; - - // Cascade: when the models for the selected provider land and the current - // model isn't one of them, either respect an edited preset's custom model - // (first load) or preselect the provider's default. - const modelsResolvedOnceRef = useRef(false); - useEffect(() => { - if (!models || modelCustom) return; - if (models.some((model) => model.id === draft.modelId)) { - modelsResolvedOnceRef.current = true; - return; - } - if (editing && draft.modelId !== "" && !modelsResolvedOnceRef.current) { - modelsResolvedOnceRef.current = true; - setModelCustom(true); - return; - } - modelsResolvedOnceRef.current = true; - const fallback = models.find((model) => model.isDefault) ?? models[0]; - set("modelId", fallback ? fallback.id : ""); - }, [models, modelCustom]); - - // Keep the reasoning level inside what the provider actually offers. - useEffect(() => { - if (!reasoningOptions.includes(draft.reasoningLevel)) { - set( - "reasoningLevel", - reasoningOptions.includes("medium") ? "medium" : reasoningOptions[0]!, - ); - } - }, [reasoningOptions.join(","), draft.reasoningLevel]); - - useEffect(() => { - if (permissionOptions.length === 0) return; - if (!permissionOptions.includes(draft.permissionMode)) { - set("permissionMode", defaultPermissionMode(permissionOptions)); - } - }, [permissionOptions.join(","), draft.permissionMode]); - const canSubmit = draft.name.trim() !== "" && draft.providerId.trim() !== "" && draft.modelId.trim() !== "" && !submitting; + const pickerValue: ExperimentalProviderModelPickerValue = { + providerId: draft.providerId, + model: draft.modelId, + reasoningLevel: draft.reasoningLevel, + ...(draft.serviceTier === undefined + ? {} + : { serviceTier: draft.serviceTier }), + }; + const pickerRouting = + draft.environmentKind === "new-worktree" && draft.machineId.trim() !== "" + ? ({ kind: "host", hostId: draft.machineId.trim() } as const) + : undefined; return ( @@ -322,159 +218,36 @@ export function PresetDialog({ className="h-8" /> + + + setDraft((current) => ({ + ...current, + providerId: value.providerId, + modelId: value.model, + reasoningLevel: value.reasoningLevel, + serviceTier: value.serviceTier, + })) + } + {...(pickerRouting === undefined + ? {} + : { routing: pickerRouting })} + className="h-8 max-w-full" + /> +
- - - {providerCustom ? ( - set("providerId", event.target.value)} - className="h-8" - /> - ) : null} - - - {providerCustom || modelCustom ? ( - <> - {!providerCustom ? ( - - ) : null} - set("modelId", event.target.value)} - className="h-8" - /> - - ) : ( - - )} - -
-
- - - - + onChange={(value) => set("permissionMode", value)} + {...(pickerRouting === undefined + ? {} + : { routing: pickerRouting })} + align="start" + className="h-8 max-w-full" + />
diff --git a/scripts/provider-literal-baseline.json b/scripts/provider-literal-baseline.json index 46ed46dafb..1e56a733c5 100644 --- a/scripts/provider-literal-baseline.json +++ b/scripts/provider-literal-baseline.json @@ -1,6 +1,6 @@ { "_comment": "Provider-literal ratchet (G1). Per-file occurrence count of provider-ID references in core. May only go DOWN. Regenerate: node scripts/check-provider-literal-ratchet.mjs --write. Delete this file and the guard when empty.", - "total": 147, + "total": 139, "files": { "apps/app/src/components/tools/SkillsCollection.tsx": 3, "apps/app/src/lib/provider-icon.ts": 15, @@ -38,8 +38,6 @@ "packages/test-helpers/src/provider-models.ts": 1, "packages/thread-view/src/active-prompt-mode-extraction.ts": 2, "packages/thread-view/src/model-fallback-extraction.ts": 1, - "plugins/automations/lib/model-label.ts": 5, - "plugins/automations/lib/provider-icon.tsx": 3, "plugins/tasks/cli/index.ts": 2, "plugins/tasks/views/activity/provider-logo.tsx": 6 } From 411c777149de6d0ad6de00bbac77867414f892a5 Mon Sep 17 00:00:00 2001 From: brsbl Date: Fri, 21 Aug 2026 16:19:36 -0700 Subject: [PATCH 147/232] Add dark mode to the marketing site (#2246) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds dark mode to the marketing site (`/`, `/blog`, `/blog/:slug`, `/changelog`, `/privacy`). - **Dark token block in `landing.css`** mirroring the app theme's dark anchors (canvas `oklch(0.195 0 0)`, ink `oklch(0.81 0 0)`) and its dark chromatic tokens. Every surface, border, and state fill in the marketing styles already derives from the `--bg`/`--ink` anchors, so the ramp re-resolves coherently; mix percentages step up the same way the app's dark ramp does. - **Remaining hardcoded light values tokenized** (`--heading`, `--heading-sub`, `--ink-strong`, and a `--shade` base for shadows/scrims, pinned to black in dark so shadows don't become glows). `blog.css` and `changelog.css` pick the tokens up unchanged. - **Nav theme control with the app's theme model**: a preference of `light | dark | system` (default `system`) stored under the same `bb.theme` key the app and dashboard read. The button shows the *preference* (sun / moon / monitor) and opens a Light / Dark / System menu (`menuitemradio`, Escape/outside-click dismiss). A `prefers-color-scheme` listener re-applies the theme while the preference is `system`, so the page flips live when the OS does; a `storage` listener picks up a choice made in another tab or in the app. The pre-paint script stamps `data-theme-preference` on `` (alongside the `dark` class) so the glyph is right from first paint; all three glyphs render and CSS picks one, so SSR output is preference-independent and hydration can't mismatch. The menu only exists while open, so its checked state is read from storage at open time. - **Logo swap in dark**: new `bb-icon-dark.png` (the brand's white glyph from `assets/bb-logo-white.png`, scaled so its glyph optically matches the light tile's), used in the site nav and the connect dashboard brand row. - **Platform-native details**: the Telegram band mock follows Telegram's own dark appearance; grayscale company logos invert so near-black marks stay legible; `theme-color` meta follows the resolved theme (the pre-paint script retints it because the router dedupes metas by name, so two media-scoped metas can't coexist). ## Screenshots **Before** (merge base `c942421a4`) / **After** (PR head): same route, viewport, and emulated `prefers-color-scheme`, captured from local dev servers at each revision. ### Dark OS preference, desktop 1440px — before the site ignored it and stayed light | Before | After | | --- | --- | | ![Before: dark preference ignored, page stays light](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/before-dark-preference-desktop.png) | ![After: full dark theme with nav toggle](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/after-dark-preference-desktop.png) | ### Light OS preference, desktop 1440px — unchanged apart from the new nav toggle | Before | After | | --- | --- | | ![Before: light](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/before-light-desktop.png) | ![After: light, with theme toggle in nav](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/after-light-desktop.png) | ### Dark OS preference, mobile 390px | Before | After | | --- | --- | | ![Before: mobile stays light](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/before-dark-preference-mobile.png) | ![After: mobile dark](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/after-dark-preference-mobile.png) | ### After only — the new theme menu (no before counterpart; the control is new) ![After: Light / Dark / System menu open, System checked](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/after-theme-menu-dark.png) ## Full-page screenshots of every marketing page (PR head) Each page at three widths — narrow/PWA (390px), laptop (1440px), large external display (2560px) — in light and dark, captured full-page from the PR head after scrolling through so scroll-reveal sections and the self-assembling hero mock have rendered. The changelog is 23–34k px tall, beyond what Chrome can capture in one image, so it is shown in labeled vertical segments (DPR 1) that together cover the whole page.
Home — narrow 390px | Light | Dark | | --- | --- | | ![Home, narrow, light](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-home-narrow-light.png) | ![Home, narrow, dark](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-home-narrow-dark.png) |
Home — laptop 1440px | Light | Dark | | --- | --- | | ![Home, laptop, light](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-home-laptop-light.png) | ![Home, laptop, dark](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-home-laptop-dark.png) |
Home — large display 2560px | Light | Dark | | --- | --- | | ![Home, large, light](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-home-large-light.png) | ![Home, large, dark](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-home-large-dark.png) |
Blog index — narrow 390px | Light | Dark | | --- | --- | | ![Blog, narrow, light](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-blog-narrow-light.png) | ![Blog, narrow, dark](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-blog-narrow-dark.png) |
Blog index — laptop 1440px | Light | Dark | | --- | --- | | ![Blog, laptop, light](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-blog-laptop-light.png) | ![Blog, laptop, dark](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-blog-laptop-dark.png) |
Blog index — large display 2560px | Light | Dark | | --- | --- | | ![Blog, large, light](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-blog-large-light.png) | ![Blog, large, dark](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-blog-large-dark.png) |
Blog post — narrow 390px | Light | Dark | | --- | --- | | ![Post, narrow, light](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-post-narrow-light.png) | ![Post, narrow, dark](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-post-narrow-dark.png) |
Blog post — laptop 1440px | Light | Dark | | --- | --- | | ![Post, laptop, light](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-post-laptop-light.png) | ![Post, laptop, dark](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-post-laptop-dark.png) |
Blog post — large display 2560px | Light | Dark | | --- | --- | | ![Post, large, light](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-post-large-light.png) | ![Post, large, dark](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-post-large-dark.png) |
Changelog — narrow 390px (6 vertical segments) | Segment | Light | Dark | | --- | --- | --- | | Part 1 (0–6,000px) | ![Changelog, narrow, light, part 1](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-changelog-narrow-light-part1.png) | ![Changelog, narrow, dark, part 1](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-changelog-narrow-dark-part1.png) | | Part 2 (6,000–12,000px) | ![Changelog, narrow, light, part 2](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-changelog-narrow-light-part2.png) | ![Changelog, narrow, dark, part 2](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-changelog-narrow-dark-part2.png) | | Part 3 (12,000–18,000px) | ![Changelog, narrow, light, part 3](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-changelog-narrow-light-part3.png) | ![Changelog, narrow, dark, part 3](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-changelog-narrow-dark-part3.png) | | Part 4 (18,000–24,000px) | ![Changelog, narrow, light, part 4](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-changelog-narrow-light-part4.png) | ![Changelog, narrow, dark, part 4](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-changelog-narrow-dark-part4.png) | | Part 5 (24,000–30,000px) | ![Changelog, narrow, light, part 5](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-changelog-narrow-light-part5.png) | ![Changelog, narrow, dark, part 5](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-changelog-narrow-dark-part5.png) | | Part 6 (30,000–34,120px) | ![Changelog, narrow, light, part 6](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-changelog-narrow-light-part6.png) | ![Changelog, narrow, dark, part 6](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-changelog-narrow-dark-part6.png) |
Changelog — laptop 1440px (3 vertical segments) | Segment | Light | Dark | | --- | --- | --- | | Part 1 (0–8,000px) | ![Changelog, laptop, light, part 1](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-changelog-laptop-light-part1.png) | ![Changelog, laptop, dark, part 1](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-changelog-laptop-dark-part1.png) | | Part 2 (8,000–16,000px) | ![Changelog, laptop, light, part 2](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-changelog-laptop-light-part2.png) | ![Changelog, laptop, dark, part 2](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-changelog-laptop-dark-part2.png) | | Part 3 (16,000–23,352px) | ![Changelog, laptop, light, part 3](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-changelog-laptop-light-part3.png) | ![Changelog, laptop, dark, part 3](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-changelog-laptop-dark-part3.png) |
Changelog — large display 2560px (2 vertical segments) | Segment | Light | Dark | | --- | --- | --- | | Part 1 (0–12,000px) | ![Changelog, large, light, part 1](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-changelog-large-light-part1.png) | ![Changelog, large, dark, part 1](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-changelog-large-dark-part1.png) | | Part 2 (12,000–23,352px) | ![Changelog, large, light, part 2](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-changelog-large-light-part2.png) | ![Changelog, large, dark, part 2](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-changelog-large-dark-part2.png) |
Privacy — narrow 390px | Light | Dark | | --- | --- | | ![Privacy, narrow, light](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-privacy-narrow-light.png) | ![Privacy, narrow, dark](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-privacy-narrow-dark.png) |
Privacy — laptop 1440px | Light | Dark | | --- | --- | | ![Privacy, laptop, light](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-privacy-laptop-light.png) | ![Privacy, laptop, dark](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-privacy-laptop-dark.png) |
Privacy — large display 2560px | Light | Dark | | --- | --- | | ![Privacy, large, light](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-privacy-large-light.png) | ![Privacy, large, dark](https://raw.githubusercontent.com/get-bb/bb/a09929da642a39973e42b00ee6c92d1e2b8e9e5c/evidence/web-dark-mode/site-privacy-large-dark.png) |
## Validation - `pnpm typecheck` and the full `apps/web` vitest suite (13 files, 79 tests) pass. - Screenshot sweep of every marketing route in both schemes at desktop and mobile widths, plus close-ups (changelog media cards, tweet embeds, Telegram band settled state, open menu in light/dark/mobile). - Theme control exercised end-to-end over CDP: a fresh visitor defaults to System (checked in the menu) and the page flips live when the emulated OS scheme changes; choosing Light/Dark persists across reloads and ignores OS flips; choosing System returns to following the OS; Escape closes the menu and returns focus to the button, outside click closes it. A dark-OS visitor gets `.dark` pre-paint with no light flash, and the `theme-color` meta resolves to the current canvas. ## Review follow-up (026768d) A code review of the first three commits found eight issues; this commit fixes them. **Dark mode rendered wrong in three places.** `.dark .company-proof-company img { filter: grayscale(1) invert(1) }` assumed every company mark was a glyph on transparent, but Blackstone, Moody's, Notion, Owner.com and Shortcut bake their own tile, so the invert flipped the tile rather than the glyph — Blackstone and Moody's came out as bright squares, Owner.com as a dark blob. The marks are now flagged in `COMPANY_PROOF` and tiles are excluded from the invert. The white app-icon tile also still rendered in the Telegram card and spawnbar mocks, which is the glare the nav and dashboard swaps were added to avoid. **`THEME_INIT` gave up when storage access threw.** It read `localStorage` first inside one `try`, so on Safari's "block all cookies" or in a sandboxed frame the script aborted before setting the dark class, and nothing re-applied the theme on mount: the page stayed light while the nav control reported "System". The storage read now has its own `try`, and the mount effect reconciles the document. **The `theme-color` retint appended a duplicate meta.** React 19 hydrates a hoistable `` by matching its `content` attribute (`react-dom-client`, `getHydratableHoistableCache("meta", "content")`), so rewriting content to `#151515` on `DOMContentLoaded` made hydration miss and append a second `#ffffff` meta. `` now ships one meta per scheme — browser chrome follows the OS with no JS — and an explicit preference narrows them by `media`, which React does not compare. The five per-route `theme-color` entries are gone. **Smaller fixes.** The storage and scheme listeners updated the document but not React state, so an open menu kept a checkmark the page no longer agreed with. Both logo PNGs downloaded on every page, because `display:none` does not stop an `` fetch; one `.bb-mark` element per site now picks its asset in CSS, covering the nav, dashboard and both mocks. The Telegram surface moved to a `--tg-card` token with both scheme values, replacing a raw `oklch(0.28 0 0)` in a component rule. **One note on the shared script.** `lib/theme.ts` owns the preference model and `lib/theme-init.js` holds the pre-paint script, imported with `?raw` so the server and client embed identical text. Deriving it from a compiled function's `toString()` does **not** work: esbuild's SSR and client transforms re-print it with different stray semicolons, which React reports as a hydration mismatch on every page load. That was caught in the browser before it shipped. ### Verified - New `apps/web/src/lib/theme.test.ts` runs the shipped `THEME_INIT` string against stub globals. **Four of its six tests fail against the previous script** and all six pass now, covering the storage-throw path and the content-is-never-edited rule. - End to end against the dev server and the production build: no hydration errors, exactly two `theme-color` metas on all five routes, one icon request per load instead of two, dark honoured with storage access throwing, and an open menu tracking a real cross-tab write. - `typecheck`, `lint`, `test` (79) and `build` pass for `@bb/web`. Also reformats `__root.tsx`, which prettier already failed on before this change. BB-Thread-ID: thr_49n689amph 🤖 Generated with [Claude Code](https://claude.com/claude-code) > AGENT GENERATED --------- Co-authored-by: Claude Fable 5 Co-authored-by: Sawyer Hood --- apps/web/src/assets/bb-icon-dark.png | Bin 0 -> 15134 bytes apps/web/src/blog/blog.css | 14 +- apps/web/src/landing/changelog.css | 14 +- apps/web/src/landing/landing.css | 262 +++++++++++++++++++++++++-- apps/web/src/landing/site-chrome.tsx | 150 ++++++++++++++- apps/web/src/lib/theme-init.js | 58 ++++++ apps/web/src/lib/theme.test.ts | 157 ++++++++++++++++ apps/web/src/lib/theme.ts | 66 +++++++ apps/web/src/routes/__root.tsx | 38 +++- apps/web/src/routes/blog.tsx | 1 - apps/web/src/routes/blog_.$slug.tsx | 1 - apps/web/src/routes/changelog.tsx | 1 - apps/web/src/routes/dashboard.tsx | 9 +- apps/web/src/routes/index.tsx | 41 +++-- apps/web/src/routes/privacy.tsx | 1 - apps/web/src/styles.css | 13 ++ 16 files changed, 762 insertions(+), 64 deletions(-) create mode 100644 apps/web/src/assets/bb-icon-dark.png create mode 100644 apps/web/src/lib/theme-init.js create mode 100644 apps/web/src/lib/theme.test.ts create mode 100644 apps/web/src/lib/theme.ts diff --git a/apps/web/src/assets/bb-icon-dark.png b/apps/web/src/assets/bb-icon-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..6ae2e0382859995bbf7edfd32eb4970a44fd36da GIT binary patch literal 15134 zcmaKTWmp`+((d9O9D>{87Tn#Pz~b&6+*u%4aCe8`1b2cwE7FT({cYR#&uTm<9+aomoO zsi)z%XW)9K<2}E+X!ox+7}wUM23k}&Cl*;Q#P2evZ$l`dI+#)nL-aGLFzY(<+jm1& zEyEC0X!tY;NbaEHFVGmt)v5ixR9%v_W;%^Pxx;Tam#t5XcQZamZS!_oeKn@;TwYaY zH>GtmrQV8JWd>Q^r{MTZY2O_j&3>FgC_`*MA%j+FVgL`KdIQWQKy&tV95)9}IQqMq z-FG!YK>6a?!zSOmVy;POn`YLBh3lh$f$IRIC%^?c4!3;zz41%2bFchVlN8lInoM0S zj@fPUUC5A5UiC{7X&a&%zdgIk^M8q8kuqEU@B_x`rC0UUK3u4^@EIJZy|>ooBeqY} z%)avj)RTT+^=DhXxBgE}Qv#y8>^YtJmVq%Au1gD_4);jm-P=Tl@2G}r@`o)0N>ap4 z(R)z)3Z1*h)-}2haP`8(094ELHWQ8y*t6?9K2fNa?}HOhuzLJL|^yt+31K74NRN(S?3H^aO0KrNcHGpi5h)2E}j zcZMI%yBQMi?&@K@xB*^^_79uMgC8l0BfBzc{m7V0@tRXb%Kwm!mm4{mjV->bW;T>M zb?blNZQq~uQLwQ4NWmhg#gJM19l8Fsbw^QM<9n}#U72n*0fR#&?=FNxCC>1U_uqHx z>qNe*{><|CByej)>|6?%eODs^m?w{oDja8K3%=G})lutfJ&$z*XI1mi^m7+WPde$*~k=q%7>@}W|DpsYEE$HwEBL`NrLuQ6g-lJ;%_UiQe= zTv)KN-RV<4%sS&}xXW$zOVQF&TLykd>VWYyOQAJ z-l)fWDM_gxt4paQ-u=(lVI*1UytTytu+*-u(HN@9$9&+u7#|ov#Y>v(rV)C+3hWcd ze(DKKWlamzi_3$4?Pk|hMj}R*hD~5VBG4h@-`$@)tCH~5!t_csH~+&-@R_EAee%+v zuzwpYL&uDbF#csYyDa`CQ}Y&9rdKM$YD2-<*hJx7S1+_S|M! zJ1R~Y){NT;@cs>P-Z!FU3WA5JL2DCAHg2p)$7yQqbDG@f{ZW47buyGBdl4c^*C|nb z>1$j{$FWgrN<+dgtnf9CLf3<7ppI*F?6eK>MIe>3b8Ho|oE$*-+5Zi}{E_DHqB$2` zitx53i5E{xzH3z;(zLs)O`73=4Jl9|iL7im{`PFm0G~+js(`)WsQ;KctsoLMpe$Q_ zP){Qam&0Y-BRMpfI#_*6XvO=i9b7{{{{Ae#kAEM@Q8a zoKQKo$udC1gUn@Hf%VLigYS;P&d9vg`)({Eb;kW=;0tXBi`9|kE0!#9D+5#w{2cV^ ziwcDPt_?q4g0)NXyI%X|)$()89qzxiH6g@%W>^r6NsBek_#^su{YD_)+4o;4UU10? z?f`BCoRSdj>00eJvx7SJ*=>!_IA~2VrsRa)^2+Xcc4HWwx=n^fWT|&Qb8gTXgm%C@ zS?w_D5&(yS&m0L^-;%`vad?_xg@O3w(8ieO1VOnHT^UdjzL-dgM!FoC1>QrdCg$d- z%5N}gS4!ED<32bbQNVhQ%&r&jTGGi(FlTEpgKD^xWF9}C4brc00DNA9mfBWTi|xxK zx3Dzfb`3b>E3N*vmOkDT35&5%Kex`xN_l#tN!p9|kh}4Y54a5KFIz4U0 zLy?`g`o8c&ITv`exBW;XwC$NT{ZBi04+;=_O&O+NLf3n!&H{(63}ty-;Si~y-Uk7b zyqKFGBWvpQC5S;9p4G@0?k1cbf4RP3==ibk*MmH|xuK~`G@sw{BTC5C=o2Y3S=ctu z6wTf!S}jkCzQVP`{NfeF7cQB0Ua?nM}9lvF)8X96R1uV_mNCWNB_5mA^ns{VH5<(P+Gc2)z7mmpw@ z{nrkH11*hPqerEt2be>miQD4f3$ z4LpydFbe38V=!rN8e()|dBDMEW`qLLuc0#( z#XDQP?@oF1<*}HWA4cyVx6Y;1RJIA80e?&ord>IZ_UEbkVz?NVTMPbH z1a!x*>TkIS^&iG?=js6Cd7s&){-RHx031bc&k@vyhL+XANRcv5=5UNF< zUs(8TP;$Ka88Fh{cA^!ktz|&=A21oT^&giiI}+!V!nbIsM3)C6^5gTc!~f z+vF*f({Ex}@6)yk75aUplbFJ_=tUuI{?Kk>?2)$3) z(&z`@NMd1ErN{DYAS_A4m^NXuhb64T51QO`J$FhMv^OK8URiKNhp0ijw;PkN5`bo9 zF}ykQF*=vF;eF#A^$!xkn60yu@PX>i6NhM!D8>X>jO;AiHGkbRqDsR+7}^ zP3_Lbh2!G#k$0g~vINm90=xax$5d+jaOQixXhou;`F~skx@LLbWbVF$mz>Cxf7dQ_C%&*uC~dBqA0CF!`Ll_+8X^ z`$QH}xwPV(SAK;U;m@2PD^^+doJJLG* z2at|G_fXTCe~rf=fkfhlWL<}Q&q{Y8-EMC2CcnuO$Kmo3b4pIz>*LXjtI?2%GR+_d zxe?7+F!2RK|8mdrSe>GPGX5ja{iN=%X%cJ|B5Lc7I=iKqkpZ{ttb`Bzh{4x*Az(Ob)VWJ`W%xfP(Yy0<<*gAj|AG-sDB( zC^b%XcRb2*nNW0kdg6u zGdDLyb8~A}4h}}l;v!og>qBOA=e~oPnUy1R^JoDx^TP!j{flseSK)N5*G63$A9&l2 zI`kfrGNAnos1i5sXms1Xwy2$FxMwLow!MiZ1MD&%edBjCn<41jYa#T|S@qPn_BP63sWCsQN_&931JtiaF0*(B-vSluU8Fe zTW9oCh0K=lBwvR0?X-?80GnG2%~i^yb_w8`xoYDurIP|`|AA)c*xI9O;+{9pUR z9Q}ou5>!=H4$22trG#X&&-(_3k{L4|6?*)&l!xyf5x)=!N0!3{ie@#k!mx#^C>gd0 zEZK$eQ)<@#v$m?61o<_kW@MyT7t%pN`9N>N{+Tp{PQX*6u2#z8r%DCxWd!{)yHW35 zQ!1FWU0tzk{Pyp+yS*y!e6i5WK>!iooIPiYo9*$b9VX_TE*ppI@mpGU+a9HJ1Lm8j zuiLY`g~h-cD<9i!^5gOXfw_fJo2{4EaqG}fLJS^>P;GBf(HxPGZzhM%B3|ttlDpn_+Ax>e-{1_yFmS!H^eVonTYK;>Jtez7)0~f|C5Qg7sBcwl5UP6k z@olO?AyN9kfDNEWFRBY4Jgq~C{KFrf?$G0~(L#J{~koOwHMG6E!nJ#WVK~j)A z|N2kpkxf#LLwe@ z9iay8gr|`rPp!MH^y}qWRfsR#-1pMhIu;o9v(^X$C|NN3Y!@)|-E6d5n8o4=;*Dnu zR2q8V!sF!@6y?n~iFwulM*r+7`KYXATahk$9Cu5nby;GqPmEOnie@rsD~WRpyK}CL z3mFhoirHv72eP>|Pe0w3BT0fsm-xCf=<+V{-~cZGXGKXN@2PAo`qHV; zN6h*X&V1uvcTfNRkmzSN=tQfdo_(O&XMItmAs)JuPi9Wks(-oxuS??1l9=8}FrMCZ zc=`fDeupxDF040B^=W_80Em~j3*!Yir9WFY)_RzxW@XLdLww;u@c2V*8j!LQ7l+>B zGEQ0hG!;>xU9o;Y{l|3#AR3GJAi1Jthu|+kdAY7C0jnA4sgy|gx@eCeVey&&kuC|3NossBKnw`Fh3v+p&dZ65WQ~77sl?5R@BN0~ znOSszQo#Y$X{^2!iK3f*@LQ;brPUAn*ZN=_1z09tACvXy?q|r^t8->raj^{{o}z@Z z-AW_&`C0PK(fp>>x1Rp&rgkex%{GJ--Uo6Dr=e5K++j0d^mNBs9)s!mKTa|D_zUPk z4~zzQX~E#BQdWLehJNy9*7R(ex{)CFAoy1v{i&DcU9TMD`MqX}mlw4b(>tb?^?;Ms zYRkPYUNJo49sr>Iqe{;&* z?2Hz@=>KE^mtGIfvO#>|*KKF7uSMb(#MO_EdKkJZY>bMs@*b1Is z!GE#H&JZJ7_sW8t@&7gj*-0Uu5(;~w{CiAGj#Q6S5pDTk9o4@R+x;~%tF!-fHIZGx ztoIRxT<~XOggU*$FROb;#?wnx-))|LRLA3Iyt}hiaGtK$K|kbqA&AZ~fnBP8;Er*~ zA_GU2xEc+nLM#!%0<)m{wi*41!H`GnWIS_q1D~HckwMqJ2O@Fq31}s@B1=;EnUSiz z8CNW_=Cg;p{}gRq48N$FdG)j%PJcT%SMd|t4u%5X|nE~86>*pO z6@ok$!851}sh!H7@BU(7Vu;lb18|MA)3uIv22eww*%0{(_w4MmagnkjBWU-yr#0_+ z)cCdK@~_n3#00>r7`gpR=Y0DR(C)-)S*V}5uixD+#RzY6hdj(~;Z}DQ4C= zsn6SBk)9`Z1;NU&QzcL|M65PE9(As~w$`w=7S8idV-id>3qf%1I`>RGuh8FyZi6lQ z@dEC99ex(#_ex~8oSRHK_d1#`@SWQdO1L*PQVCgRxq@<{f;yp>Twe426wzi?GmGUwg*5HsSvi;K* zZM!`zA*T)GQZ9nW)Pduq$*)58TcGXHZuHVE`8~WF&heSJ;1a0B%G7l;()H?aUwu`u zjodZvQQl2Z+znrL+c%f_x;gvSeNO@|z2`Pp0w$l`h0?j0Ms2q&>BmOWM-Jb>)Nc&| z4GS+Bqp-c{c3YIoORj!WX&Kml|8BDUG7Sn5=vp=}RLBHmkWqyG4?{JN%cDKg7&Z<(?puyN?E>iQS{vMH;{_}`mcoqNa0E_Gn zhE%p((0Jy4FkR$HI(K)jgp;mnXzm@@-xYn3PYZ4W>k%ss-ZokUL#EIE>e~ep-Ud_I zHa?b-0)}cZW&-)?#CiPh&x^nblS#>C;MY0GlWN+A-El6a6<1nFy%e7$z2fTwL=yh{ zn}#~)1_4Y=%s>u7veU%|r1Pp6^5*hD63n%I^16f#)$djTK@xi^P}p>=a91RS%J*3{ zs-p&Ygv2isU0p@q?RU2Otw)u^OdZE%FJyKvZ=>p0ogDa4H4l&c%yl}Hn$_>?r3)l% z3*Wb`&o4fkZJU=XtlSH5smS4oEZEwa$pgt2MLm8sIKN+#*M)6f+kiEm%Flfp^Lq@+p>TDL+E|CDlG-Hi-pyd;d2O`Ibb2^LbG+1m@ z-iiTANdX2Md^O+&>HN#SKO+-NAqW2n(1HfZ!_}$Qt^0c9nqscXfSJ$zTZ#$`w#27B zZgx^aseH($1j1){2O;E?BI)Z`s$hcMT>`GiNZW^yvc`46U-kBDu53|r&weDt_>WH; z8%7swLfv$jmu%wLUIHB{?=Dis61uTOYqYWC-^LK^5UziqzSUBCL4 z#|cm)^`~eY1`~uQ-S|z-Hy!+Z=_L>n{8D|2ne>X{r*o?F)NK#Gn^+3mKLW)Ya87d7 z?kqIyeT_y^7vqX3TU8hgvPPZw{W_Y|rgOJ`Dw3FQJ z7aY33s@bg#!lQBtyge`rT-;OhznWD1GUG4&SZ^kC11cH90GYFZTG_zfUm5#E$GI8*Hf6uxH5KB`<{pSJpZ=r1NTb zuNrt>0>v@>SbfMgwgl@*QKDB^M|Rwt1lnX*c-Ldv&&9sH<%u%{p**8`j=nF(DUbqP z#L`>><53H?Mn;BQ{SV4UbtGL!2jPtm2ZQsQ&e1=2$<$7W_q0V(^k3wWmkqUd$G>U+ zB7E^b46)J*v9a_x;qY0Tr}a&1WLSXVYg%Y+=p{cy%q=>%VQ|&n)L2i>C*1sHn;53NtSeF&tDWEGz;sQly)q_14!=Y1SRayy*~5-Xu|8z9@} z9_fRIwR5^lsAF`S*h0Clt3X>_TDMJUy?SSM4KqPC!K-_$f|b1wmjr6Q#JGeSvbJ;>RTn@a+zQ`?<;pUdA*%8i#bML9 z`~bBn*Y_EJaor$c@eRWbgod)5IFR zeV9*P0~SCfgB0Aud_LqwFO7TRKiKACAu&dU_&_hv3?*M7&&Z4Hv?$?IEz@yHliFU$5uZc8Y zdBV|i}|RnY2^&}N$D|HPUlP##1{d9_6YaY9M?#O zR2*tFI0uJldu`D$q=Q>M+CSgx0Q#3F1+mX~M5s@ELMG4UG7S&nF8iIwwJY&d7OD6S zYwm1bKl6(@(W$TNB+1~5fRk%)uXiyF-d%|E$H&*BsnNJ6oL$}QZYD2zc3l6h1z?vn zE87PORA2SpW#te=8anz`D=X|BJ@5eQ|OvMroFm;Dl;I zT}kT6b3A-$da3a1l(POeEpX@aN>iuUGI=Vs`Dh{!h>?Jqj)+U+AqlWoPBt4l)gf zs^!?kgQ18<0^`#{ zG^)l33{Gg{5iN$^1|#~}bU|VKGB+FBsUJh{Sz=P_2r8HV;sVQv+&Lj{;ni~X>h07B z4Nj<9iDLEcuI$01!(N!WUvuEE?U#iNW+BoHfWy{6i&1)=Ln`-LnsC^HF!WvtqP{is zaL4UzmG54GJ2RJ_%TT4%CzM-MIf4cGAEK2%R3uoiE)lnpZ_fSuKx!BYBjWaBwbvwE zwhL^G^d3a~_l_Ow`{~VfKVjw){Pm(pzu2q(;k*H8krwyr{t3uG^k~o?gaU{$c&4YO zewI;vVA-&>NBU{v;)hlOGXd2-z`PEIFh0dhjbF}6rgMK_&RNw~jtdb{R{c^( z+-}k_`#hD*sFVDn7i@KwB3pQ=21u7e>pJr%*8Pcj@@XPa2*#L*I2wKis*OPtrJEw> z<=Em1(43=|Qu39Fdj5Qn(a5c9G`I0w4JI0bK?zNOYy{ATshOLWTFQa|yb}0tD=Q5d8h8w?$Sb)PED;HbA52*5 z8JSD;o}(|LA7_hX=avvFL0x4f*D_jgPZj+cu&}A|OJjAJHm<4Xl3tJsQrClt3nG^U z)G^UX>k*yh>qFg3N?<0nJa}EL+x<9w_`}uV7+Hy}U}6Fn0=S65$9JwRmPN_^-Hzff zb=f!#wu|ub5%nB?wmmXxH>ubUNc&|2*)^gfTuWqlk$@r0>gGon95J|2o0|Ho7vfnz zg5U7ZOO&srN5_oM73eul-YPu(ym3gTkCY^t(mX-stl z)4;g)^~d70{7AYI=qVJ)bw~(jkeoP2o{U^@L}W(LO6TO@cH`1%>gaC9Yh}QcOO-O~ zlS|&LwVnkT0ir*VNqjZc4H|?!26Fz%ZFHj~xd&<2PcL?xMR!hJ--lX3vy??b5987w zy4e@Ck<4`Q*e~{a^-<$2m_PhkskU=5H#XB6v>Z&h7?{-%0|_|q4LS-<@TQiRRU3=q z+W_2WSYMQA|N4;+;nkJG`&0L;gyK_UCYcN<4qj{x5P+d(@w7H}L zS#WJ)Z?&|G73=1*zn&YPeJaAqi92sYIw^dwPz+vBQhMY7t~YB{w|5cDS^T*s08{>Md}{S zrDi21>aPNPe>-hD6C)%Jsz9G5)sLNC#NuDz8t?URo)-cikZs(ANwD*xQG#C=!bnOs z0|99qn3ML);JZG%uZP=ZHVE7jdM2X&dg{+tc12Yd%IL6+A)`qzsDny<_LiFdym>1W z>dMH1!Tk#jka)b*Yzea#*5*n^t+nF4Mw7y%)x*D&cLxaX3rgJm?R~$Rze?o>5|L&K z4cIe8_yq;jw325@L7Z1DMKE(;mEjv06Y)b@K7V>%?|-4aE#;m7!u|+NwY8|3kB*5! zozkuav%9P1r4jvr92`P zx`5QzS=q8uxqYtfAVQqSBWbZQZ1(OGiMeVk)+03+QPax5D+RV$ye?U*F%5Ko*)h_K z%rh~OVou5LNGKlJL5!6cj8y*xpCaL;TzWSd-mbp~tCl4BlhUB$puFcwfo`WZk$&e@ zwzSpx?e&Ec*MQ0CMJc<@8B}+i0=N`JYJ(7$s41TptIrYB8~J`F?kB%&3wun4-p;2m z)!AWrwkmVbb7TZ%R5;h>eK&n%?39>A7YfCDdi<&S$;Lkr7!cV7FzAukI9EkaD#vKB z7$3{_(d7G;R_HuEK(eMGvzi|-Ak!S#O=%8FwHARBc|T=0g@EAtzHE2Wv*&ZjAmsA` zJwBFcmxA7pec{sYOjV-oHV(1sC%CV0#Fq*DpnVlW-zw}aQK=J#PBpYJ%CbN!a#y7O z-27dsVaURa_lf=Hy(qg;kcv){T4tF_xythsz z$smW{$AeLHFY|evVstd?nP>9YU9FbHK2SV$z~U=5b1H4}KBgR;pa9PA(%4MD?d+eI z?Psgxg(_bkpC0?(#^VZAw2=BY3wJ~>CL@U`&BB=Z+?A?ixW97{2NR zHFl#ejP^lp7bZlwbe-J+)0fq03eFZ@ngLHSP+c5p z6Suc~d?78k)!FQO61QJmkeu1^cC*h>#*>+^Z}tkwxdz=U^n^+au+YiVrd0cxODcM~ zaa-wo?~hK+EV_AvG_#GJ)NkbgTb8x!rFHiVST}s;rJ!I0$Zm)FC@H9>&k7qiT%H4! z_C(LTNQa9vY7olh2MWoW%Xa=PG#y{ns#?YR%FlyjGx~HCLt;zkXFSWq@;BB}5dV1} z-VDtv$rE<|fna*;aecmj{oc|N2LA14=fxHiBZPy*jK=onENImiT@M!tqn&H;y9_JN zn5vA9j=YSn;UL{->u4=`>y8*#==QqiNuykiHc3{S0VgTmM0v+*)_UjQ^-VB=vh~lO z(|>~e&0BuCZ&j?@B?L_IX(3%2By8s>KVq!?iC7}AGD$qhh4s0Jk;;bFu>aorwx6~} z*0gfEssY+wPV9xo#%{E9fuE=@mI=rqD}k5n=vBi#*lDYw5W;c?Kg8vhy&ANRcEGbe z2Kqi1$x36RQSE_xuQ#fy0|vd`JsIztl|CMp4%c!R=_JZX%+M$?rUekvKG0v)$S|3( zqjx;ex!jsOhd^5oWZM9-(u^W?E+w2Mj|WyL90~T_>Jw zD?vg}9JvX;v>oS1v|jb66fzZ=*37|Rzo~}g6~4&@qv$nMuq<_mW`&g0Wo3$#%2L_& z>>Z|XL8XOxNeM--MepU18h0iZUF!7bzeY>nBF>(ljnoc{_fUnDlNYax25GVm%Au3= z$TTM=Uz!<6lt^5brQRnsc!ag=J5=4T=@riUUd?>|C5j-hX^7sMkEo8!vFPx$!H52LvEks$}D6MZm$}*pU z3>#J?qQWL);L?xnea5SyHzxH^i3s2_d^<1lW1XxDpHvAAiWpo%f$@+%x(-^hq-&zF zN8MdPLpTM?U`N04++zh5mxbG5C5ON5Iq9?Fej$(32%bQbYgxrZEdNyeVpmLIikX9}f?b|%2I{!R?;tg4<1m_)lgPgTZ4#fX`{F;L z<4UPxCYiG(=qx-4-L)`><~;n63Ndw<=r)KtVg2n_3L*!DtI!n0Q-u}Dl<~uee})H7 zv!e*<(cuURace%VCo>ZGw(IA39WE~qNpaz`X>j4c*SDx+-aIPY4u#l{65a}R8Q#Du zu}Wo&NW$lmNJy};9%=R6sP+9ma_jP@{v?SBzr?Vz3&5pR#Z#gi6-nKH;oQ$wNB71# zJD08s2ym58*X|Eu3rH}M)MwVRsErMy@jpe5Otbo1!Y@G%8%{{-0}xQ;`XC$kT|&Ds zTlt0I6Bjt6?gr#ffAiY?VFeI?;&aPlx&PVPt={_Wd0&pdA>Ttho)1zk7;*^ng?&M0oCRZ;k2JhVYxKP;XVHh5TQlVi#8L1bYWEFfBm-UW?&dc5(8wV`&B1D(*Xd8RUaII zUonrlT66Y!YUd9;)_J_u99UPT$G7HVHkc9c`(e;c2?X1A%wgexZWfY=n&5^gY{cL@ zt0McQ#;3C1S@Uu;jn*j@@Wl&VT{TMq2Ri3_O^%x0Fp>~x{b8S!A%A9N&L%sPiT)Vq zU=|el46G04T{v9F%l2X}ToaqL@~jYpm4v$dGi(0fOg?$w9TT&1QOFE7dtL7i>~S2J zt0bx+TYGre?A=o+J)bQe!Oc-Ag?fdwc!fDH4)XxqyL^SB#23e~OIge=$$Dztqww~_o$Lu8OwcW3-Q^YjCXiiDqu@f$hbci4MuUZ9}n2FCo>P7UH! zYG{tDRYHIJiQGd1t@1@<*?^~$;+A15JrmOAG7N<%4ikhAAuPhxlB`=WT;l7PSfO1g zXf`^Q-ZTzBMY;n#!<_`n!igDy!CWEYt8N>?G0kFL*G^(?CsUGAQZ}>SZ~PJphK@kn zHer{!>eeUNJlskY_s!bjgxr1N(~uYSgnoUBD{b zQup^D6%!ofoG!W+zMZglmQvFm+^Urx+P1X4ZdpH)C?v#`(-_dyw=-lC6Cs*|*n#1o zA8o_I!v46@^+*u+(&*f)Vo@pD`2N|PToBCA8vyBDPPI4uwBvDBuu;33&sV><5T&Td zn0k>SqUu4-=%@8C=guTk=VB@}KDo-3uQT0Z z140LHXC?<1!xhhB+M81fNN5>_uTch_N9TR6??qI3cAk(3M^~3$&GzlE@grcREEQqp zABn6OOS$))pPT2q&wi2?EpHGA8E-=CsQD{Bp2$z_B|Xgvwc0!M{s;*P`2%WbsICP+ zWpnx6XJdA9@UvgXby)vtD zv^W*(An8KD`82dbuvXtNa5wTdcL4=f$rP14SdRh=a+EZk6lR|ZGY4uWiv&u1Mto@! zi2(fyhAo=fjxs&EFnBYkSmshS!~NJZM&H9|{-uJ+U`VWRJ8#uPaBWX2*2(pS;HY8> zlYq=KEX-FJ_&~?IR^9d5bc}!c6qu?be{&^*bcRm< zJn-*fw?lTVbx(utNN5Lw+QJbXI!bjqYW^JZd8yF&V9vNkml@|_qg)sbYbq8PR!S8T zosYD2Ad15I`}cjq55em=AS}QKE6Qgv2+%F>P+gbc&8mvls-~3AF{u?_Uxn@a#u?zLVSbp z`N7Je3UiLWGj^aXUURi%m(Eh7D7$WVe7U+qK6b4VRh&*_R^m4${>nGq#w&N+|5L}k z(_*?(Pv9jO4wK(f8yn&rpnjg!SX$;9n)NkHd)~kg>;ZTvG+yfJ`eQPCI02V2x@&(0 z^pdDP=PNrfNIQT)&%%Wi5oQ1$rcL?}Qj)KD#_hXyq?N!SXZ>xbvN5i5Y*W1PtC(of18u&9w2&U$qtFuvt{f zDCnH9{Z}QE-r-y6`vGnS-QiRme?6@TXH&?MFP}q8`)Ibuwk!(1}4@m9`mE#FI z?3inHJu0g2%qi|2v?~i-)2lPznlGPi;KD|<>CmJ>mb|^1wrUMsV>a?MKmP^D+v5pDl4_nNJHXdV0Dwq+)z5f>@@K6%=FP~vpvx5F!f-c>ab z==Yp6n?6W*ZLSKH3$h;!zocp>!GZrOUWXX(J<)ym7f3v6`t<=+YcvDZw?pRx=!#g2 z@9>AG toggled with display:none downloads + both, on every page, for one visible mark. */ +.bb-mark { display: block; + flex-shrink: 0; + background-image: url("../assets/bb-icon.png"); + background-size: 100% 100%; +} + +.dark .bb-mark { + background-image: url("../assets/bb-icon-dark.png"); +} + +.logo-mark { + width: 36px; + height: 36px; } .nav-links { @@ -139,6 +208,131 @@ code, font-weight: 500; } +/* Theme control: the button shows the *preference* (sun / moon / monitor for + light / dark / system). All three glyphs are always in the DOM — SSR can't + know the preference, so this keeps hydration deterministic — and + html[data-theme-preference], stamped pre-paint by THEME_INIT, picks one; + no attribute (no JS) means the default, system. The button opens a + Light / Dark / System menu anchored to its right edge. */ +.theme-menu-wrap { + position: relative; + display: inline-flex; +} + +.theme-toggle { + display: inline-grid; + place-items: center; + width: 30px; + height: 30px; + padding: 0; + border: 0; + border-radius: 7px; + background: none; + color: var(--dim); + cursor: pointer; +} + +.theme-toggle:hover { + background: var(--state-hover); + color: var(--ink); +} + +.theme-toggle:focus-visible { + outline: 2px solid var(--ink); + outline-offset: 2px; +} + +.theme-toggle svg { + width: 17px; + height: 17px; +} + +.theme-toggle .theme-ic-sun, +.theme-toggle .theme-ic-moon { + display: none; +} + +html[data-theme-preference="light"] .theme-toggle .theme-ic-sun, +html[data-theme-preference="dark"] .theme-toggle .theme-ic-moon { + display: block; +} + +html[data-theme-preference="light"] .theme-toggle .theme-ic-system, +html[data-theme-preference="dark"] .theme-toggle .theme-ic-system { + display: none; +} + +.theme-menu { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 20; + min-width: 148px; + padding: 4px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg); + box-shadow: + 0 1px 0 color-mix(in oklab, var(--shade) 4%, transparent), + 0 12px 32px -12px color-mix(in oklab, var(--shade) 40%, transparent); + animation: theme-menu-in 0.12s ease-out; +} + +@keyframes theme-menu-in { + from { + opacity: 0; + transform: translateY(-3px); + } + to { + opacity: 1; + transform: none; + } +} + +.theme-menu-item { + display: flex; + align-items: center; + gap: 9px; + width: 100%; + padding: 7px 9px; + border: 0; + border-radius: 7px; + background: none; + font: inherit; + font-size: 13.5px; + color: var(--ink); + text-align: left; + cursor: pointer; +} + +.theme-menu-item:hover { + background: var(--state-hover); +} + +.theme-menu-item:focus-visible { + outline: 2px solid var(--ink); + outline-offset: -2px; +} + +.theme-menu-item[aria-checked="true"] { + font-weight: 500; +} + +.theme-menu-ic { + width: 15px; + height: 15px; + flex-shrink: 0; + color: var(--dim); +} + +.theme-menu-check { + width: 14px; + height: 14px; + flex-shrink: 0; + margin-left: auto; + color: var(--ink); +} + /* ── Buttons ────────────────────────────────────────────────────── */ .btn { @@ -170,7 +364,7 @@ code, .btn-primary:hover { background: color-mix(in oklab, var(--ink) 90%, var(--bg)); - box-shadow: 0 4px 14px color-mix(in oklab, var(--ink) 18%, transparent); + box-shadow: 0 4px 14px color-mix(in oklab, var(--shade) 18%, transparent); } .btn-ghost { @@ -281,7 +475,7 @@ code, pointer-events: none; opacity: 0; transform: translateX(-50%) translateY(4px); - box-shadow: 0 8px 22px -8px color-mix(in oklab, var(--ink) 50%, transparent); + box-shadow: 0 8px 22px -8px color-mix(in oklab, var(--shade) 50%, transparent); transition: opacity 0.18s ease, transform 0.18s ease; @@ -514,7 +708,7 @@ code, letter-spacing: -0.035em; max-width: 780px; margin: 0 auto; - color: oklch(0.21 0 0); + color: var(--heading); text-wrap: balance; } @@ -717,6 +911,20 @@ code, opacity: 0.52; } +/* Dark: invert after the grayscale so near-black marks flip light instead of + sinking into the canvas; grayscale-first keeps the invert luminance-only. + Marks that carry their own tile background are excluded — inverting one of + those flips the tile rather than the glyph, so Blackstone's black square + would come out white and Notion's white square black. They keep their own + contrast, which already reads on either canvas. */ +.dark .company-proof-company img { + opacity: 0.6; +} + +.dark .company-proof-company img:not(.company-proof-tile) { + filter: grayscale(1) invert(1); +} + /* One cycle travels exactly one logo-list copy. The track holds --company-proof-copies identical copies, so -100% / copies keeps the loop seamless at every viewport width. */ @@ -733,8 +941,8 @@ code, background: var(--bg); overflow: hidden; box-shadow: - 0 1px 0 color-mix(in oklab, var(--ink) 4%, transparent), - 0 30px 70px -32px color-mix(in oklab, var(--ink) 34%, transparent); + 0 1px 0 color-mix(in oklab, var(--shade) 4%, transparent), + 0 30px 70px -32px color-mix(in oklab, var(--shade) 34%, transparent); } /* ── Self-constructing mock ─────────────────────────────────────────── @@ -1253,7 +1461,7 @@ html.js .mock[data-construct]:not(.constructing):not(.constructed) { .msg-say { font-size: 13.5px; line-height: 1.55; - color: oklch(0.28 0 0); + color: var(--ink-strong); max-width: 58ch; } @@ -1778,7 +1986,7 @@ html.js .mock[data-construct]:not(.constructing):not(.constructed) { letter-spacing: -0.03em; text-wrap: balance; overflow-wrap: break-word; - color: oklch(0.21 0 0); + color: var(--heading); } .band-copy p { @@ -1825,7 +2033,8 @@ html.js .mock[data-construct]:not(.constructing):not(.constructed) { border-radius: 18px; overflow: hidden; background: var(--bg); - box-shadow: 0 18px 50px -26px color-mix(in oklab, var(--ink) 32%, transparent); + box-shadow: 0 18px 50px -26px + color-mix(in oklab, var(--shade) 32%, transparent); } /* contact bar */ @@ -1883,8 +2092,11 @@ html.js .mock[data-construct]:not(.constructing):not(.constructed) { object-fit: cover; } -/* wallpaper + messages */ +/* wallpaper + messages. --tg-card is Telegram's own surface colour, not a bb + token: the mock imitates a foreign app, so its values are deliberately + literal in both schemes, like the wallpaper and the outgoing green bubble. */ .tg-feed { + --tg-card: #fff; display: flex; flex-direction: column; gap: 8px; @@ -1944,8 +2156,8 @@ html.js .mock[data-construct]:not(.constructing):not(.constructed) { } .tg-in .tg-bubble { - background: #fff; - color: oklch(0.28 0 0); + background: var(--tg-card); + color: var(--ink-strong); border-bottom-left-radius: 5px; } @@ -1979,7 +2191,7 @@ html.js .mock[data-construct]:not(.constructing):not(.constructed) { /* the bb thread card the bot spawns */ .tg-thread { width: 100%; - background: #fff; + background: var(--tg-card); border: 1px solid var(--border-soft); border-radius: 12px; border-bottom-left-radius: 5px; @@ -2135,6 +2347,21 @@ html.js .mock[data-construct]:not(.constructing):not(.constructed) { } } +/* Telegram mock, dark: the wallpaper and incoming bubbles follow Telegram's + own dark appearance (dark green-tinted wallpaper, graphite bubbles) so the + foreign app stays recognizable instead of glaring white on the dark page. + The outgoing green bubble is Telegram's in both schemes and stays put. */ +.dark .tg-feed { + --tg-card: oklch(0.28 0 0); + background-color: oklch(0.24 0.02 150); + background-image: + radial-gradient( + color-mix(in oklab, oklch(0.75 0.06 150) 10%, transparent) 1px, + transparent 1.5px + ), + linear-gradient(165deg, oklch(0.26 0.025 155), oklch(0.2 0.03 140)); +} + /* ── Band 3 visual: mobile bb app mock ──────────────────────────── */ .mockup-wrap-customize { @@ -2265,7 +2492,7 @@ html.js .mock[data-construct]:not(.constructing):not(.constructed) { 50% { transform: translateY(-1px) scale(1.04); box-shadow: 0 8px 18px -12px - color-mix(in oklab, var(--ink) 60%, transparent); + color-mix(in oklab, var(--shade) 60%, transparent); } } @@ -2601,7 +2828,7 @@ html.js .mock[data-construct]:not(.constructing):not(.constructed) { font-weight: 700; letter-spacing: -0.025em; margin-bottom: 12px; - color: oklch(0.21 0 0); + color: var(--heading); } .closer { @@ -2925,7 +3152,8 @@ html.js .mock[data-construct]:not(.constructing):not(.constructed) { } .nav, - .hero > * { + .hero > *, + .theme-menu { animation: none; } diff --git a/apps/web/src/landing/site-chrome.tsx b/apps/web/src/landing/site-chrome.tsx index 2a56ebc44b..1ff32b6ca1 100644 --- a/apps/web/src/landing/site-chrome.tsx +++ b/apps/web/src/landing/site-chrome.tsx @@ -1,14 +1,155 @@ -import bbIcon from "../assets/bb-icon.png"; +import { + ComputerIcon, + Moon02Icon, + Sun03Icon, + Tick02Icon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react"; +import { useEffect, useRef, useState } from "react"; + import { DASHBOARD_PATH } from "../lib/connect-return-to"; +import { + DARK_SCHEME_QUERY, + THEME_STORAGE_KEY, + type ThemePreference, + applyThemePreference, + readThemePreference, + setThemePreference, +} from "../lib/theme"; import { DiscordLink, DownloadLink, GitHubLink, XLink } from "./cta"; type SiteNavPage = "blog" | "changelog"; +/* ── Theme ───────────────────────────────────────────────────────── + The preference model itself lives in lib/theme.ts, shared with the + pre-paint script in __root.tsx so there is one implementation of the rule. + This file only owns the control. */ + +const THEME_OPTIONS: ReadonlyArray<{ + value: ThemePreference; + label: string; + icon: IconSvgElement; +}> = [ + { value: "light", label: "Light", icon: Sun03Icon }, + { value: "dark", label: "Dark", icon: Moon02Icon }, + { value: "system", label: "System", icon: ComputerIcon }, +]; + +// Preference button (sun / moon / monitor for Light / Dark / System — all +// three glyphs render and CSS keyed off html[data-theme-preference] picks +// one, so SSR output is preference-independent and hydration can't +// mismatch) that opens a Light / Dark / System menu. The menu only exists +// while open, and its checked state comes from the effect below rather than +// the server render, so it never has to agree with SSR. +function ThemeMenu() { + const [open, setOpen] = useState(false); + const [preference, setPreference] = useState("system"); + const rootRef = useRef(null); + const buttonRef = useRef(null); + + // Follow the OS while the preference is "system" (live, not just at load), + // and pick up a choice made in another tab. Both the document and this + // component's copy of the preference are refreshed together, so an open menu + // can't keep showing a checkmark the page no longer agrees with. + useEffect(() => { + const media = matchMedia(DARK_SCHEME_QUERY); + const sync = () => { + const next = readThemePreference(); + setPreference(next); + applyThemePreference(next); + }; + // THEME_INIT normally did this pre-paint, but it gives up when storage + // access throws, which would otherwise leave the page light while this + // control reported "System". + sync(); + const onScheme = () => { + if (readThemePreference() === "system") applyThemePreference("system"); + }; + const onStorage = (event: StorageEvent) => { + if (event.key === THEME_STORAGE_KEY || event.key === null) sync(); + }; + media.addEventListener("change", onScheme); + window.addEventListener("storage", onStorage); + return () => { + media.removeEventListener("change", onScheme); + window.removeEventListener("storage", onStorage); + }; + }, []); + + // Dismiss on outside click or Escape; Escape returns focus to the button. + useEffect(() => { + if (!open) return; + const onPointerDown = (event: PointerEvent) => { + if (!rootRef.current?.contains(event.target as Node)) setOpen(false); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setOpen(false); + buttonRef.current?.focus(); + } + }; + document.addEventListener("pointerdown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("pointerdown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [open]); + + // A same-tab write fires no storage event, so this tab's copy is set here. + const choose = (next: ThemePreference) => { + setThemePreference(next); + setPreference(next); + setOpen(false); + buttonRef.current?.focus(); + }; + + return ( +
+ + {open && ( +
+ {THEME_OPTIONS.map((option) => ( + + ))} +
+ )} +
+ ); +} + export function SiteNav({ current }: { current?: SiteNavPage }) { return (