diff --git a/apps/app/.ladle/story-fixtures.ts b/apps/app/.ladle/story-fixtures.ts index 2428c70e5f..eef540f6bb 100644 --- a/apps/app/.ladle/story-fixtures.ts +++ b/apps/app/.ladle/story-fixtures.ts @@ -13,6 +13,7 @@ import type { import type { ProjectResponse, SystemEnvironmentProvider, + SystemMachineProvider, } from "@bb/server-contract"; import { EMPTY_ORDERED_MENTION_SUGGESTIONS } from "@bb/client-core"; import { @@ -339,6 +340,30 @@ export const STORY_ENVIRONMENT_PROVIDERS: readonly SystemEnvironmentProvider[] = }, ]; +export const STORY_MACHINE_PROVIDERS: readonly SystemMachineProvider[] = [ + { + id: "modal-sandbox", + displayName: "Modal sandbox", + icon: "Box", + logoUrl: null, + pluginId: "environment-modal-sandbox", + requires: { gitRemote: true }, + inputs: null, + acceptsEmptyInputs: true, + supportsSuspend: true, + environmentRow: { + displayName: "Modal sandbox", + environmentProviderId: "project-checkout", + }, + policy: { + idleSuspendMs: 15 * 60_000, + retire: { after: "last-thread", graceMs: 30 * 24 * 60 * 60_000 }, + removeRetryMs: 60_000, + }, + availability: null, + }, +]; + export const STORY_PROJECTS: readonly ProjectSelectorOption[] = [ { id: PROJECT_IDS.bb, name: PROJECT_NAMES.bb }, { id: PROJECT_IDS.pierre, name: PROJECT_NAMES.pierre }, diff --git a/apps/app/src/components/dialogs/AddMachineDialog.test.tsx b/apps/app/src/components/dialogs/AddMachineDialog.test.tsx deleted file mode 100644 index 0c931e991f..0000000000 --- a/apps/app/src/components/dialogs/AddMachineDialog.test.tsx +++ /dev/null @@ -1,335 +0,0 @@ -// @vitest-environment jsdom - -import { - act, - cleanup, - fireEvent, - render, - screen, - waitFor, -} from "@testing-library/react"; -import type { Host } from "@bb/domain"; -import { makeHost as host } from "@bb/test-helpers/domain-fixtures"; -import type { InstalledPlugin } from "@bb/server-contract"; -import { MemoryRouter } from "react-router-dom"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { BbHttpError, sdk } from "@/lib/sdk"; -import { hostsQueryKey } from "@/hooks/queries/query-keys"; -import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; -import { AddMachineDialog } from "./AddMachineDialog"; -import { makeInstalledPlugin } from "@/test/fixtures/plugins"; - -vi.mock("@/lib/sdk", async (importOriginal) => { - const original = await importOriginal(); - return { - ...original, - sdk: { - hosts: { - createJoinCode: vi.fn(), - list: vi.fn(), - }, - plugins: { callRpc: vi.fn(), list: vi.fn() }, - }, - }; -}); - -vi.mock("@/lib/ws", () => ({ - wsManager: { subscribe: vi.fn(), unsubscribe: vi.fn() }, -})); - -const existingHost = host({ id: "host_primary", name: "MacBook Pro" }); - -function connectPlugin( - overrides: Pick, -): InstalledPlugin { - return makeInstalledPlugin({ - id: "connect", - source: "builtin:connect", - rootDir: "/plugins/connect", - provenance: "builtin", - publisherLabel: "BB Official", - sourceDisplay: "builtin · connect", - name: "Remote access", - hasSettings: true, - ...overrides, - }); -} - -function notRunningRpcError(status: string): BbHttpError { - const message = `plugin "connect" is not running (status: ${status})`; - return new BbHttpError({ - body: { ok: false, error: message }, - code: null, - message, - status: 503, - }); -} -const writeTextMock = vi.fn().mockResolvedValue(undefined); -Object.defineProperty(navigator, "clipboard", { - configurable: true, - value: { writeText: writeTextMock }, -}); - -afterEach(() => { - cleanup(); - vi.clearAllMocks(); -}); - -describe("AddMachineDialog", () => { - it("mints a join code, shows the pairing command, and detects the new machine connecting", async () => { - vi.mocked(sdk.hosts.createJoinCode).mockResolvedValue({ - joinCode: "jc_test123", - hostId: "host_new", - expiresAt: Date.now() + 15 * 60 * 1000, - }); - vi.mocked(sdk.plugins.callRpc).mockResolvedValue({ - code: "mc_test456", - expiresAt: Date.now() + 10 * 60 * 1000, - serverUrl: "https://example.getbb.app", - }); - vi.mocked(sdk.hosts.list).mockResolvedValue([existingHost]); - - const { queryClient, wrapper } = createQueryClientTestHarness(); - render( - - - , - { wrapper }, - ); - - const command = await screen.findByText(/--join-code jc_test123/); - expect(sdk.plugins.callRpc).toHaveBeenCalledWith( - expect.objectContaining({ - pluginId: "connect", - method: "createMachineCode", - input: null, - }), - ); - expect(command.textContent).toContain("--host-id host_new"); - expect(command.textContent).toContain( - "curl -fL --progress-meter --connect-timeout 10 --max-time 60 --retry 2 https://example.getbb.app/install.sh", - ); - expect(command.textContent).toContain("--server https://example.getbb.app"); - expect(command.textContent).toContain("--machine-code mc_test456"); - expect(command.textContent).not.toContain(window.location.origin); - expect(command.closest("[data-add-machine-command]")).not.toBeNull(); - expect( - screen.getByText( - /It installs bb and keeps the machine connected to this server/u, - ), - ).toBeDefined(); - expect(screen.getByText(/Code expires in \d+:\d{2}/)).toBeDefined(); - const waiting = screen.getByText("Waiting for the machine to connect…"); - expect(waiting).toBeDefined(); - expect(waiting.parentElement?.className).not.toContain("border-border"); - - fireEvent.click(screen.getByRole("button", { name: "Copy" })); - await waitFor(() => { - expect(writeTextMock).toHaveBeenCalledWith(command.textContent); - expect(screen.getByRole("button", { name: "Copied" })).toBeDefined(); - }); - - await waitFor(() => { - expect(queryClient.getQueryData(hostsQueryKey())).toHaveLength(1); - }); - - act(() => { - queryClient.setQueryData(hostsQueryKey(), [ - existingHost, - host({ id: "host_new", name: "Mac Studio" }), - ]); - }); - - expect(await screen.findByText("Mac Studio connected")).toBeDefined(); - expect( - screen.getByRole("button", { name: "Set up a project on it →" }), - ).toBeDefined(); - expect( - screen.queryByText("Waiting for the machine to connect…"), - ).toBeNull(); - }); - - it("falls back to direct pairing when connect is unpaired and ignores known hosts", async () => { - vi.mocked(sdk.hosts.createJoinCode).mockResolvedValue({ - joinCode: "jc_test123", - hostId: "host_new", - expiresAt: Date.now() + 15 * 60 * 1000, - }); - vi.mocked(sdk.plugins.callRpc).mockRejectedValue( - new BbHttpError({ - body: { - ok: false, - error: { code: "handler_error", message: "not_paired" }, - }, - code: "handler_error", - message: "not_paired", - status: 500, - }), - ); - vi.mocked(sdk.hosts.list).mockResolvedValue([ - existingHost, - host({ id: "host_offline", name: "dev-vm", status: "disconnected" }), - ]); - - const { queryClient, wrapper } = createQueryClientTestHarness(); - render( - - - , - { wrapper }, - ); - - const command = await screen.findByText(/--join-code jc_test123/); - expect(command.textContent).toContain( - "curl -fL --progress-meter --connect-timeout 10 --max-time 60 --retry 2 http://direct.example.test:38886/install.sh", - ); - expect(command.textContent).toContain( - "--server http://direct.example.test:38886", - ); - expect(command.textContent).not.toContain("--machine-code"); - - await waitFor(() => { - expect(queryClient.getQueryData(hostsQueryKey())).toHaveLength(2); - }); - - act(() => { - queryClient.setQueryData(hostsQueryKey(), [ - existingHost, - host({ id: "host_offline", name: "dev-vm" }), - ]); - }); - - expect( - await screen.findByText("Waiting for the machine to connect…"), - ).toBeDefined(); - expect(screen.queryByText("dev-vm connected")).toBeNull(); - }); - - it("explains that a loopback server is unreachable when connect is unpaired", async () => { - vi.mocked(sdk.hosts.createJoinCode).mockResolvedValue({ - joinCode: "jc_test123", - hostId: "host_new", - expiresAt: Date.now() + 15 * 60 * 1000, - }); - vi.mocked(sdk.plugins.callRpc).mockRejectedValue( - new BbHttpError({ - body: { - ok: false, - error: { code: "handler_error", message: "not_paired" }, - }, - code: "handler_error", - message: "not_paired", - status: 500, - }), - ); - vi.mocked(sdk.hosts.list).mockResolvedValue([existingHost]); - - const { wrapper } = createQueryClientTestHarness(); - render( - - - , - { wrapper }, - ); - - const notice = await screen.findByRole("status"); - expect(notice.textContent).toContain( - "Another machine cannot use this address.", - ); - expect(notice.textContent).toContain("http://127.0.0.1:38886"); - expect(screen.queryByText(/--join-code jc_test123/)).toBeNull(); - const link = screen.getByRole("link", { name: "Set up remote access" }); - expect(link.getAttribute("href")).toBe("/settings/plugins/connect"); - expect( - screen.queryByText("Waiting for the machine to connect…"), - ).toBeNull(); - }); - - it("offers a retry when connect is temporarily unavailable on a loopback server", async () => { - vi.mocked(sdk.hosts.createJoinCode).mockResolvedValue({ - joinCode: "jc_test123", - hostId: "host_new", - expiresAt: Date.now() + 15 * 60 * 1000, - }); - vi.mocked(sdk.plugins.callRpc).mockRejectedValue( - notRunningRpcError("degraded"), - ); - vi.mocked(sdk.plugins.list).mockResolvedValue({ - plugins: [connectPlugin({ enabled: true, status: "degraded" })], - }); - vi.mocked(sdk.hosts.list).mockResolvedValue([existingHost]); - - const { wrapper } = createQueryClientTestHarness(); - render( - - - , - { wrapper }, - ); - - expect( - await screen.findByText("Remote access isn't ready yet."), - ).toBeDefined(); - expect(screen.getByRole("button", { name: "Try again" })).toBeDefined(); - expect(screen.queryByText(/--join-code jc_test123/)).toBeNull(); - expect(screen.queryByRole("status")).toBeNull(); - }); - - it("links to the Connect plugin when it is disabled on a loopback server", async () => { - vi.mocked(sdk.hosts.createJoinCode).mockResolvedValue({ - joinCode: "jc_test123", - hostId: "host_new", - expiresAt: Date.now() + 15 * 60 * 1000, - }); - vi.mocked(sdk.plugins.callRpc).mockRejectedValue( - notRunningRpcError("disabled"), - ); - vi.mocked(sdk.plugins.list).mockResolvedValue({ - plugins: [connectPlugin({ enabled: false, status: "disabled" })], - }); - vi.mocked(sdk.hosts.list).mockResolvedValue([existingHost]); - - const { wrapper } = createQueryClientTestHarness(); - render( - - - , - { wrapper }, - ); - - const notice = await screen.findByRole("status"); - expect(notice.textContent).toContain("The Connect plugin is disabled"); - const link = screen.getByRole("link", { - name: "Enable the Connect plugin", - }); - expect(link.getAttribute("href")).toBe( - "/settings/plugins/connect?view=installed", - ); - expect(screen.queryByText("Remote access isn't ready yet.")).toBeNull(); - expect(screen.queryByRole("button", { name: "Try again" })).toBeNull(); - expect( - screen.queryByText("Waiting for the machine to connect…"), - ).toBeNull(); - expect(screen.queryByText(/--join-code jc_test123/)).toBeNull(); - }); -}); diff --git a/apps/app/src/components/dialogs/AddMachineDialog.tsx b/apps/app/src/components/dialogs/AddMachineDialog.tsx deleted file mode 100644 index 585f44b8dd..0000000000 --- a/apps/app/src/components/dialogs/AddMachineDialog.tsx +++ /dev/null @@ -1,397 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import { Link } from "react-router-dom"; -import { useMutation } from "@tanstack/react-query"; -import type { Host } from "@bb/domain"; -import { z } from "zod"; -import { Button } from "@bb/shared-ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@bb/shared-ui/dialog"; -import { Icon } from "@bb/shared-ui/icon"; -import { MachineStatusDot } from "@/components/machines/MachineStatusDot"; -import { useHosts } from "@/hooks/queries/host-queries"; -import { useClipboardCopy } from "@/lib/clipboard"; -import { isLocalOnlyUrl } from "@/lib/loopback-hostname"; -import { - getPluginConfigurationRoutePath, - getPluginDetailRoutePath, -} from "@/lib/route-paths"; -import { BbHttpError, sdk } from "@/lib/sdk"; -import { getMutationErrorMessage } from "@/lib/mutation-errors"; - -interface AddMachineDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - serverUrl: string | null; -} - -const connectMachineCodeSchema = z.object({ - code: z.string(), - expiresAt: z.number(), - serverUrl: z.string(), -}); - -const pluginRpcErrorEnvelopeSchema = z.object({ - error: z.object({ message: z.string() }), -}); - -type ConnectMachineCode = z.infer; - -function isNotPairedRpcError(error: BbHttpError): boolean { - const envelope = pluginRpcErrorEnvelopeSchema.safeParse(error.body); - return envelope.success && envelope.data.error.message === "not_paired"; -} - -type ConnectMachineCodeResult = - | { kind: "issued"; code: ConnectMachineCode } - | { kind: "unpaired" } - | { kind: "disabled" } - | { kind: "unavailable" }; - -async function isConnectPluginDisabled(): Promise { - try { - const { plugins } = await sdk.plugins.list(); - const connect = plugins.find((plugin) => plugin.id === "connect"); - return connect !== undefined && !connect.enabled; - } catch { - return false; - } -} - -async function createConnectMachineCode(): Promise { - try { - const code = await sdk.plugins.callRpc({ - pluginId: "connect", - method: "createMachineCode", - input: null, - outputSchema: connectMachineCodeSchema, - }); - return { kind: "issued", code }; - } catch (error) { - if (!(error instanceof BbHttpError)) throw error; - if ( - error.code === "not_paired" || - isNotPairedRpcError(error) || - error.status === 404 - ) { - return { kind: "unpaired" }; - } - if (error.status === 503) { - return (await isConnectPluginDisabled()) - ? { kind: "disabled" } - : { kind: "unavailable" }; - } - if (error.status === 422) { - return { kind: "unavailable" }; - } - throw error; - } -} - -export function AddMachineDialog({ - open, - onOpenChange, - serverUrl, -}: AddMachineDialogProps) { - return ( - - - {open ? ( - - ) : null} - - - ); -} - -function formatCountdown(remainingMs: number): string { - const totalSeconds = Math.max(0, Math.floor(remainingMs / 1000)); - const minutes = Math.floor(totalSeconds / 60); - const seconds = totalSeconds % 60; - return `${minutes}:${seconds.toString().padStart(2, "0")}`; -} - -function pairingCommand( - joinCode: string, - hostId: string, - machineCode: ConnectMachineCode | null, - directServerUrl: string | null, -): string | null { - const serverUrl = machineCode?.serverUrl ?? directServerUrl; - if (serverUrl === null) return null; - const machineFlag = - machineCode === null ? "" : ` --machine-code ${machineCode.code}`; - return `curl -fL --progress-meter --connect-timeout 10 --max-time 60 --retry 2 ${serverUrl}/install.sh | sh -s -- --join-code ${joinCode} --host-id ${hostId} --server ${serverUrl}${machineFlag}`; -} - -const REMOTE_ACCESS_ROUTE = getPluginConfigurationRoutePath({ - pluginId: "connect", -}); -const CONNECT_PLUGIN_ROUTE = getPluginDetailRoutePath({ - pluginId: "connect", - view: "installed", -}); - -function UnreachableServerNotice({ - serverUrl, - reason, -}: { - serverUrl: string; - reason: "unpaired" | "disabled"; -}) { - return ( -
-

- Another machine cannot use this address. -

-

- The pairing command would target{" "} - {serverUrl}, which points to the - machine that runs it, not to this bb.{" "} - {reason === "disabled" - ? "The Connect plugin is disabled, so remote access is off. Enable it, then come back here to get a pairing command that works from anywhere." - : "Set up remote access first, then come back here to get a pairing command that works from anywhere."} -

-
- - - Other options - -
-
- ); -} - -function AddMachineDialogContent({ - onOpenChange, - serverUrl, -}: { - onOpenChange: (open: boolean) => void; - serverUrl: string | null; -}) { - const hostsQuery = useHosts(); - const mintJoinCode = useMutation({ - meta: { showErrorToast: false }, - mutationFn: async () => { - const [join, machine] = await Promise.all([ - sdk.hosts.createJoinCode(), - createConnectMachineCode(), - ]); - return { join, machine }; - }, - }); - const mint = mintJoinCode.mutate; - useEffect(() => { - mint(); - }, [mint]); - - const baselineHostIds = useRef | null>(null); - if (baselineHostIds.current === null && hostsQuery.data !== undefined) { - baselineHostIds.current = new Set(hostsQuery.data.map((host) => host.id)); - } - const connectedNewHost: Host | null = - (baselineHostIds.current !== null - ? hostsQuery.data?.find( - (host) => - host.status === "connected" && - !baselineHostIds.current?.has(host.id), - ) - : undefined) ?? null; - - const joinCode = mintJoinCode.data?.join ?? null; - const machineCodeResult = mintJoinCode.data?.machine ?? null; - const machineCode = - machineCodeResult?.kind === "issued" ? machineCodeResult.code : null; - const expiresAt = - joinCode === null - ? null - : Math.min(joinCode.expiresAt, machineCode?.expiresAt ?? Infinity); - const localOnlyServerUrl = - serverUrl !== null && isLocalOnlyUrl(serverUrl) ? serverUrl : null; - const unreachable = - (machineCodeResult?.kind === "unpaired" || - machineCodeResult?.kind === "disabled") && - localOnlyServerUrl !== null - ? { serverUrl: localOnlyServerUrl, reason: machineCodeResult.kind } - : null; - const connectUnavailable = - machineCodeResult?.kind === "unavailable" && localOnlyServerUrl !== null; - const showCommand = - joinCode !== null && unreachable === null && !connectUnavailable; - - const [now, setNow] = useState(() => Date.now()); - const hasCountdown = showCommand && expiresAt !== null; - useEffect(() => { - if (!hasCountdown) return; - const interval = window.setInterval(() => setNow(Date.now()), 1000); - return () => window.clearInterval(interval); - }, [hasCountdown]); - const remainingMs = - hasCountdown && expiresAt !== null ? expiresAt - now : null; - const expired = remainingMs !== null && remainingMs <= 0; - const command = - showCommand && joinCode !== null - ? pairingCommand( - joinCode.joinCode, - joinCode.hostId, - machineCode, - serverUrl, - ) - : null; - const { copied, copy } = useClipboardCopy({ text: command ?? "" }); - - return ( - <> - - Add a machine - - {unreachable !== null - ? "Pair a machine to run projects and threads on it." - : "Run this command on the machine you want to add. It installs bb and keeps the machine connected to this server."} - - -
- {mintJoinCode.isError || connectUnavailable ? ( -
-

- {connectUnavailable - ? "Remote access isn't ready yet." - : getMutationErrorMessage({ - error: mintJoinCode.error, - fallbackMessage: "Couldn't create a join code.", - })} -

- -
- ) : unreachable !== null ? ( - - ) : command !== null ? ( -
-
-              {command}
-            
-
- {expired ? ( - <> - - Code expired - - - - ) : remainingMs !== null ? ( - - Code expires in {formatCountdown(remainingMs)} - - ) : null} - -
-
- ) : ( -

- - Creating a join code… -

- )} - {unreachable !== null ? null : ( -
- {connectedNewHost !== null ? ( - <> - - - {connectedNewHost.name} connected - - - - ) : ( - <> - - - Waiting for the machine to connect… - - - )} -
- )} -
- - - - - ); -} diff --git a/apps/app/src/components/dialogs/ConfirmDeleteDialog.tsx b/apps/app/src/components/dialogs/ConfirmDeleteDialog.tsx index 4278244b98..34631a956d 100644 --- a/apps/app/src/components/dialogs/ConfirmDeleteDialog.tsx +++ b/apps/app/src/components/dialogs/ConfirmDeleteDialog.tsx @@ -57,18 +57,20 @@ export function ConfirmDeleteDialogContent({ } interface ConfirmDeleteDialogProps { + modal?: boolean; open: boolean; onOpenChange: (open: boolean) => void; children: ReactNode; } export function ConfirmDeleteDialog({ + modal = true, open, onOpenChange, children, }: ConfirmDeleteDialogProps) { return ( - + {open ? children : null} ); diff --git a/apps/app/src/components/dialogs/CreateMachineDialog.test.tsx b/apps/app/src/components/dialogs/CreateMachineDialog.test.tsx new file mode 100644 index 0000000000..68abecc9e1 --- /dev/null +++ b/apps/app/src/components/dialogs/CreateMachineDialog.test.tsx @@ -0,0 +1,243 @@ +// @vitest-environment jsdom + +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, expect, it, vi } from "vitest"; +import { sdk } from "@/lib/sdk"; +import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; +import { CreateMachineDialog } from "./CreateMachineDialog"; + +const accessState = vi.hoisted(() => ({ ready: false })); +vi.mock("@/hooks/queries/system-queries", () => ({ + useSystemConfig: () => ({ + data: { + serverUrl: "http://127.0.0.1:19635", + serverAccess: { + defaultProviderId: "connect", + effectiveUrl: null, + providers: [ + { + id: "connect", + displayName: "bb connect", + availability: accessState.ready + ? { status: "available" } + : { status: "setup-required", message: "Pair bb connect" }, + }, + ], + }, + }, + }), +})); +vi.mock("@/lib/sdk", async (importOriginal) => ({ + ...(await importOriginal()), + sdk: { + projects: { list: vi.fn().mockResolvedValue([]) }, + hosts: { + submit: vi.fn(), + experimental_enrollmentCommand: vi.fn().mockResolvedValue({ + command: "bb machine enroll --bootstrap-env BB_ENROLLMENT", + expiresAt: Date.now() + 60_000, + }), + follow: vi.fn(), + cancel: vi.fn(), + createJoinCode: vi.fn(), + list: vi.fn().mockResolvedValue([]), + listProviders: vi.fn(), + }, + }, +})); +vi.mock("@/lib/ws", () => ({ + wsManager: { subscribe: vi.fn(), unsubscribe: vi.fn() }, +})); +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + accessState.ready = false; +}); + +it("lists alternative providers without duplicating the manual command flow", async () => { + accessState.ready = true; + vi.mocked(sdk.hosts.listProviders).mockResolvedValue( + ["manual", "ssh", "modal", "digitalocean", "tailscale"].map((id) => ({ + id, + displayName: id === "manual" ? "Manual machine setup" : id, + icon: null, + logoUrl: null, + pluginId: `machine-${id}`, + requires: { gitRemote: false }, + inputs: null, + acceptsEmptyInputs: true, + supportsSuspend: false, + environmentRow: null, + policy: { + idleSuspendMs: null, + retire: { after: "never" }, + removeRetryMs: 60_000, + }, + availability: { status: "available" }, + })), + ); + vi.mocked(sdk.projects.list).mockResolvedValue([ + { + id: "project-fixture", + kind: "standard", + name: "Fixture", + gitRemoteUrl: null, + createdAt: 1, + updatedAt: 1, + sources: [], + }, + ]); + const launch = { + id: "launch-manual", + machineProviderId: "manual", + projectId: null, + hostId: null, + phase: "creating" as const, + step: "Run the enrollment command shown in the picker", + message: null, + log: "", + cancelPending: false, + terminal: false, + }; + vi.mocked(sdk.hosts.submit).mockResolvedValue(launch); + vi.mocked(sdk.hosts.follow).mockImplementation(async (args) => { + args.onProgress?.(launch); + return new Promise(() => {}); + }); + vi.mocked(sdk.hosts.cancel).mockResolvedValue({ + ...launch, + phase: "cancelled", + }); + const { wrapper } = createQueryClientTestHarness(); + render( + + {}} /> + , + { wrapper }, + ); + await screen.findByRole("button", { name: "Copy command" }); + fireEvent.click( + screen.getByRole("button", { name: "Other ways to add a machine" }), + ); + await screen.findByRole("button", { name: "ssh" }); + expect( + screen.queryByRole("button", { name: "Manual machine setup" }), + ).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "ssh" })); + for (const name of ["ssh", "modal", "digitalocean", "tailscale"]) + expect(screen.getByRole("button", { name })).toBeDefined(); + await screen.findByRole("option", { name: "Fixture" }); + fireEvent.change(screen.getByRole("combobox", { name: "Machine project" }), { + target: { value: "project-fixture" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Create ssh machine" })); + expect((await screen.findByRole("status")).textContent).toContain( + "Run the enrollment command shown in the picker", + ); + expect( + await screen.findByText("bb machine enroll --bootstrap-env BB_ENROLLMENT"), + ).toBeTruthy(); + expect(sdk.hosts.experimental_enrollmentCommand).toHaveBeenCalledWith({ + id: "launch-manual", + scope: "launch", + signal: expect.any(AbortSignal), + }); + fireEvent.click(screen.getByRole("button", { name: "Cancel setup" })); + await waitFor(() => + expect(sdk.hosts.cancel).toHaveBeenCalledWith({ id: "launch-manual" }), + ); + expect(sdk.hosts.submit).toHaveBeenCalledWith( + expect.objectContaining({ projectId: "project-fixture" }), + ); + expect(sdk.hosts.createJoinCode).not.toHaveBeenCalled(); +}); + +it("shows preparation until the command is available, then waits for the machine", async () => { + accessState.ready = true; + let resolveCommand!: (value: { command: string; expiresAt: number }) => void; + vi.mocked(sdk.hosts.experimental_enrollmentCommand).mockReturnValue( + new Promise((resolve) => { + resolveCommand = resolve; + }), + ); + const { wrapper } = createQueryClientTestHarness(); + try { + render( + + {}} /> + , + { wrapper }, + ); + await waitFor(() => + expect(sdk.hosts.experimental_enrollmentCommand).toHaveBeenCalled(), + ); + expect(screen.getByText("Preparing command…")).toBeTruthy(); + expect( + screen.queryByText("Waiting for the machine to connect…"), + ).toBeNull(); + await act(async () => { + resolveCommand({ + command: "test-command", + expiresAt: Date.now() + 60_000, + }); + }); + await screen.findByRole("button", { name: "Copy command" }); + expect( + screen.getByText("Waiting for the machine to connect…"), + ).toBeTruthy(); + expect(screen.queryByText("Preparing command…")).toBeNull(); + expect(sdk.hosts.submit).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ machineProviderId: "manual", projectId: null }), + ); + expect( + screen.queryByRole("combobox", { name: "Machine project" }), + ).toBeNull(); + } finally { + accessState.ready = false; + } +}); + +it("keeps provider-specific setup accessible while default access is missing", async () => { + const { wrapper } = createQueryClientTestHarness(); + render( + + {}} /> + , + { wrapper }, + ); + expect( + ( + await screen.findByRole("link", { name: "Set up bb connect" }) + ).getAttribute("href"), + ).toBe("/settings/plugins/connect"); + expect( + screen + .getByRole("link", { name: "Other ways to connect" }) + .getAttribute("href"), + ).toBe("/settings/machines#advanced-machine-settings"); + expect(sdk.hosts.submit).not.toHaveBeenCalled(); + fireEvent.click( + await screen.findByRole("button", { name: "Other ways to add a machine" }), + ); + fireEvent.click(await screen.findByRole("button", { name: "tailscale" })); + expect( + screen.getByRole("combobox", { name: "Machine project" }), + ).toBeTruthy(); + expect(screen.queryByRole("link", { name: "Set up bb connect" })).toBeNull(); + fireEvent.click( + screen.getByRole("button", { name: "Create tailscale machine" }), + ); + await waitFor(() => + expect(sdk.hosts.submit).toHaveBeenCalledWith( + expect.objectContaining({ machineProviderId: "tailscale" }), + ), + ); +}); diff --git a/apps/app/src/components/dialogs/CreateMachineDialog.tsx b/apps/app/src/components/dialogs/CreateMachineDialog.tsx new file mode 100644 index 0000000000..9e3d647b16 --- /dev/null +++ b/apps/app/src/components/dialogs/CreateMachineDialog.tsx @@ -0,0 +1,531 @@ +import { useSystemConfig } from "@/hooks/queries/system-queries"; +import { isLocalOnlyUrl } from "@/lib/loopback-hostname"; +import { MachineEnrollmentCommand } from "./MachineEnrollmentCommand"; +import { useEffect, useRef, useState } from "react"; +import { Link } from "react-router-dom"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import type { JsonValue } from "@bb/domain"; +import type { PluginMachineProviderInputsChange } from "@get-bb/plugin-sdk"; +import type { SystemMachineProvider } from "@bb/server-contract"; +import { Button } from "@bb/shared-ui/button"; +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 { MachineProviderIcon } from "@/components/plugin/MachineProviderIcon"; +import { PluginSlotMount } from "@/components/plugin/PluginSlotMount"; +import { machineProviderInputsControlRequired } from "@/components/pickers/machine-provider-inputs"; +import { useHosts } from "@/hooks/queries/host-queries"; +import { useSystemMachineProviders } from "@/hooks/queries/machine-provider-queries"; +import { getPluginConfigurationRoutePath } from "@/lib/route-paths"; +import { sdk } from "@/lib/sdk"; +import { getMutationErrorMessage } from "@/lib/mutation-errors"; +import { usePluginSlots } from "@/lib/plugin-slots"; + +export function CreateMachineDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + return ( + + + {open && ( + + )} + + + ); +} + +function CreateMachineContent({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const config = useSystemConfig(); + const [otherOptions, setOtherOptions] = useState(false); + const autoStarted = useRef(false); + const access = config.data?.serverAccess; + const accessProvider = access?.providers.find( + (provider) => provider.id === access.defaultProviderId, + ); + const localUrl = + access?.defaultProviderId === "direct" && + access.effectiveUrl !== null && + isLocalOnlyUrl(access.effectiveUrl); + const serverUrl = + access?.defaultProviderId === "direct" + ? access.effectiveUrl + : config.data?.serverUrl; + const unreachableUrl = + serverUrl && isLocalOnlyUrl(serverUrl) ? serverUrl : null; + const accessReady = + accessProvider?.availability.status === "available" && !localUrl; + const createController = useRef(null); + const createKey = useRef(null); + const [commandReady, setCommandReady] = useState(false); + const [commandExpired, setCommandExpired] = useState(false); + const [progress, setProgress] = useState(""); + const [launchId, setLaunchId] = useState(null); + useEffect(() => { + if (!open) { + createController.current?.abort(); + createKey.current = null; + } + }, [open]); + useEffect(() => () => createController.current?.abort(), []); + const hostsQuery = useHosts(); + const projects = useQuery({ + queryKey: ["machine-create-projects"], + queryFn: () => sdk.projects.list(), + enabled: open, + }); + const [projectId, setProjectId] = useState(null); + const { providers: machineProviders } = useSystemMachineProviders(); + const alternativeProviders = + machineProviders?.filter((provider) => provider.id !== "manual") ?? []; + const machineProviderInputsSlots = usePluginSlots().machineProviderInputs; + const [selectedMachineProvider, setSelectedMachineProvider] = + useState(null); + const [machineInputs, setMachineInputs] = useState(null); + const [machineInputsBlocked, setMachineInputsBlocked] = useState< + string | null + >(null); + const machineInputsRegistration = + selectedMachineProvider === null + ? undefined + : machineProviderInputsSlots.find( + (slot) => + slot.machineProviderId === selectedMachineProvider.id && + slot.pluginId === selectedMachineProvider.pluginId, + ); + const MachineInputsComponent = machineInputsRegistration?.component; + const selectMachineProvider = (provider: SystemMachineProvider): void => { + createKey.current = null; + setSelectedMachineProvider(provider); + setMachineInputs( + provider.inputs === null ? null : provider.acceptsEmptyInputs ? {} : null, + ); + setMachineInputsBlocked(null); + }; + const handleMachineInputsChange = ( + next: PluginMachineProviderInputsChange, + ): void => { + createKey.current = null; + if (next.status === "blocked") { + setMachineInputsBlocked(next.reason); + return; + } + setMachineInputsBlocked(null); + setMachineInputs(next.value); + }; + const createMachine = useMutation({ + meta: { showErrorToast: false }, + mutationFn: async () => { + if (selectedMachineProvider?.id === "manual" && !accessReady) + throw new Error( + "Configure a reachable server address before adding a machine.", + ); + if (selectedMachineProvider === null) { + throw new Error("Select a machine provider."); + } + setCommandExpired(false); + setCommandReady(false); + setProgress(""); + setLaunchId(null); + const controller = new AbortController(); + createController.current = controller; + createKey.current ??= crypto.randomUUID(); + try { + const launch = await sdk.hosts.submit({ + key: createKey.current, + machineProviderId: selectedMachineProvider.id, + projectId, + inputs: machineInputs, + signal: controller.signal, + }); + setLaunchId(launch.id); + return await sdk.hosts.follow({ + id: launch.id, + signal: controller.signal, + onProgress: (status) => { + setProgress(status.step); + if (status.terminal) createKey.current = null; + }, + }); + } finally { + if (createController.current === controller) + createController.current = null; + } + }, + onSuccess: async () => { + await hostsQuery.refetch(); + onOpenChange(false); + }, + }); + + useEffect(() => { + if (!otherOptions && selectedMachineProvider === null) { + const manual = machineProviders?.find( + (provider) => provider.id === "manual", + ); + if (manual) selectMachineProvider(manual); + } + }, [machineProviders, otherOptions, selectedMachineProvider]); + useEffect(() => { + if ( + !otherOptions && + accessReady && + selectedMachineProvider?.id === "manual" && + selectedMachineProvider.availability?.status !== "unavailable" && + !autoStarted.current + ) { + autoStarted.current = true; + createMachine.mutate(); + } + }, [otherOptions, accessReady, selectedMachineProvider, createMachine]); + + const showOtherOptions = async () => { + if (launchId && createMachine.isPending) + await sdk.hosts.cancel({ id: launchId }); + createController.current?.abort(); + setOtherOptions(true); + setSelectedMachineProvider(null); + setLaunchId(null); + createKey.current = null; + createMachine.reset(); + }; + const providerOptionsLink = ( + + ); + + return ( + <> + + Add a machine + + {otherOptions + ? "Choose how to add your machine." + : !accessReady + ? "Pair a machine to run projects and threads on it." + : "Run this command on the machine you want to add. It installs bb and keeps the machine connected to this server."} + + +
+ {!otherOptions && !accessReady && ( +
+

+ {unreachableUrl + ? "Another machine cannot use this address." + : "Remote access isn't ready yet."} +

+

+ {unreachableUrl ? ( + <> + The pairing command would target{" "} + {unreachableUrl}, which + points to the machine that runs it, not to this bb. Set up + remote access first, then come back here to get a pairing + command that works from anywhere. + + ) : access?.defaultProviderId === "connect" ? ( + "Other machines need a reachable address for this server. Set up remote access first, then come back here to copy the pairing command." + ) : accessProvider?.availability.status !== "available" ? ( + (accessProvider?.availability.message ?? + "Choose a reachable server address in Advanced settings.") + ) : ( + "Checking remote access…" + )} +

+
+ + {access?.defaultProviderId === "connect" && ( + onOpenChange(false)} + className="text-xs text-subtle-foreground underline underline-offset-2 hover:text-foreground" + > + Other ways to connect + + )} +
+
+ )} + {!otherOptions && createMachine.isError && !commandExpired && ( +
+

+ {getMutationErrorMessage({ + error: createMachine.error, + fallbackMessage: "Couldn't prepare an enrollment command.", + })} +

+ +
+ )} + {!otherOptions && + accessReady && + !createMachine.isError && + !launchId && ( +

+ Preparing command… +

+ )} + {otherOptions && alternativeProviders.length > 0 ? ( +
+
+ {alternativeProviders.map((provider) => { + const unavailable = + provider.availability?.status === "unavailable"; + return ( + + ); + })} +
+ {selectedMachineProvider === null ? null : ( +
+ + {projects.error && ( +

+ Could not load projects: {projects.error.message} +

+ )} + + {machineInputsRegistration === undefined || + MachineInputsComponent === undefined ? null : ( + + + + )} + {selectedMachineProvider.availability?.status === + "setup-required" ? ( + + ) : ( + + )} + {machineInputsBlocked === null ? null : ( +

+ {machineInputsBlocked} +

+ )} + {createMachine.isError ? ( +

+ {getMutationErrorMessage({ + error: createMachine.error, + fallbackMessage: "Couldn't create the machine.", + })} +

+ ) : null} +
+ )} +
+ ) : null} + + {otherOptions && createMachine.isPending && progress ? ( +
+
+              {progress}
+            
+
+ ) : null} +
+ {open && launchId && (!otherOptions || createMachine.isPending) ? ( + setCommandExpired(true)} + onReadyChange={setCommandReady} + onRegenerate={ + otherOptions + ? undefined + : async () => { + await sdk.hosts.cancel({ id: launchId }); + createController.current?.abort(); + createKey.current = null; + createMachine.mutate(); + } + } + /> + ) : null} + {!otherOptions && !commandExpired && ( +
+

+ {createMachine.isPending && launchId + ? commandReady + ? "Waiting for the machine to connect…" + : "Preparing command…" + : ""} +

+ {alternativeProviders.length > 0 && providerOptionsLink} +
+ )} + {otherOptions && !createMachine.isPending && ( + + )} + + {otherOptions && createMachine.isPending && launchId ? ( + + ) : null} + + + + ); +} diff --git a/apps/app/src/components/dialogs/MachineEnrollmentCommand.test.tsx b/apps/app/src/components/dialogs/MachineEnrollmentCommand.test.tsx new file mode 100644 index 0000000000..16b844f1cd --- /dev/null +++ b/apps/app/src/components/dialogs/MachineEnrollmentCommand.test.tsx @@ -0,0 +1,113 @@ +// @vitest-environment jsdom + +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { sdk } from "@/lib/sdk"; +import { MachineEnrollmentCommand } from "./MachineEnrollmentCommand"; + +vi.mock("@/lib/sdk", () => ({ + sdk: { hosts: { experimental_enrollmentCommand: vi.fn() } }, +})); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +it("discards the private command when the server settles enrollment", async () => { + vi.mocked(sdk.hosts.experimental_enrollmentCommand) + .mockResolvedValueOnce({ + command: "bb machine enroll --bootstrap-env PRIVATE_BUNDLE", + expiresAt: Date.now() + 60_000, + }) + .mockResolvedValue({ command: null, expiresAt: null }); + render(); + expect( + await screen.findByText("bb machine enroll --bootstrap-env PRIVATE_BUNDLE"), + ).toBeTruthy(); + await waitFor( + () => + expect( + screen.queryByText("bb machine enroll --bootstrap-env PRIVATE_BUNDLE"), + ).toBeNull(), + { timeout: 3000 }, + ); + expect(screen.queryByRole("button", { name: "Copy command" })).toBeNull(); +}); + +it("aborts retrieval when the follower closes", async () => { + let signal: AbortSignal | undefined; + vi.mocked(sdk.hosts.experimental_enrollmentCommand).mockImplementation( + async (args) => { + signal = args.signal; + return new Promise(() => {}); + }, + ); + const view = render( + , + ); + expect(signal?.aborted).toBe(false); + view.unmount(); + expect(signal?.aborted).toBe(true); +}); + +it("counts down, keeps the expired state after the command disappears, and lets regeneration retry", async () => { + vi.useFakeTimers(); + try { + const expiresAt = Date.now() + 2000; + vi.mocked(sdk.hosts.experimental_enrollmentCommand).mockResolvedValue({ + command: "private-command", + expiresAt, + }); + const regenerate = vi + .fn() + .mockRejectedValueOnce(new Error("offline")) + .mockResolvedValue(undefined); + render( + , + ); + await act(async () => { + await Promise.resolve(); + }); + expect(screen.getByText("Expires in 0:02")).toBeTruthy(); + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + expect(screen.getByText("Code expired")).toBeTruthy(); + expect(screen.queryByText("private-command")).toBeNull(); + expect(screen.queryByRole("button", { name: "Copy command" })).toBeNull(); + vi.mocked(sdk.hosts.experimental_enrollmentCommand).mockResolvedValue({ + command: null, + expiresAt: null, + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + expect(screen.getByText("Code expired")).toBeTruthy(); + await act(async () => { + fireEvent.click( + screen.getByRole("button", { name: "Generate new command" }), + ); + }); + expect(screen.getByRole("alert").textContent).toContain("Try again"); + await act(async () => { + fireEvent.click( + screen.getByRole("button", { name: "Generate new command" }), + ); + }); + expect(regenerate).toHaveBeenCalledTimes(2); + } finally { + cleanup(); + vi.useRealTimers(); + } +}); diff --git a/apps/app/src/components/dialogs/MachineEnrollmentCommand.tsx b/apps/app/src/components/dialogs/MachineEnrollmentCommand.tsx new file mode 100644 index 0000000000..67ed762398 --- /dev/null +++ b/apps/app/src/components/dialogs/MachineEnrollmentCommand.tsx @@ -0,0 +1,133 @@ +import { useClipboardCopy } from "@/lib/clipboard"; +import { useEffect, useState } from "react"; +import { Button } from "@bb/shared-ui/button"; +import { sdk } from "@/lib/sdk"; + +export function MachineEnrollmentCommand({ + id, + scope, + onRegenerate, + onExpired, + onReadyChange, +}: { + id: string; + scope: "launch" | "thread"; + onRegenerate?: () => Promise; + onExpired?: () => void; + onReadyChange?: (ready: boolean) => void; +}) { + const [command, setCommand] = useState(null); + const [expiresAt, setExpiresAt] = useState(null); + const [now, setNow] = useState(Date.now); + const [regenerating, setRegenerating] = useState(false); + const [error, setError] = useState(null); + const remaining = + expiresAt === null + ? null + : Math.max(0, Math.ceil((expiresAt - now) / 1000)); + const expired = remaining === 0; + useEffect(() => { + if (expired) onExpired?.(); + }, [expired, onExpired]); + useEffect(() => { + onReadyChange?.(command !== null && !expired); + }, [command, expired, onReadyChange]); + const { copy, copied } = useClipboardCopy({ + text: expired ? "" : (command ?? ""), + }); + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(timer); + }, []); + useEffect(() => { + const controller = new AbortController(); + let timer: ReturnType; + setCommand(null); + setExpiresAt(null); + setRegenerating(false); + setError(null); + const poll = async () => { + try { + const result = await sdk.hosts.experimental_enrollmentCommand({ + id, + scope, + signal: controller.signal, + }); + if (!controller.signal.aborted) { + setCommand(result.command); + setExpiresAt( + (previous) => + result.expiresAt ?? + (previous !== null && previous <= Date.now() ? previous : null), + ); + setNow(Date.now()); + } + } catch { + if (!controller.signal.aborted) setCommand(null); + } + if (!controller.signal.aborted) + timer = setTimeout(() => void poll(), 1000); + }; + void poll(); + return () => { + controller.abort(); + clearTimeout(timer); + }; + }, [id, scope]); + if (command === null && !expired) return null; + return ( +
+ {expired ? ( +
+

Code expired

+

+ {onRegenerate + ? "Generate a new command to connect this machine." + : "Restart machine setup to generate a new command."} +

+
+ ) : ( +
+          {command}
+        
+ )} +
+ + {!expired && remaining !== null + ? `Expires in ${Math.floor(remaining / 60)}:${String(remaining % 60).padStart(2, "0")}` + : ""} + + {expired ? ( + onRegenerate && ( + + ) + ) : ( + + )} +
+ {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/apps/app/src/components/dialogs/ProjectPathDialog.tsx b/apps/app/src/components/dialogs/ProjectPathDialog.tsx index 2658126dc2..61fddb4aff 100644 --- a/apps/app/src/components/dialogs/ProjectPathDialog.tsx +++ b/apps/app/src/components/dialogs/ProjectPathDialog.tsx @@ -1,4 +1,3 @@ -import { MachineStatusDot } from "@/components/machines/MachineStatusDot"; import { useEffect, useId, useState, type FormEvent } from "react"; import { DropdownMenu, @@ -26,8 +25,9 @@ import { import { Input } from "@bb/shared-ui/input"; import { cn } from "@bb/shared-ui/lib/utils"; import { RemotePathBrowser } from "@/components/dialogs/RemotePathBrowser"; +import { MachineStatusDot } from "@/components/machines/MachineStatusDot"; import { usePointerCoarse } from "@bb/shared-ui/hooks/use-pointer-coarse"; -import { selectPersistentHosts } from "@/hooks/queries/host-queries"; +import { selectHosts } from "@/hooks/queries/host-queries"; export type ProjectPathDialogTarget = | { @@ -159,7 +159,7 @@ export function ProjectPathDialogContent({ const inputId = useId(); const isPointerCoarse = usePointerCoarse(); const machineOptions = - target.kind === "create" ? selectPersistentHosts(hosts) : undefined; + target.kind === "create" ? selectHosts(hosts) : undefined; const firstConnectedHostId = machineOptions?.find( (host) => host.status === "connected", )?.id; diff --git a/apps/app/src/components/machines/MachineLifecycleNotice.test.tsx b/apps/app/src/components/machines/MachineLifecycleNotice.test.tsx new file mode 100644 index 0000000000..d7cf9b348a --- /dev/null +++ b/apps/app/src/components/machines/MachineLifecycleNotice.test.tsx @@ -0,0 +1,85 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, expect, it, vi } from "vitest"; +import { MachineLifecycleNotice } from "./MachineLifecycleNotice"; +import { sdk } from "@/lib/sdk"; + +vi.mock("@/lib/sdk", () => ({ + sdk: { hosts: { experimental_lifecycle: vi.fn() } }, +})); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +it("keeps retention explicitly and delegates removal through the existing confirmation flow", async () => { + let kept = false; + vi.mocked(sdk.hosts.experimental_lifecycle).mockImplementation( + async (args) => { + if (args.keep !== undefined) kept = args.keep; + return { + phase: "suspended", + expiresAt: null, + maintenanceAt: null, + lastSnapshotAt: 1, + recoveryState: "healthy", + message: null, + retentionAt: Date.now() + 60_000, + keep: kept, + }; + }, + ); + const remove = vi.fn(); + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const view = render( + + + , + ); + fireEvent.click(await view.findByText("Keep machine")); + await view.findByText("Allow automatic deletion"); + expect(sdk.hosts.experimental_lifecycle).toHaveBeenCalledWith({ + hostId: "machine", + keep: true, + }); + fireEvent.click(view.getByText("Remove machine")); + expect(remove).toHaveBeenCalledTimes(1); + fireEvent.click(view.getByText("Allow automatic deletion")); + await waitFor(() => expect(kept).toBe(false)); + client.clear(); +}); + +it.each(["Compute disappeared before preservation completed", null])( + "shows preservation loss without lifecycle dates: %s", + async (message) => { + vi.mocked(sdk.hosts.experimental_lifecycle).mockResolvedValue({ + phase: "active", + expiresAt: null, + maintenanceAt: null, + lastSnapshotAt: null, + recoveryState: "lost-since-last-snapshot", + message, + retentionAt: null, + keep: true, + }); + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const view = render( + + {}} /> + , + ); + expect( + await view.findByText( + message ?? + "Machine preservation was lost. Explicit recovery is required.", + ), + ).toBeTruthy(); + expect(view.getByText("Remove machine")).toBeTruthy(); + client.clear(); + }, +); diff --git a/apps/app/src/components/machines/MachineLifecycleNotice.tsx b/apps/app/src/components/machines/MachineLifecycleNotice.tsx new file mode 100644 index 0000000000..d987bf6bbf --- /dev/null +++ b/apps/app/src/components/machines/MachineLifecycleNotice.tsx @@ -0,0 +1,87 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; +import { Button } from "@bb/shared-ui/button"; +import { sdk } from "@/lib/sdk"; + +export function MachineLifecycleNotice({ + hostId, + onRemove, +}: { + hostId: string; + onRemove: () => void; +}) { + const query = useQuery({ + queryKey: ["machine-lifecycle", hostId], + queryFn: () => sdk.hosts.experimental_lifecycle({ hostId }), + refetchInterval: 10_000, + }); + const keep = useMutation({ + mutationFn: (value: boolean) => + sdk.hosts.experimental_lifecycle({ hostId, keep: value }), + onSuccess: () => query.refetch(), + }); + const lifecycle = query.data; + if ( + !lifecycle || + (lifecycle.expiresAt === null && + lifecycle.retentionAt === null && + lifecycle.lastSnapshotAt === null && + lifecycle.message === null && + lifecycle.recoveryState !== "recoverable" && + lifecycle.recoveryState !== "lost-since-last-snapshot") + ) + return null; + const approaching = + lifecycle.maintenanceAt !== null && + lifecycle.maintenanceAt <= Date.now() + 15 * 60_000; + return ( +
+ {approaching && ( +

+ Preservation scheduled for{" "} + {new Date(lifecycle.maintenanceAt ?? 0).toLocaleString()}. Active + turns will be interrupted and terminals closed. +

+ )} + {lifecycle.message &&

{lifecycle.message}

} + {!lifecycle.message && + (lifecycle.recoveryState === "recoverable" || + lifecycle.recoveryState === "lost-since-last-snapshot") && ( +

+ {lifecycle.recoveryState === "lost-since-last-snapshot" + ? "Machine preservation was lost. Explicit recovery is required." + : "Machine preservation failed. Recovery is required."} +

+ )} + {lifecycle.lastSnapshotAt !== null && ( +

Last saved {new Date(lifecycle.lastSnapshotAt).toLocaleString()}.

+ )} + {lifecycle.retentionAt !== null && ( +

+ {lifecycle.keep + ? "Automatic deletion disabled. Retention date:" + : "Automatically deletes after retention:"}{" "} + {new Date(lifecycle.retentionAt).toLocaleString()}. +

+ )} +
+ + +
+ {keep.error && ( +

Could not update retention: {keep.error.message}

+ )} +
+ ); +} diff --git a/apps/app/src/components/machines/MachinePhaseBadge.test.ts b/apps/app/src/components/machines/MachinePhaseBadge.test.ts new file mode 100644 index 0000000000..16130d8fb3 --- /dev/null +++ b/apps/app/src/components/machines/MachinePhaseBadge.test.ts @@ -0,0 +1,33 @@ +import type { MachineLifecycle } from "@bb/domain"; +import { describe, expect, it } from "vitest"; +import { machinePhaseLabel } from "./MachinePhaseBadge"; + +function lifecycle( + phase: MachineLifecycle["phase"], + teardown: MachineLifecycle["teardown"] = null, +): MachineLifecycle { + return { phase, suspendedAt: null, retireAt: null, progress: null, teardown }; +} + +describe("machinePhaseLabel", () => { + it.each([ + ["active", null], + ["destroyed", null], + ["suspended", "Suspended"], + ["retiring", "Retiring"], + ] as const)("maps %s to %s", (phase, label) => { + expect(machinePhaseLabel(lifecycle(phase))).toBe(label); + }); + + it("prioritizes cleanup failure over retiring", () => { + expect( + machinePhaseLabel( + lifecycle("retiring", { + status: "failed", + attempt: 1, + message: "uninstall failed", + }), + ), + ).toBe("Cleanup failed"); + }); +}); diff --git a/apps/app/src/components/machines/MachinePhaseBadge.tsx b/apps/app/src/components/machines/MachinePhaseBadge.tsx new file mode 100644 index 0000000000..ebd6395ef7 --- /dev/null +++ b/apps/app/src/components/machines/MachinePhaseBadge.tsx @@ -0,0 +1,40 @@ +import type { MachineLifecycle } from "@bb/domain"; +import { cn } from "@bb/shared-ui/lib/utils"; + +interface MachinePhaseBadgeProps { + lifecycle: MachineLifecycle; +} + +export function machinePhaseLabel( + lifecycle: MachineLifecycle, +): "Suspended" | "Retiring" | "Cleanup failed" | null { + if ( + lifecycle.phase === "retiring" && + lifecycle.teardown?.status === "failed" + ) { + return "Cleanup failed"; + } + if (lifecycle.phase === "suspended") return "Suspended"; + if (lifecycle.phase === "retiring") return "Retiring"; + return null; +} + +export function MachinePhaseBadge({ lifecycle }: MachinePhaseBadgeProps) { + const label = machinePhaseLabel(lifecycle); + if (label === null) return null; + return ( + + {label} + + ); +} diff --git a/apps/app/src/components/machines/MachineProviderDetails.tsx b/apps/app/src/components/machines/MachineProviderDetails.tsx new file mode 100644 index 0000000000..34f496fc1e --- /dev/null +++ b/apps/app/src/components/machines/MachineProviderDetails.tsx @@ -0,0 +1,32 @@ +import { useQuery } from "@tanstack/react-query"; +import { sdk } from "@/lib/sdk"; + +export function MachineProviderDetails({ + hostId, + expanded = false, +}: { + hostId: string; + expanded?: boolean; +}) { + const query = useQuery({ + queryKey: ["machine-provider-details", hostId], + queryFn: ({ signal }) => + sdk.hosts.experimental_providerDetails({ hostId, signal }), + staleTime: 60_000, + refetchInterval: 60_000, + }); + if (query.error) + return ( +

+ Provider inventory unavailable +

+ ); + if (!query.data) return null; + return ( +
+

+ {query.data.summary} +

+
+ ); +} diff --git a/apps/app/src/components/pickers/EnvironmentPicker.test.tsx b/apps/app/src/components/pickers/EnvironmentPicker.test.tsx index 19435d2b62..e69194aa45 100644 --- a/apps/app/src/components/pickers/EnvironmentPicker.test.tsx +++ b/apps/app/src/components/pickers/EnvironmentPicker.test.tsx @@ -4,7 +4,10 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import type { Host, ProjectSource } from "@bb/domain"; import { makeHost } from "@bb/test-helpers/domain-fixtures"; import { HOST_DAEMON_PROTOCOL_VERSION } from "@bb/host-daemon-contract"; -import type { SystemEnvironmentProvider } from "@bb/server-contract"; +import type { + SystemEnvironmentProvider, + SystemMachineProvider, +} from "@bb/server-contract"; import { afterEach, describe, expect, it, vi } from "vitest"; import { EnvironmentPickerUI, @@ -86,6 +89,28 @@ const optionalInputsProvider: SystemEnvironmentProvider = { }, }; +const modalMachineProvider: SystemMachineProvider = { + id: "modal-sandbox", + displayName: "Modal sandbox", + icon: "Box", + logoUrl: null, + pluginId: "environment-modal-sandbox", + requires: { gitRemote: true }, + inputs: null, + acceptsEmptyInputs: true, + supportsSuspend: true, + environmentRow: { + displayName: "Modal sandbox", + environmentProviderId: checkoutProvider.id, + }, + policy: { + idleSuspendMs: 60_000, + retire: { after: "last-thread", graceMs: 60_000 }, + removeRetryMs: 60_000, + }, + availability: null, +}; + const host = makeHost({ id: "host_test", name: "Local host", @@ -359,6 +384,85 @@ describe("EnvironmentPickerUI", () => { host.id, ); }); + + it("offers Manual machine setup alongside opted-in shortcuts without a DigitalOcean shortcut", () => { + const providers = [ + "manual", + "ssh", + "modal", + "digitalocean", + "tailscale", + ].map((id) => ({ + ...modalMachineProvider, + id, + displayName: id === "manual" ? "Manual machine setup" : id, + requires: { gitRemote: false }, + supportsSuspend: false, + environmentRow: + id === "manual" || id === "digitalocean" + ? null + : { displayName: id, environmentProviderId: "project-checkout" }, + })); + const onSelect = vi.fn(); + render( + , + ); + fireEvent.pointerDown(screen.getByRole("button", { name: "Environment" }), { + button: 0, + }); + for (const name of ["Manual machine setup", "ssh", "modal", "tailscale"]) + expect( + screen.getByRole("menuitem", { name: new RegExp(name, "u") }), + ).toBeDefined(); + expect( + screen.queryByRole("menuitem", { name: /digitalocean/u }), + ).toBeNull(); + fireEvent.click( + screen.getByRole("menuitem", { name: /Manual machine setup/u }), + ); + expect(onSelect).toHaveBeenCalledWith(providers[0]); + }); + + it("puts machine-provider rows last after a separator", () => { + render( + , + ); + + fireEvent.pointerDown(screen.getByRole("button", { name: "Environment" }), { + button: 0, + }); + + const menuItems = screen.getAllByRole("menuitem"); + const modalItem = screen.getByRole("menuitem", { + name: /Modal sandbox/u, + }); + const modalGroup = modalItem.closest('[role="group"]'); + const separator = screen.getByRole("separator"); + expect(menuItems.at(-1)).toBe(modalItem); + expect(modalGroup?.previousElementSibling).toBe(separator); + }); }); describe("EnvironmentPickerUI multi-machine menu", () => { @@ -436,6 +540,38 @@ describe("EnvironmentPickerUI multi-machine menu", () => { expect(onSelectProvider).toHaveBeenCalledWith(checkoutProvider, studio.id); }); + it("includes provider-made hosts in environment picker machine sections", () => { + const providerHost = makeHost({ + id: "host_modal", + name: "Modal sandbox 3f9a", + machineProviderId: "modal-sandbox", + }); + render( + , + ); + + fireEvent.pointerDown(screen.getByRole("button", { name: "Environment" }), { + button: 0, + }); + + expect(screen.getByText("Mac Studio")).toBeTruthy(); + expect(screen.getByText("Modal sandbox 3f9a")).toBeTruthy(); + }); + it("does not show project checkout paths in machine headers", () => { renderMachineMenu(); diff --git a/apps/app/src/components/pickers/EnvironmentPicker.tsx b/apps/app/src/components/pickers/EnvironmentPicker.tsx index 735f45b5eb..f82f58bcd9 100644 --- a/apps/app/src/components/pickers/EnvironmentPicker.tsx +++ b/apps/app/src/components/pickers/EnvironmentPicker.tsx @@ -1,7 +1,11 @@ import { EnvironmentProviderIcon } from "@/components/plugin/EnvironmentProviderIcon"; +import { MachineProviderIcon } from "@/components/plugin/MachineProviderIcon"; import { useMemo } from "react"; import type { Host, ProjectSource } from "@bb/domain"; -import type { SystemEnvironmentProvider } from "@bb/server-contract"; +import type { + SystemEnvironmentProvider, + SystemMachineProvider, +} from "@bb/server-contract"; import { Icon, type IconName } from "@bb/shared-ui/icon"; import { findLocalPathProjectSourceForHost } from "@bb/domain"; import { pluginIconName } from "@/components/plugin/PluginIcon"; @@ -12,6 +16,7 @@ import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@bb/shared-ui/dropdown-menu"; import { @@ -36,8 +41,9 @@ import { encodeProviderValue, parseEnvironmentValue, } from "./environment-picker-value"; -import { selectPersistentHosts } from "@/hooks/queries/host-queries"; +import { selectHosts } from "@/hooks/queries/host-queries"; import { providerInputsControlRequired } from "./environment-provider-inputs"; +import { machineProviderInputsControlRequired } from "./machine-provider-inputs"; interface SelectedEnvironment { modeLabel: string; @@ -75,6 +81,10 @@ export interface EnvironmentPickerUIProps { provider: SystemEnvironmentProvider, hostId: string | null, ) => void; + machineProviders?: readonly SystemMachineProvider[]; + selectedMachineProviderId?: string | null; + machineInputsControlProviderIds?: ReadonlySet; + onSelectMachineProvider?: (provider: SystemMachineProvider) => void; } export const PROVIDER_INPUTS_CONTROL_MISSING_REASON = @@ -82,6 +92,22 @@ export const PROVIDER_INPUTS_CONTROL_MISSING_REASON = const NO_INPUTS_CONTROL_PROVIDER_IDS: ReadonlySet = new Set(); +function machineProviderDisabledReason( + provider: SystemMachineProvider, + inputsControlProviderIds: ReadonlySet, +): string | null { + if (provider.availability?.status === "unavailable") { + return provider.availability.message; + } + if ( + !inputsControlProviderIds.has(provider.id) && + machineProviderInputsControlRequired(provider) + ) { + return PROVIDER_INPUTS_CONTROL_MISSING_REASON; + } + return null; +} + function providerValueSelected( value: string, provider: SystemEnvironmentProvider, @@ -144,7 +170,7 @@ export function EnvironmentPickerUI({ disabled = false, className, defaultOpen, - modal, + modal = false, machines, onRequestMachineSetup, providers = [], @@ -153,12 +179,16 @@ export function EnvironmentPickerUI({ selectedProviderHostId = null, inputsControlProviderIds = NO_INPUTS_CONTROL_PROVIDER_IDS, onSelectProvider, + machineProviders: creatableMachineProviders = [], + selectedMachineProviderId = null, + machineInputsControlProviderIds = NO_INPUTS_CONTROL_PROVIDER_IDS, + onSelectMachineProvider, }: EnvironmentPickerUIProps) { const availableMachines = useMemo( () => machines === null || machines === undefined ? null - : { ...machines, hosts: selectPersistentHosts(machines.hosts) }, + : { ...machines, hosts: selectHosts(machines.hosts) }, [machines], ); const hostId = host?.id ?? null; @@ -200,7 +230,28 @@ export function EnvironmentPickerUI({ : undefined, [environmentProviders, parsed], ); + const selectedMachineProvider = useMemo( + () => + selectedMachineProviderId === null + ? undefined + : creatableMachineProviders.find( + (provider) => provider.id === selectedMachineProviderId, + ), + [creatableMachineProviders, selectedMachineProviderId], + ); + const selected = useMemo((): SelectedEnvironment => { + if (selectedMachineProvider !== undefined) { + return { + modeLabel: + selectedMachineProvider.environmentRow?.displayName ?? + selectedMachineProvider.displayName, + compactModeLabel: + selectedMachineProvider.environmentRow?.displayName ?? + selectedMachineProvider.displayName, + icon: pluginIconName(selectedMachineProvider.icon), + }; + } if (selectedProvider !== undefined && hostUnavailableReason === null) { const showsHost = selectedMachineName !== null; return { @@ -238,6 +289,7 @@ export function EnvironmentPickerUI({ host, selectedMachineName, selectedProvider, + selectedMachineProvider, ]); return ( @@ -260,7 +312,12 @@ export function EnvironmentPickerUI({ )} > - {selectedProvider === undefined ? ( + {selectedMachineProvider !== undefined ? ( + + ) : selectedProvider === undefined ? ( )} + {onSelectMachineProvider === undefined ? null : ( + + )} ); } +function MachineProviderEnvironmentOptions({ + providers, + selectedProviderId, + inputsControlProviderIds, + onSelect, +}: { + providers: readonly SystemMachineProvider[]; + selectedProviderId: string | null; + inputsControlProviderIds: ReadonlySet; + onSelect: (provider: SystemMachineProvider) => void; +}) { + const rows = providers.filter( + (provider) => provider.environmentRow !== null || provider.id === "manual", + ); + if (rows.length === 0) return null; + return ( + <> + + + New machine + {rows.map((provider) => { + const disabledReason = machineProviderDisabledReason( + provider, + inputsControlProviderIds, + ); + const description = + provider.availability?.status === "setup-required" + ? provider.availability.message + : (disabledReason ?? undefined); + return ( + onSelect(provider)} + /> + ); + })} + + + ); +} + interface EnvironmentOptionsSectionProps { hostId: string | null; hostName: string | null; @@ -488,12 +601,24 @@ function MachineSection({ }: MachineSectionProps) { const connected = host.status === "connected"; const hostProviders = machineProviders; + const selectable = + connected || + (host.machineProviderId !== null && host.lifecycle.phase === "suspended"); return ( {host.name} + {host.machineProviderId ? ( + + {host.lifecycle.phase === "suspended" + ? "paused" + : host.lifecycle.phase === "active" && connected + ? "running" + : host.lifecycle.phase} + + ) : null} {isThisMachine ? ( this machine ) : null} @@ -530,7 +655,7 @@ function MachineSection({ providerValueSelected(value, provider) && selectedProviderHostId === host.id } - disabled={!connected || disabledReason !== null} + disabled={!selectable || disabledReason !== null} onSelect={() => onSelectProvider(provider, host.id)} /> ); @@ -625,3 +750,58 @@ function EnvironmentMenuItem({ ); } + +function MachineProviderMenuItem({ + provider, + label, + description, + selected, + onSelect, + disabled, +}: { + provider: SystemMachineProvider; + label: string; + description?: string; + selected: boolean; + onSelect: () => void; + disabled: boolean; +}) { + return ( + { + if (!disabled) onSelect(); + }} + className={cn( + "flex items-start justify-between gap-3 whitespace-normal", + LIST_HOVER_TRANSITION, + )} + > + + + + {label} + {description === undefined ? null : ( + + {description} + + )} + + + + + ); +} diff --git a/apps/app/src/components/pickers/MachinePicker.test.tsx b/apps/app/src/components/pickers/MachinePicker.test.tsx index 4b2a02e520..58cdeac305 100644 --- a/apps/app/src/components/pickers/MachinePicker.test.tsx +++ b/apps/app/src/components/pickers/MachinePicker.test.tsx @@ -100,4 +100,20 @@ describe("MachinePickerUI", () => { screen.getByRole("button", { name: "Machine" }).textContent, ).toContain("MacBook Pro"); }); + + it("includes provider-made hosts in machine pickers", () => { + renderMachineMenu({ + hosts: [ + thisMachine, + studio, + makeHost({ + id: "host_modal", + name: "Modal sandbox 3f9a", + machineProviderId: "modal-sandbox", + }), + ], + }); + + expect(screen.getByText("Modal sandbox 3f9a")).toBeTruthy(); + }); }); diff --git a/apps/app/src/components/pickers/MachinePicker.tsx b/apps/app/src/components/pickers/MachinePicker.tsx index d7843a3932..90cbef5cba 100644 --- a/apps/app/src/components/pickers/MachinePicker.tsx +++ b/apps/app/src/components/pickers/MachinePicker.tsx @@ -1,4 +1,3 @@ -import { MachineStatusDot } from "@/components/machines/MachineStatusDot"; import { useMemo } from "react"; import type { Host } from "@bb/domain"; import { Icon } from "@bb/shared-ui/icon"; @@ -15,10 +14,8 @@ import { COARSE_POINTER_ICON_SIZE_CLASS, } from "@bb/shared-ui/coarse-pointer-sizing"; import { LIST_HOVER_TRANSITION } from "@bb/shared-ui/motion"; -import { - selectPersistentHosts, - selectPrimaryHost, -} from "@/hooks/queries/host-queries"; +import { MachineStatusDot } from "@/components/machines/MachineStatusDot"; +import { selectHosts, selectPrimaryHost } from "@/hooks/queries/host-queries"; import { formatRelativeTime } from "@/lib/relative-time"; import { cn } from "@bb/shared-ui/lib/utils"; import { formatHostUpdateStatus } from "@/lib/host-update-status"; @@ -56,7 +53,7 @@ export function MachinePickerUI({ className, modal, }: MachinePickerUIProps) { - const availableHosts = useMemo(() => selectPersistentHosts(hosts), [hosts]); + const availableHosts = useMemo(() => selectHosts(hosts), [hosts]); const selectedHost = useMemo( () => availableHosts.find((host) => host.id === selectedHostId) ?? diff --git a/apps/app/src/components/pickers/machine-provider-inputs.ts b/apps/app/src/components/pickers/machine-provider-inputs.ts new file mode 100644 index 0000000000..c3630195cc --- /dev/null +++ b/apps/app/src/components/pickers/machine-provider-inputs.ts @@ -0,0 +1,7 @@ +import type { SystemMachineProvider } from "@bb/server-contract"; + +export function machineProviderInputsControlRequired( + provider: SystemMachineProvider, +): boolean { + return provider.inputs !== null && !provider.acceptsEmptyInputs; +} diff --git a/apps/app/src/components/plugin/MachineProviderIcon.tsx b/apps/app/src/components/plugin/MachineProviderIcon.tsx new file mode 100644 index 0000000000..7ad98c5214 --- /dev/null +++ b/apps/app/src/components/plugin/MachineProviderIcon.tsx @@ -0,0 +1,25 @@ +import type { SystemMachineProvider } from "@bb/server-contract"; +import { Icon } from "@bb/shared-ui/icon"; +import { getProviderIconInfo } from "@/lib/provider-icon"; +import { pluginIconName } from "./PluginIcon"; + +export function MachineProviderIcon({ + provider, + className, +}: { + provider: SystemMachineProvider; + className?: string; +}) { + if (provider.icon === null) return null; + const info = getProviderIconInfo(provider.id, { + logoUrl: provider.logoUrl, + displayName: provider.displayName, + icon: { glyph: provider.icon }, + }); + const ProviderIcon = info?.icon; + return ProviderIcon === undefined ? ( + + ) : ( + + ); +} diff --git a/apps/app/src/components/plugin/PluginBranchPicker.tsx b/apps/app/src/components/plugin/PluginBranchPicker.tsx index 9844571c3d..1cd074e98a 100644 --- a/apps/app/src/components/plugin/PluginBranchPicker.tsx +++ b/apps/app/src/components/plugin/PluginBranchPicker.tsx @@ -1,5 +1,5 @@ import { useCallback, useState } from "react"; -import type { BranchPickerProps } from "@get-bb/plugin-sdk"; +import type { ExperimentalBranchPickerProps } from "@get-bb/plugin-sdk"; import { BranchPicker } from "@/components/pickers/BranchPicker"; import { usePluginBranches, @@ -14,7 +14,7 @@ export function PluginBranchPicker({ label, placeholder, disabled, -}: BranchPickerProps) { +}: ExperimentalBranchPickerProps) { const [searchQuery, setSearchQuery] = useState(""); const { branches, remoteBranches, isLoading, refresh } = usePluginBranches({ hostId, diff --git a/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx b/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx index 7afb1fbc7a..a19cc9078b 100644 --- a/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx +++ b/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx @@ -30,6 +30,7 @@ import type { import type { SystemEnvironmentProvider } from "@bb/server-contract"; import { NewThreadComposer, + resolveSubmittedExecutionSources, type NewThreadComposerState, } from "@/components/promptbox/NewThreadComposer"; import { @@ -81,6 +82,10 @@ vi.mock("@/hooks/queries/environment-provider-queries", () => ({ ) => new Map(hostIds.map((hostId) => [hostId, mocks.environmentProviders])), })); +vi.mock("@/hooks/queries/machine-provider-queries", () => ({ + useSystemMachineProviders: () => ({ providers: [] }), +})); + vi.mock("@/lib/sdk", () => ({ sdk: { projects: { attachments: { copy: mocks.copyAttachments } } }, })); @@ -152,7 +157,7 @@ vi.mock("@/hooks/queries/host-queries", () => ({ useHosts: () => ({ data: [{ id: "host_1", name: "Machine" }], }), - selectPersistentHosts: (hosts: T[] | undefined) => hosts ?? [], + selectHosts: (hosts: T[] | undefined) => hosts ?? [], selectPrimaryHost: ( hosts: Array<{ id: string }> | undefined, primaryHostId: string | null, @@ -1812,3 +1817,32 @@ describe("NewThreadComposer environment providers", () => { }); }); }); + +it("submits the visible model explicitly when a new machine cannot resolve a catalog default", () => { + expect( + resolveSubmittedExecutionSources( + { + type: "provider", + environmentProviderId: "project-checkout", + inputs: null, + machine: { + type: "new", + machineProviderId: "modal-sandbox", + inputs: null, + }, + }, + {}, + ), + ).toEqual({ model: "explicit" }); + expect( + resolveSubmittedExecutionSources( + { + type: "provider", + environmentProviderId: "project-checkout", + inputs: null, + machine: { type: "existing", hostId: "host_1" }, + }, + {}, + ), + ).toEqual({}); +}); diff --git a/apps/app/src/components/plugin/new-thread-environment-seed.test.ts b/apps/app/src/components/plugin/new-thread-environment-seed.test.ts index d9a3a6bc10..002e8d51b8 100644 --- a/apps/app/src/components/plugin/new-thread-environment-seed.test.ts +++ b/apps/app/src/components/plugin/new-thread-environment-seed.test.ts @@ -224,6 +224,20 @@ describe("newThreadEnvironmentArgsToSeed round trip", () => { expect(roundTrip(environment)).toEqual(environment); }); + it("a provider on a new machine keeps its inputs verbatim", () => { + const environment: CreateThreadEnvironmentArgs = { + type: "provider", + environmentProviderId: "container", + machine: { + type: "new", + machineProviderId: "container-machine", + inputs: { target: "primary" }, + }, + inputs: { image: "custom:latest" }, + }; + expect(roundTrip(environment)).toEqual(environment); + }); + it("an unregistered provider resolves to no environment", () => { const seed = newThreadEnvironmentArgsToSeed({ type: "provider", diff --git a/apps/app/src/components/plugin/new-thread-environment-seed.ts b/apps/app/src/components/plugin/new-thread-environment-seed.ts index 02b01b0414..a8cb3fff96 100644 --- a/apps/app/src/components/plugin/new-thread-environment-seed.ts +++ b/apps/app/src/components/plugin/new-thread-environment-seed.ts @@ -65,7 +65,10 @@ export function newThreadEnvironmentArgsToSeed( return { selectionValue: encodeProviderValue(environment.environmentProviderId), providerMachine: environment.machine, - providerHostId: environment.machine.hostId, + providerHostId: + environment.machine.type === "existing" + ? environment.machine.hostId + : null, providerInputs: environment.inputs, }; } diff --git a/apps/app/src/components/promptbox/NewThreadComposer.tsx b/apps/app/src/components/promptbox/NewThreadComposer.tsx index d96e82bf86..1e9f590f02 100644 --- a/apps/app/src/components/promptbox/NewThreadComposer.tsx +++ b/apps/app/src/components/promptbox/NewThreadComposer.tsx @@ -22,11 +22,13 @@ import { import type { NewThreadRequest, PluginEnvironmentProviderInputsChange, + PluginMachineProviderInputsChange, } from "@get-bb/plugin-sdk"; import type { CreateExecutionInputSources, SidebarBootstrapResponse, SystemEnvironmentProvider, + SystemMachineProvider, SystemExecutionOptionsModelLoadError, } from "@bb/server-contract"; import type { ProjectSelectorCreateProjectConfig } from "@/components/pickers/ProjectSelector"; @@ -36,6 +38,7 @@ import { parseEnvironmentValue, } from "@/components/pickers/environment-picker-value"; import { providerInputsControlRequired } from "@/components/pickers/environment-provider-inputs"; +import { machineProviderInputsControlRequired } from "@/components/pickers/machine-provider-inputs"; import { formatModelLoadErrorText } from "@/components/pickers/model-load-error-message"; import { NewThreadPromptBox, @@ -57,8 +60,9 @@ import { useSystemEnvironmentProviders, useSystemEnvironmentProvidersByHost, } from "@/hooks/queries/environment-provider-queries"; +import { useSystemMachineProviders } from "@/hooks/queries/machine-provider-queries"; import { - selectPersistentHosts, + selectHosts, selectPrimaryHost, useHosts, } from "@/hooks/queries/host-queries"; @@ -176,6 +180,15 @@ export interface NewThreadComposerState { renderPromptBox: (options: NewThreadComposerPromptOptions) => ReactNode; } +export function resolveSubmittedExecutionSources( + environment: NewThreadRequest["environment"], + sources: CreateExecutionInputSources, +): CreateExecutionInputSources { + return environment.type === "provider" && environment.machine?.type === "new" + ? { ...sources, model: "explicit" } + : sources; +} + export interface NewThreadComposerSubmission extends NewThreadRequest { sendAt?: number; } @@ -415,7 +428,7 @@ export function NewThreadComposer({ const hostsQuery = useHosts(); const availableHosts = useMemo( - () => selectPersistentHosts(hostsQuery.data), + () => selectHosts(hostsQuery.data), [hostsQuery.data], ); const systemConfigQuery = useSystemConfig(); @@ -483,6 +496,10 @@ export function NewThreadComposer({ environmentProvidersByHostId, ], ); + const { providers: machineProviders } = useSystemMachineProviders( + isProjectless ? {} : { projectId }, + ); + const seedSignature = JSON.stringify([ resetKey ?? null, seed?.providerId ?? null, @@ -543,6 +560,7 @@ export function NewThreadComposer({ ? environmentSeed.providerMachine : null; const candidate = picked ?? seeded; + if (candidate?.type === "new") return { provider, machine: candidate }; if (usable(candidate?.hostId ?? null)) { return { provider, machine: candidate }; } @@ -743,6 +761,37 @@ export function NewThreadComposer({ }, [changeEnvironment], ); + const handleSelectMachineProvider = useCallback( + (provider: SystemMachineProvider) => { + const current = parseEnvironmentValue(environmentSelectionValue); + const environmentProviderId = + provider.environmentRow?.environmentProviderId ?? + (current?.type === "provider" + ? current.environmentProviderId + : environmentProviders?.find((entry) => + isProjectless + ? entry.requires.projectless + : entry.id === PROJECT_CHECKOUT_ENVIRONMENT_PROVIDER_ID, + )?.id); + if (environmentProviderId === undefined) return; + changeEnvironment(encodeProviderValue(environmentProviderId), { + type: "new", + machineProviderId: provider.id, + inputs: + provider.inputs === null + ? null + : provider.acceptsEmptyInputs + ? {} + : null, + }); + }, + [ + changeEnvironment, + environmentSelectionValue, + environmentProviders, + isProjectless, + ], + ); const effectiveEnvironmentValue = useMemo( () => resolveRootComposeEffectiveEnvironmentValue({ @@ -778,6 +827,12 @@ export function NewThreadComposer({ const providerMachine = providerSelection?.machine ?? null; const providerHostId = providerMachine?.type === "existing" ? providerMachine.hostId : null; + const selectedMachineProvider = + providerMachine?.type === "new" + ? machineProviders?.find( + (provider) => provider.id === providerMachine.machineProviderId, + ) + : undefined; const selectedScopedEnvironmentProvider = useMemo(() => { if (selectedEnvironmentProvider === undefined) return undefined; if (providerHostId === null) return undefined; @@ -790,9 +845,12 @@ export function NewThreadComposer({ selectedEnvironmentProvider, ]); const setupRequiredPluginId = - selectedScopedEnvironmentProvider?.availability?.status === "setup-required" - ? selectedScopedEnvironmentProvider.pluginId - : null; + selectedMachineProvider?.availability?.status === "setup-required" + ? selectedMachineProvider.pluginId + : selectedScopedEnvironmentProvider?.availability?.status === + "setup-required" + ? selectedScopedEnvironmentProvider.pluginId + : null; const gitCheckoutProviderSelected = selectedEnvironmentProvider?.requires.gitCheckout === true; const projectCheckoutProviderSelected = @@ -947,6 +1005,12 @@ export function NewThreadComposer({ : null; const environmentProviderInputsSlot = useMemo(() => { if (environmentProviderInputsRegistration === undefined) return null; + if ( + providerMachine?.type === "new" && + selectedEnvironmentProvider?.id === + PROJECT_CHECKOUT_ENVIRONMENT_PROVIDER_ID + ) + return null; const InputsComponent = environmentProviderInputsRegistration.component; return ( { + const pluginIdByProviderId = new Map( + (machineProviders ?? []).map((provider) => [ + provider.id, + provider.pluginId, + ]), + ); + return new Set( + machineProviderInputsSlots + .filter( + (slot) => + pluginIdByProviderId.get(slot.machineProviderId) === slot.pluginId, + ) + .map((slot) => slot.machineProviderId), + ); + }, [machineProviderInputsSlots, machineProviders]); + const machineProviderInputsRegistration = useMemo(() => { + if (selectedMachineProvider === undefined) { + return undefined; + } + return machineProviderInputsSlots.find( + (slot) => + slot.machineProviderId === selectedMachineProvider.id && + slot.pluginId === selectedMachineProvider.pluginId, + ); + }, [machineProviderInputsSlots, selectedMachineProvider]); + const machineProviderInputsScopeKey = `${projectId}\0${selectedMachineProvider?.id ?? ""}`; + const [machineProviderInputsOverride, setMachineProviderInputsOverride] = + useState<{ scopeKey: string; value: JsonValue } | null>(null); + const [machineProviderInputsBlocked, setMachineProviderInputsBlocked] = + useState<{ scopeKey: string; reason: string } | null>(null); + const handleMachineProviderInputsChange = useCallback( + (next: PluginMachineProviderInputsChange) => { + if (next.status === "blocked") { + setMachineProviderInputsBlocked({ + scopeKey: machineProviderInputsScopeKey, + reason: next.reason, + }); + return; + } + setMachineProviderInputsBlocked(null); + setMachineProviderInputsOverride({ + scopeKey: machineProviderInputsScopeKey, + value: next.value, + }); + }, + [machineProviderInputsScopeKey], + ); + const activeMachineInputsOverride = + machineProviderInputsOverride?.scopeKey === machineProviderInputsScopeKey + ? machineProviderInputsOverride + : null; + const activeMachineInputsBlocked = + machineProviderInputsBlocked?.scopeKey === machineProviderInputsScopeKey + ? machineProviderInputsBlocked + : null; + const machineProviderTakesInputs = selectedMachineProvider?.inputs !== null; + const machineInputsControlRequired = + selectedMachineProvider !== undefined && + machineProviderInputsControlRequired(selectedMachineProvider); + const submissionMachineInputs = useMemo( + () => + selectedMachineProvider === undefined || !machineProviderTakesInputs + ? null + : (activeMachineInputsOverride?.value ?? + (providerMachine?.type === "new" ? providerMachine.inputs : null) ?? + (machineProviderInputsRegistration === undefined && + !machineInputsControlRequired + ? {} + : null)), + [ + activeMachineInputsOverride?.value, + machineInputsControlRequired, + machineProviderInputsRegistration, + machineProviderTakesInputs, + providerMachine, + selectedMachineProvider, + ], + ); + const machineProviderInputsBlocker = + selectedMachineProvider === undefined || !machineProviderTakesInputs + ? null + : activeMachineInputsBlocked !== null + ? activeMachineInputsBlocked.reason + : machineProviderInputsRegistration === undefined && + machineInputsControlRequired + ? `${selectedMachineProvider.displayName} needs its plugin's control` + : submissionMachineInputs === null + ? `Configure ${selectedMachineProvider.displayName}` + : null; + const machineProviderInputsSlot = useMemo(() => { + if (machineProviderInputsRegistration === undefined) return null; + const MachineProviderInputsComponent = + machineProviderInputsRegistration.component; + return ( + + + + ); + }, [ + handleMachineProviderInputsChange, + isProjectless, + machineProviderInputsRegistration, + projectId, + selectedProviderId, + submissionMachineInputs, + ]); + const submissionProviderMachine = useMemo( + () => + providerMachine?.type === "new" + ? { ...providerMachine, inputs: submissionMachineInputs } + : providerMachine, + [providerMachine, submissionMachineInputs], + ); + const selectedEnvironment = useMemo( () => resolveRootComposeThreadEnvironment({ environmentValue: effectiveEnvironmentValue, projectId, environmentProviders, - providerMachine: providerMachine, + providerMachine: submissionProviderMachine, providerInputs: submissionProviderInputs, }), [ @@ -985,7 +1176,7 @@ export function NewThreadComposer({ environmentProviders, projectId, submissionProviderInputs, - providerMachine, + submissionProviderMachine, ], ); @@ -1237,7 +1428,8 @@ export function NewThreadComposer({ (selectionScope === "new-thread" ? seed?.environment : undefined) ?? null; const submitDisabledReason = resolveNewThreadSubmitDisabledReason({ - environmentProviderInputsBlocker: environmentProviderInputsBlocker, + environmentProviderInputsBlocker: + machineProviderInputsBlocker ?? environmentProviderInputsBlocker, isCopyingAttachments, isLoadingModels, isSubmitting, @@ -1288,7 +1480,10 @@ export function NewThreadComposer({ reasoningLevel, permissionMode, ...(supportsServiceTier && serviceTier ? { serviceTier } : {}), - executionInputSources: sources, + executionInputSources: resolveSubmittedExecutionSources( + submissionEnvironment, + sources, + ), environment: submissionEnvironment, input, ...(sendAt === null ? {} : { sendAt }), @@ -1470,6 +1665,10 @@ export function NewThreadComposer({ selectedProviderHostId: providerHostId, inputsControlProviderIds, onSelectProvider: handleSelectProvider, + machineProviders: machineProviders ?? [], + selectedMachineProviderId: selectedMachineProvider?.id ?? null, + machineInputsControlProviderIds, + onSelectMachineProvider: handleSelectMachineProvider, ...(!isProjectless && options.onRequestMachineSetup ? { onRequestMachineSetup: options.onRequestMachineSetup } : {}), @@ -1487,6 +1686,7 @@ export function NewThreadComposer({ supported: supportsPermissionModeSelection, }, environmentProviderInputsSlot, + machineProviderInputsSlot, banner: options.banner, header: options.header, }} @@ -1556,6 +1756,7 @@ export function NewThreadComposer({ handleProviderChange, handleReasoningChange, handleSelectProvider, + handleSelectMachineProvider, handleServiceTierChange, handleSubmit, handleWorktreeChange, @@ -1593,8 +1794,12 @@ export function NewThreadComposer({ supportsServiceTier, submitDisabledReason, environmentProviderInputsSlot, + machineProviderInputsSlot, environmentProvidersByHostId, inputsControlProviderIds, + machineInputsControlProviderIds, + machineProviders, + selectedMachineProvider, providerHostId, textEffects, serviceTierFastLabel, diff --git a/apps/app/src/components/promptbox/NewThreadEnvironmentOptions.stories.tsx b/apps/app/src/components/promptbox/NewThreadEnvironmentOptions.stories.tsx index bc0ada64fb..79c047bba3 100644 --- a/apps/app/src/components/promptbox/NewThreadEnvironmentOptions.stories.tsx +++ b/apps/app/src/components/promptbox/NewThreadEnvironmentOptions.stories.tsx @@ -17,6 +17,7 @@ import { STORY_BRANCH_OPTIONS, STORY_PROJECTS, STORY_ENVIRONMENT_PROVIDERS, + STORY_MACHINE_PROVIDERS, STORY_PROJECT_SOURCES, STORY_WORKTREE_OPTIONS, } from "../../../.ladle/story-fixtures"; @@ -68,6 +69,8 @@ function EnvironmentOptionsStrip({ projectless={projectless} selectedProviderHostId={HOST_IDS.local} onSelectProvider={noop} + machineProviders={STORY_MACHINE_PROVIDERS} + onSelectMachineProvider={noop} muted modal={false} {...environment} diff --git a/apps/app/src/components/promptbox/NewThreadPromptBox.test.tsx b/apps/app/src/components/promptbox/NewThreadPromptBox.test.tsx index 81ba70ca98..bc25c57ba5 100644 --- a/apps/app/src/components/promptbox/NewThreadPromptBox.test.tsx +++ b/apps/app/src/components/promptbox/NewThreadPromptBox.test.tsx @@ -5,7 +5,10 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import type { Host } from "@bb/domain"; import { makeHost } from "@bb/test-helpers/domain-fixtures"; import { afterEach, describe, expect, it, vi } from "vitest"; -import type { SystemEnvironmentProvider } from "@bb/server-contract"; +import type { + SystemEnvironmentProvider, + SystemMachineProvider, +} from "@bb/server-contract"; import { ProjectlessEnvSlot, ProjectlessMachineSlot, @@ -203,13 +206,37 @@ describe("ProjectlessEnvSlot", () => { inputs: null, }; + const modalMachineProvider: SystemMachineProvider = { + id: "modal-sandbox", + displayName: "Modal sandbox", + icon: "Box", + logoUrl: null, + pluginId: "environment-modal-sandbox", + requires: { gitRemote: true }, + inputs: null, + acceptsEmptyInputs: true, + supportsSuspend: true, + environmentRow: { + displayName: "Modal sandbox", + environmentProviderId: personalProvider.id, + }, + policy: { + idleSuspendMs: 60_000, + retire: { after: "last-thread", graceMs: 60_000 }, + removeRetryMs: 60_000, + }, + availability: null, + }; + function makeEnvironment(overrides: { value?: string; providers?: readonly SystemEnvironmentProvider[]; + machineProviders?: readonly SystemMachineProvider[]; onSelectProvider?: ( provider: SystemEnvironmentProvider, hostId: string | null, ) => void; + onSelectMachineProvider?: (provider: SystemMachineProvider) => void; }) { return { value: overrides.value ?? "provider:personal-workspace", @@ -225,6 +252,8 @@ describe("ProjectlessEnvSlot", () => { providers: overrides.providers ?? [personalProvider], selectedProviderHostId: host.id, onSelectProvider: overrides.onSelectProvider ?? vi.fn(), + machineProviders: overrides.machineProviders, + onSelectMachineProvider: overrides.onSelectMachineProvider, }; } @@ -274,6 +303,26 @@ describe("ProjectlessEnvSlot", () => { expect(screen.queryByText("Modal sandbox")).toBeNull(); }); + it("shows machine-provider sugar after the personal workspace option", () => { + render( + , + ); + + expect(screen.queryByRole("button", { name: "Machine" })).toBeNull(); + const trigger = screen.getByRole("button", { name: "Environment" }); + fireEvent.pointerDown(trigger, { button: 0 }); + const items = screen.getAllByRole("menuitem"); + expect(items[0]?.textContent).toContain("Personal workspace"); + expect(items.at(-1)?.textContent).toContain("Modal sandbox"); + }); + it("shows the reused environment instead of the machine slot when a thread reuses one", () => { render( diff --git a/apps/app/src/components/promptbox/NewThreadPromptBox.tsx b/apps/app/src/components/promptbox/NewThreadPromptBox.tsx index cae81d65fb..783b40385d 100644 --- a/apps/app/src/components/promptbox/NewThreadPromptBox.tsx +++ b/apps/app/src/components/promptbox/NewThreadPromptBox.tsx @@ -56,7 +56,7 @@ import { type ReuseThreadOption, } from "@/components/pickers/ReuseEnvironmentPicker"; import { - selectPersistentHosts, + selectHosts, selectPrimaryHost, useHosts, } from "@/hooks/queries/host-queries"; @@ -87,6 +87,10 @@ export interface NewThreadEnvironmentConfig { selectedProviderHostId?: string | null; inputsControlProviderIds?: ReadonlySet; onSelectProvider?: EnvironmentPickerUIProps["onSelectProvider"]; + machineProviders?: EnvironmentPickerUIProps["machineProviders"]; + selectedMachineProviderId?: string | null; + machineInputsControlProviderIds?: ReadonlySet; + onSelectMachineProvider?: EnvironmentPickerUIProps["onSelectMachineProvider"]; } export interface NewThreadWorktreeConfig { @@ -112,6 +116,7 @@ export interface NewThreadModeConfig { worktree: NewThreadWorktreeConfig; permission: ExecutionPermissionConfig; environmentProviderInputsSlot?: ReactNode; + machineProviderInputsSlot?: ReactNode; banner?: ReactNode; header?: ReactNode; } @@ -375,6 +380,7 @@ const DefaultNewThreadComposer = memo(function DefaultNewThreadComposer({ environmentProviderInputsSlot={ modeConfig.environmentProviderInputsSlot } + machineProviderInputsSlot={modeConfig.machineProviderInputsSlot} /> ) : ( )} @@ -406,12 +413,14 @@ interface ThreadEnvSlotProps { environment: NewThreadEnvironmentConfig; worktree: NewThreadWorktreeConfig; environmentProviderInputsSlot?: ReactNode; + machineProviderInputsSlot?: ReactNode; } export function ThreadEnvSlot({ environment, worktree, environmentProviderInputsSlot, + machineProviderInputsSlot, }: ThreadEnvSlotProps) { const parsedEnvironment = useMemo( () => parseEnvironmentValue(environment.value), @@ -443,6 +452,12 @@ export function ThreadEnvSlot({ selectedProviderHostId={environment.selectedProviderHostId} inputsControlProviderIds={environment.inputsControlProviderIds} onSelectProvider={environment.onSelectProvider} + machineProviders={environment.machineProviders} + selectedMachineProviderId={environment.selectedMachineProviderId} + machineInputsControlProviderIds={ + environment.machineInputsControlProviderIds + } + onSelectMachineProvider={environment.onSelectMachineProvider} className="shrink-0" muted /> @@ -458,6 +473,10 @@ export function ThreadEnvSlot({ {selectedProvider !== undefined && selectedProvider.inputs !== null ? environmentProviderInputsSlot : null} + {environment.selectedMachineProviderId === undefined || + environment.selectedMachineProviderId === null + ? null + : machineProviderInputsSlot} ); } @@ -466,12 +485,14 @@ interface ProjectlessEnvSlotProps { environment: NewThreadEnvironmentConfig; worktree: NewThreadWorktreeConfig; environmentProviderInputsSlot?: ReactNode; + machineProviderInputsSlot?: ReactNode; } export function ProjectlessEnvSlot({ environment, worktree, environmentProviderInputsSlot, + machineProviderInputsSlot, }: ProjectlessEnvSlotProps) { const providers = (environment.providers ?? []).filter( (provider) => provider.requires.projectless, @@ -487,7 +508,15 @@ export function ProjectlessEnvSlot({ ) : undefined; const showReuseEnvironmentPicker = parsedEnvironment?.type === "reuse"; - if (providers.length <= 1 && !showReuseEnvironmentPicker) { + const hasMachineProviderEnvironmentOptions = + environment.onSelectMachineProvider !== undefined && + (environment.machineProviders?.length ?? 0) > 0; + + if ( + providers.length <= 1 && + !showReuseEnvironmentPicker && + !hasMachineProviderEnvironmentOptions + ) { return ; } @@ -506,6 +535,12 @@ export function ProjectlessEnvSlot({ selectedProviderHostId={environment.selectedProviderHostId} inputsControlProviderIds={environment.inputsControlProviderIds} onSelectProvider={environment.onSelectProvider} + machineProviders={environment.machineProviders} + selectedMachineProviderId={environment.selectedMachineProviderId} + machineInputsControlProviderIds={ + environment.machineInputsControlProviderIds + } + onSelectMachineProvider={environment.onSelectMachineProvider} className="shrink-0" muted /> @@ -521,6 +556,10 @@ export function ProjectlessEnvSlot({ {selectedProvider !== undefined && selectedProvider.inputs !== null ? environmentProviderInputsSlot : null} + {environment.selectedMachineProviderId === undefined || + environment.selectedMachineProviderId === null + ? null + : machineProviderInputsSlot} ); } @@ -534,7 +573,7 @@ export function ProjectlessMachineSlot({ }: ProjectlessMachineSlotProps) { const machines = environment.machines ?? null; const availableHosts = useMemo( - () => selectPersistentHosts(machines?.hosts), + () => selectHosts(machines?.hosts), [machines?.hosts], ); const parsedEnvironment = useMemo( @@ -590,6 +629,7 @@ interface NewThreadConnectedModeConfig { worktree: NewThreadWorktreeConfig; permission: ExecutionPermissionConfig; environmentProviderInputsSlot?: ReactNode; + machineProviderInputsSlot?: ReactNode; banner?: ReactNode; header?: ReactNode; } @@ -608,7 +648,7 @@ export function NewThreadPromptBox({ const { data: hosts } = useHosts(); const systemConfigQuery = useSystemConfig(); const primaryHostId = systemConfigQuery.data?.primaryHostId ?? null; - const availableHosts = useMemo(() => selectPersistentHosts(hosts), [hosts]); + const availableHosts = useMemo(() => selectHosts(hosts), [hosts]); const primaryHost = useMemo( () => selectPrimaryHost(availableHosts, primaryHostId), [availableHosts, primaryHostId], @@ -654,6 +694,7 @@ export function NewThreadPromptBox({ permission: threadConfig.permission, environmentProviderInputsSlot: threadConfig.environmentProviderInputsSlot, + machineProviderInputsSlot: threadConfig.machineProviderInputsSlot, banner: threadConfig.banner, header: threadConfig.header, }} diff --git a/apps/app/src/components/secondary-panel/ThreadMetadataContent.rows.stories.tsx b/apps/app/src/components/secondary-panel/ThreadMetadataContent.rows.stories.tsx index a43110373c..118cedf74a 100644 --- a/apps/app/src/components/secondary-panel/ThreadMetadataContent.rows.stories.tsx +++ b/apps/app/src/components/secondary-panel/ThreadMetadataContent.rows.stories.tsx @@ -144,7 +144,8 @@ export function Environment() { @@ -153,7 +154,8 @@ export function Environment() { diff --git a/apps/app/src/components/settings/MachineAccessSettings.test.tsx b/apps/app/src/components/settings/MachineAccessSettings.test.tsx new file mode 100644 index 0000000000..2b044d8f73 --- /dev/null +++ b/apps/app/src/components/settings/MachineAccessSettings.test.tsx @@ -0,0 +1,168 @@ +// @vitest-environment jsdom +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { makeSystemConfig } from "@/test/fixtures/system-config"; +import { MachineAccessSettings } from "./MachineAccessSettings"; + +const mocks = vi.hoisted(() => ({ + config: vi.fn(), + mutate: vi.fn(), + isPending: false, +})); +vi.mock("@/components/pickers/OptionPicker", () => ({ + OptionPicker: ({ + value, + onChange, + disabled, + }: { + value: string; + onChange: (value: string) => void; + disabled: boolean; + }) => ( + + ), +})); +vi.mock("@/hooks/queries/system-queries", () => ({ + useSystemConfig: mocks.config, +})); +vi.mock("@/hooks/mutations/settings-mutations", () => ({ + useUpdateGeneralSettings: () => ({ + isPending: mocks.isPending, + mutate: mocks.mutate, + }), +})); +afterEach(cleanup); +beforeEach(() => { + mocks.mutate.mockReset(); + mocks.isPending = false; +}); + +function show(defaultProviderId: string, paired = false) { + mocks.config.mockReturnValue({ + data: makeSystemConfig({ + serverAccess: { + providers: [ + { + id: "connect", + displayName: "bb connect", + availability: paired + ? { status: "available", serverUrl: "https://test.getbb.app" } + : { status: "setup-required", message: "Set up bb connect" }, + attention: paired ? "2 legacy access records need attention" : null, + }, + { + id: "direct", + displayName: "Manual", + availability: { status: "available" }, + attention: null, + }, + ], + defaultProviderId, + effectiveUrl: "https://bb.example.com", + urlSource: "BB_EXTERNAL_URL", + }, + }), + }); + return render( + + + , + ); +} + +it("offers Connect setup without exposing the manual URL even when a URL exists", () => { + show("connect"); + expect( + screen + .getByRole("link", { name: "Set up bb connect" }) + .getAttribute("href"), + ).toBe("/settings/plugins/connect"); + expect(screen.getByText("Not connected")).toBeTruthy(); + expect(screen.queryByRole("textbox")).toBeNull(); + expect(screen.queryByText("Automatic")).toBeNull(); +}); + +it("shows the URL input only for Manual", () => { + show("direct"); + expect(screen.getByRole("textbox", { name: "Server address" })).toBeTruthy(); + expect(screen.queryByRole("link", { name: "Set up bb connect" })).toBeNull(); +}); + +it("retains diagnostics for paired Connect without showing setup", () => { + show("connect", true); + expect(screen.getByText("Connected")).toBeTruthy(); + expect( + screen + .getByRole("link", { name: "https://test.getbb.app" }) + .getAttribute("href"), + ).toBe("https://test.getbb.app"); + expect( + screen.getByRole("link", { name: "Manage" }).getAttribute("href"), + ).toBe("/settings/plugins/connect"); + expect(screen.getByRole("status").textContent).toBe( + "bb connect: 2 legacy access records need attention", + ); + expect(screen.queryByRole("link", { name: "Set up bb connect" })).toBeNull(); +}); + +it("keeps the selection through saving and a stale config refresh", () => { + const view = show("connect"); + fireEvent.change(screen.getByRole("combobox"), { + target: { value: "direct" }, + }); + expect(screen.getByRole("textbox", { name: "Server address" })).toBeTruthy(); + expect(mocks.mutate.mock.calls[0][0].defaultMachineAccess).toBe("direct"); + const refresh = () => + view.rerender( + + + , + ); + mocks.isPending = true; + refresh(); + expect(screen.getByRole("combobox").value).toBe("direct"); + mocks.isPending = false; + refresh(); + expect(screen.getByRole("combobox").value).toBe("direct"); + const config = mocks.config(); + mocks.config.mockReturnValue({ + data: { + ...config.data, + serverAccess: { + ...config.data.serverAccess, + defaultProviderId: "direct", + }, + }, + }); + refresh(); + expect(screen.getByRole("combobox").value).toBe("direct"); + mocks.config.mockReturnValue(config); + refresh(); + expect(screen.getByRole("combobox").value).toBe("connect"); +}); + +it("restores the saved selection when saving fails", () => { + show("connect"); + fireEvent.change(screen.getByRole("combobox"), { + target: { value: "direct" }, + }); + expect(screen.getByRole("textbox", { name: "Server address" })).toBeTruthy(); + act(() => mocks.mutate.mock.calls[0][1].onError(new Error("Save failed"))); + expect(screen.getByRole("combobox").value).toBe("connect"); + expect(screen.queryByRole("textbox", { name: "Server address" })).toBeNull(); +}); diff --git a/apps/app/src/components/settings/MachineAccessSettings.tsx b/apps/app/src/components/settings/MachineAccessSettings.tsx new file mode 100644 index 0000000000..8d840732a5 --- /dev/null +++ b/apps/app/src/components/settings/MachineAccessSettings.tsx @@ -0,0 +1,193 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { Button } from "@bb/shared-ui/button"; +import { getPluginConfigurationRoutePath } from "@/lib/route-paths"; +import { Input } from "@bb/shared-ui/input"; +import { OptionPicker } from "@/components/pickers/OptionPicker"; +import { useSystemConfig } from "@/hooks/queries/system-queries"; +import { useUpdateGeneralSettings } from "@/hooks/mutations/settings-mutations"; +import { + SettingsSection, + SettingsWithControl, +} from "@/components/ui/settings-section"; + +export function MachineAccessSettings() { + const config = useSystemConfig(); + const update = useUpdateGeneralSettings(); + const settings = config.data?.generalSettings; + const access = config.data?.serverAccess; + const value = settings?.machineServerUrl ?? ""; + const [draft, setDraft] = useState(null); + const [error, setError] = useState(null); + const disabled = !settings || update.isPending; + const commitUrl = async () => { + if (!settings || draft === null) return; + try { + const url = draft.trim(); + if (url) { + const parsed = new URL(url); + if ( + !["http:", "https:"].includes(parsed.protocol) || + parsed.username || + parsed.password + ) + throw new Error("Enter an HTTP or HTTPS URL without credentials"); + } + await update.mutateAsync({ ...settings, machineServerUrl: url || null }); + setDraft(null); + setError(null); + } catch { + setError("Enter a valid HTTP or HTTPS URL without credentials"); + } + }; + const savedProviderId = access?.defaultProviderId ?? "connect"; + const [selectedProviderId, setSelectedProviderId] = useState( + null, + ); + useEffect(() => { + if (selectedProviderId === savedProviderId) setSelectedProviderId(null); + }, [savedProviderId, selectedProviderId]); + const selected = selectedProviderId ?? savedProviderId; + const effective = access?.providers.find( + (provider) => provider.id === selected, + ); + return ( + provider.id === "connect") + ? [ + { + value: "connect", + label: "bb connect", + description: "Use a private getbb.app address.", + }, + ] + : []), + ...(access?.providers ?? []).map((provider) => ({ + value: provider.id, + label: provider.displayName, + description: + provider.id === "connect" + ? "Use a private getbb.app address." + : provider.id === "direct" + ? "Use your own domain or network address." + : provider.availability.status !== "available" + ? provider.availability.message + : "Use this provider for new machine connections.", + })), + ]} + onChange={(providerId) => { + if (!settings || providerId === selected) return; + setSelectedProviderId(providerId); + update.mutate( + { ...settings, defaultMachineAccess: providerId }, + { onError: () => setSelectedProviderId(null) }, + ); + }} + /> + } + bodyClassName="space-y-3" + > + {access?.providers.map((provider) => + provider.attention ? ( +

+ {provider.displayName}: {provider.attention} +

+ ) : null, + )} + {selected === "connect" && ( +
+
+

+ {effective?.availability.status === "available" && ( +

+

+ {effective?.availability.status === "available" ? ( + effective.availability.serverUrl ? ( + + {effective.availability.serverUrl} + + ) : ( + "Ready to add machines." + ) + ) : effective?.availability.status === "unavailable" ? ( + effective.availability.message + ) : ( + "Connect your getbb.app account to add machines." + )} +

+
+ +
+ )} + {selected !== "connect" && selected !== "direct" && ( +

+ {effective?.availability.status === "available" + ? "Ready to connect new machines." + : (effective?.availability.message ?? + "This connection method is not installed.")} +

+ )} + {selected === "direct" && ( +
+ + setDraft(event.target.value)} + onBlur={() => void commitUrl()} + onKeyDown={(event) => { + if (event.key === "Enter") void commitUrl(); + }} + /> + +
+ )} +
+ ); +} diff --git a/apps/app/src/components/settings/MachineEnvironmentSettings.test.tsx b/apps/app/src/components/settings/MachineEnvironmentSettings.test.tsx new file mode 100644 index 0000000000..1c61c1aba8 --- /dev/null +++ b/apps/app/src/components/settings/MachineEnvironmentSettings.test.tsx @@ -0,0 +1,120 @@ +// @vitest-environment jsdom +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import type { MachineEnvironmentList } from "@bb/server-contract"; +import { MachineEnvironmentSettings } from "./MachineEnvironmentSettings"; + +const mocks = vi.hoisted(() => ({ + list: vi.fn(), + set: vi.fn(), + unset: vi.fn(), +})); +vi.mock("@/hooks/queries/system-queries", () => ({ + useSystemConfig: () => ({ + data: { generalSettings: { machineGitCredentialsEnabled: true } }, + }), +})); +vi.mock("@/hooks/mutations/settings-mutations", () => ({ + useUpdateGeneralSettings: () => ({ isPending: false, mutate: vi.fn() }), +})); +vi.mock("@/lib/sdk", () => ({ + sdk: { + system: { + machineEnvironment: mocks.list, + setMachineEnvironment: mocks.set, + unsetMachineEnvironment: mocks.unset, + }, + }, +})); +vi.mock("@/hooks/cache-owners/system-cache-effects", () => ({ + invalidateSystemConfig: vi.fn(), +})); +afterEach(cleanup); +beforeEach(() => vi.resetAllMocks()); + +async function show( + status: MachineEnvironmentList["builtInGit"]["status"] = "logged in", +) { + mocks.list.mockResolvedValue({ + builtInGit: { status, statusMessage: "Git status" }, + variables: [{ name: "API_KEY", value: null, secret: true, note: null }], + }); + render( + + + , + ); + await screen.findByDisplayValue("API_KEY"); +} + +it("renders the automatic token as a read-only variable with a login error", async () => { + await show("not logged in"); + expect( + screen.getByDisplayValue("GH_TOKEN").getAttribute("readonly"), + ).not.toBeNull(); + expect(screen.getByText("Automatic")).toBeTruthy(); + expect(screen.getByRole("alert").textContent).toContain("gh auth login"); +}); + +it("stages additions and preserves an unchanged saved secret", async () => { + await show(); + fireEvent.click(screen.getByRole("button", { name: "Add variable" })); + fireEvent.change(screen.getByLabelText("Variable name 2"), { + target: { value: "NEW_VALUE" }, + }); + fireEvent.change(screen.getByLabelText("Value for NEW_VALUE"), { + target: { value: "example" }, + }); + expect(mocks.set).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Save variables" })); + await waitFor(() => + expect(mocks.set).toHaveBeenCalledExactlyOnceWith({ + name: "NEW_VALUE", + value: "example", + note: null, + }), + ); + expect(mocks.unset).not.toHaveBeenCalled(); +}); + +it("stages removal and lets users discard it without deleting", async () => { + await show(); + fireEvent.click(screen.getByRole("button", { name: "Remove API_KEY" })); + expect(screen.queryByDisplayValue("API_KEY")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Discard changes" })); + expect(screen.getByDisplayValue("API_KEY")).toBeTruthy(); + expect(mocks.unset).not.toHaveBeenCalled(); +}); + +it("retains a secret replacement when saving fails", async () => { + await show(); + mocks.set.mockRejectedValue(new Error("offline")); + fireEvent.change(screen.getByLabelText("Value for API_KEY"), { + target: { value: "replacement" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save variables" })); + await screen.findByText(/Some changes could not be saved/); + expect(screen.getByDisplayValue("replacement")).toBeTruthy(); +}); + +it("does not show a validation error until a new name field loses focus", async () => { + await show(); + fireEvent.click(screen.getByRole("button", { name: "Add variable" })); + expect(screen.queryByRole("alert")).toBeNull(); + const name = screen.getByLabelText("Variable name 2"); + fireEvent.blur(name); + expect(screen.getByRole("alert").textContent).toBe("Enter a variable name."); + fireEvent.change(name, { target: { value: "VALID_NAME" } }); + expect(screen.queryByRole("alert")).toBeNull(); +}); diff --git a/apps/app/src/components/settings/MachineEnvironmentSettings.tsx b/apps/app/src/components/settings/MachineEnvironmentSettings.tsx new file mode 100644 index 0000000000..f780146433 --- /dev/null +++ b/apps/app/src/components/settings/MachineEnvironmentSettings.tsx @@ -0,0 +1,387 @@ +import { Icon } from "@bb/shared-ui/icon"; +import { useState } from "react"; +import { Switch } from "@bb/shared-ui/switch"; +import { useSystemConfig } from "@/hooks/queries/system-queries"; +import { useUpdateGeneralSettings } from "@/hooks/mutations/settings-mutations"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + machineEnvironmentSetSchema, + type MachineEnvironmentVariable, +} from "@bb/server-contract"; +import { Button } from "@bb/shared-ui/button"; +import { Input } from "@bb/shared-ui/input"; +import { sdk } from "@/lib/sdk"; +import { + SettingsBadge, + SettingsSection, +} from "@/components/ui/settings-section"; +import { invalidateSystemConfig } from "@/hooks/cache-owners/system-cache-effects"; +import { parseMachineEnvironmentImport } from "./machine-environment-import"; + +const queryKey = ["machine-environment"]; +type DraftRow = Omit & { + id: string; + existing: boolean; + value: string | null; +}; + +export function MachineEnvironmentSettings() { + const queryClient = useQueryClient(); + const settings = useSystemConfig().data?.generalSettings; + const updateSettings = useUpdateGeneralSettings(); + const query = useQuery({ + queryKey, + queryFn: () => sdk.system.machineEnvironment(), + }); + const [draft, setDraft] = useState(null); + const [visible, setVisible] = useState>(new Set()); + const [touched, setTouched] = useState>(new Set()); + const [importOpen, setImportOpen] = useState(false); + const [importText, setImportText] = useState(""); + const [error, setError] = useState(null); + const rows = + draft ?? + (query.data?.variables ?? []).map((row) => ({ + ...row, + id: row.name, + existing: true, + })); + const issues = rows.map((row) => { + if (rows.filter((other) => other.name === row.name).length > 1) + return "Variable name already exists."; + const result = machineEnvironmentSetSchema.safeParse({ + name: row.name, + value: row.value ?? "", + note: row.note, + }); + return result.success + ? null + : !row.name + ? "Enter a variable name." + : "Use uppercase letters, numbers, and underscores; start with a letter or underscore."; + }); + const mutation = useMutation({ + mutationFn: async () => { + for (const row of rows) { + if (row.value === null) continue; + await sdk.system.setMachineEnvironment({ + name: row.name, + value: row.value, + note: row.note, + }); + } + for (const original of query.data?.variables ?? []) { + if (!rows.some((row) => row.name === original.name)) + await sdk.system.unsetMachineEnvironment(original.name); + } + }, + onSuccess: async () => { + await query.refetch(); + invalidateSystemConfig({ queryClient }); + setDraft(null); + setVisible(new Set()); + setError(null); + }, + onError: () => { + void query.refetch(); + setError( + "Some changes could not be saved. Your edits are retained; try saving again.", + ); + }, + }); + const disabled = !query.data || mutation.isPending; + const change = (id: string, patch: Partial) => { + setDraft(rows.map((row) => (row.id === id ? { ...row, ...patch } : row))); + setError(null); + }; + const hasOverride = rows.some((row) => row.name === "GH_TOKEN"); + const git = query.data?.builtInGit; + const gitDisabled = settings?.machineGitCredentialsEnabled === false; + const gitMissing = git?.status === "not logged in"; + return ( + + + + + } + > + {importOpen && ( +
+