diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx
index 9b8d9506a63c..8f475a27d1d2 100644
--- a/apps/mobile/src/Stack.tsx
+++ b/apps/mobile/src/Stack.tsx
@@ -96,6 +96,7 @@ import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteSc
import { SettingsEnvironmentDetailRouteScreen } from "./features/settings/SettingsEnvironmentDetailRouteScreen";
import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";
import { SettingsFollowUpRouteScreen } from "./features/settings/SettingsFollowUpRouteScreen";
+import { SettingsCloudRouteScreen } from "./features/settings/SettingsCloudRouteScreen";
import { SettingsLanguageRouteScreen } from "./features/settings/SettingsLanguageRouteScreen";
import {
SettingsEnvironmentMaintenanceRouteScreen,
@@ -290,6 +291,11 @@ const SETTINGS_SCREENS = {
linking: "settings/follow-ups",
options: settingsPage("Chat"),
}),
+ SettingsCloud: createNativeStackScreen({
+ screen: SettingsCloudRouteScreen,
+ linking: "settings/cloud",
+ options: settingsPage("Cloud"),
+ }),
SettingsScheduledTasks: createNativeStackScreen({
screen: SettingsScheduledTasksRouteScreen,
linking: "settings/scheduled-tasks",
diff --git a/apps/mobile/src/components/BoatIcon.tsx b/apps/mobile/src/components/BoatIcon.tsx
new file mode 100644
index 000000000000..c5a65e050890
--- /dev/null
+++ b/apps/mobile/src/components/BoatIcon.tsx
@@ -0,0 +1,22 @@
+import Svg, { Path } from "react-native-svg";
+import { withUniwind } from "uniwind";
+
+const ThemedPath = withUniwind(Path);
+
+/** The boat.dev mark. */
+export function BoatIcon(props: { readonly size: number; readonly colorClassName: string }) {
+ return (
+
+ );
+}
diff --git a/apps/mobile/src/connection/onboarding.ts b/apps/mobile/src/connection/onboarding.ts
index 54963759ed18..59623470905e 100644
--- a/apps/mobile/src/connection/onboarding.ts
+++ b/apps/mobile/src/connection/onboarding.ts
@@ -28,6 +28,25 @@ export const connectPairingUrl = createRuntimeCommand(connectionAtomRuntime, {
),
});
+/** Pairs with a server from its address and a one-time code, such as a Boat cloud VM. */
+export const connectPairingCode = createRuntimeCommand(connectionAtomRuntime, {
+ label: "mobile:connection:connect-pairing-code",
+ scheduler: onboardingScheduler,
+ concurrency: {
+ mode: "singleFlight",
+ key: (input: { readonly host: string; readonly pairingCode: string }) =>
+ JSON.stringify([input.host, input.pairingCode]),
+ },
+ execute: (input: {
+ readonly host: string;
+ readonly pairingCode: string;
+ readonly expectedEnvironmentId: EnvironmentId;
+ }) =>
+ ConnectionOnboarding.ConnectionOnboarding.pipe(
+ Effect.flatMap((onboarding) => onboarding.registerPairing(input)),
+ ),
+});
+
export const updateBearerConnection = createRuntimeCommand(connectionAtomRuntime, {
label: "mobile:connection:update-bearer",
scheduler: onboardingScheduler,
diff --git a/apps/mobile/src/features/boatCloud/BoatCloudSteps.tsx b/apps/mobile/src/features/boatCloud/BoatCloudSteps.tsx
new file mode 100644
index 000000000000..ee080939d0b2
--- /dev/null
+++ b/apps/mobile/src/features/boatCloud/BoatCloudSteps.tsx
@@ -0,0 +1,53 @@
+import type { BoatCloudStartStep, BoatCloudStepProgress } from "@t3tools/contracts";
+import { View } from "react-native";
+
+import { SymbolView, type AppSymbolName } from "../../components/ui/AppSymbol";
+import { ListRowLayout } from "../../components/ui/ListRowLayout";
+import { cn } from "../../lib/cn";
+
+export type StepState = BoatCloudStepProgress["state"];
+
+// Static glyphs: a VM takes seconds to minutes, and a spinner would repaint the whole time.
+const STEP_ICONS: Record = {
+ done: { name: "checkmark.circle.fill", tint: "accent-success-foreground" },
+ failed: { name: "xmark.circle.fill", tint: "accent-danger-foreground" },
+ active: { name: "circle.inset.filled", tint: "accent-primary-text" },
+ pending: { name: "circle.dashed", tint: "accent-icon-muted" },
+};
+
+export function StepGlyph(props: { readonly state: StepState }) {
+ const icon = STEP_ICONS[props.state];
+ return ;
+}
+
+const START_STEP_LABELS: Record = {
+ machine: "Find a VM",
+ server: "Start T3 Code",
+ repository: "Clone or update the repository",
+ connect: "Connect",
+ project: "Open the project",
+ thread: "Move the draft",
+};
+
+/** Each step of starting on Boat cloud, as the holder and this app report it. */
+export function BoatCloudStartSteps(props: {
+ readonly steps: ReadonlyArray;
+}) {
+ return (
+
+ {props.steps.map((entry, index) => (
+ 0 && "border-t border-border-subtle")}
+ accessibilityLabel={`${START_STEP_LABELS[entry.step]}, ${entry.state}`}
+ >
+ }
+ label={START_STEP_LABELS[entry.step]}
+ description={entry.detail ?? undefined}
+ />
+
+ ))}
+
+ );
+}
diff --git a/apps/mobile/src/features/boatCloud/boatCloud.ts b/apps/mobile/src/features/boatCloud/boatCloud.ts
new file mode 100644
index 000000000000..8e28217a7715
--- /dev/null
+++ b/apps/mobile/src/features/boatCloud/boatCloud.ts
@@ -0,0 +1,355 @@
+import { useAtomValue } from "@effect/atom-react";
+import {
+ connectionCatalogDisplayUrl,
+ type EnvironmentConnectionPhase,
+} from "@t3tools/client-runtime/connection";
+import {
+ isAtomCommandInterrupted,
+ runAtomCommand,
+ squashAtomCommandFailure,
+} from "@t3tools/client-runtime/state/runtime";
+import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
+import {
+ BOAT_CLOUD_START_STEPS,
+ type BoatCloudSession,
+ type BoatCloudStartStep,
+ type BoatCloudStatus,
+ type BoatCloudStepProgress,
+ type EnvironmentId,
+ isBoatCloudHttpBaseUrl,
+} from "@t3tools/contracts";
+import { boatCloudProviderDrivers } from "@t3tools/shared/boatCloudVm";
+import * as Effect from "effect/Effect";
+import { AsyncResult, Atom } from "effect/reactivity";
+import { useEffect } from "react";
+
+import * as Dialog from "../../components/ui/Dialog";
+import { connectPairingCode } from "../../connection/onboarding";
+import { uuidv4 } from "../../lib/uuid";
+import { appAtomRegistry } from "../../state/atom-registry";
+import { connectedEnvironmentIdsAtom, environmentPresentations } from "../../state/presentation";
+import { environmentProjects } from "../../state/projects";
+import { serverEnvironment } from "../../state/server";
+import {
+ ensurePhoneBoatCloudLoaded,
+ phoneBoatCloudAtom,
+ startOnPhone,
+ wakeOnPhone,
+} from "./phoneBoatCloud";
+
+/**
+ * Boat cloud VMs are ordinary environments. A holder of the Boat key hands out
+ * a VM, and this app pairs with it like any other machine. The holder is a
+ * connected machine when one has the key, else this phone.
+ */
+
+const PROJECT_APPEAR_TIMEOUT_MS = 60_000;
+
+interface BoatCloudHolder {
+ readonly environmentId: EnvironmentId;
+ readonly status: BoatCloudStatus;
+}
+
+/** Who starts a VM: a connected machine with the key, or this phone with its own. */
+type BoatCloudStarter =
+ | { readonly kind: "machine"; readonly environmentId: EnvironmentId }
+ | { readonly kind: "phone" };
+
+/** Paired Boat VMs, by environment, with the URL each one is reached at. */
+export const boatCloudMachinesAtom = Atom.make((get) => {
+ const machines = new Map();
+ for (const [environmentId, presentation] of get(environmentPresentations.presentationsAtom)) {
+ const url = connectionCatalogDisplayUrl(presentation.entry);
+ if (url !== null && isBoatCloudHttpBaseUrl(url)) machines.set(environmentId, url);
+ }
+ return machines;
+}).pipe(Atom.withLabel("mobile-boat-cloud:machines"));
+
+/** Connected machines that answer for Boat cloud, without the VMs, which answer too. */
+export const boatCloudHoldersAtom = Atom.make((get): ReadonlyArray => {
+ const machines = get(boatCloudMachinesAtom);
+ return get(connectedEnvironmentIdsAtom).flatMap((environmentId) => {
+ if (machines.has(environmentId)) return [];
+ const status = AsyncResult.getOrElse(
+ get(serverEnvironment.boatCloudStatus({ environmentId, input: {} })),
+ () => null,
+ );
+ return status ? [{ environmentId, status }] : [];
+ });
+}).pipe(Atom.withLabel("mobile-boat-cloud:holders"));
+
+function boatCloudReady(status: BoatCloudStatus | null | undefined) {
+ return status?.connected === true && status.setup.status === "ready";
+}
+
+/**
+ * The holder that should start a VM: the draft's own machine when it can, then
+ * any ready machine, which keeps session backups, then this phone.
+ */
+function boatCloudStarterFor(
+ holders: ReadonlyArray,
+ phone: BoatCloudStatus,
+ preferredEnvironmentId: EnvironmentId | null,
+): BoatCloudStarter | null {
+ const ready = holders.filter((holder) => boatCloudReady(holder.status));
+ const machine =
+ ready.find((holder) => holder.environmentId === preferredEnvironmentId) ?? ready[0];
+ if (machine) return { kind: "machine", environmentId: machine.environmentId };
+ return boatCloudReady(phone) ? { kind: "phone" } : null;
+}
+
+export function useBoatCloudStarter(preferredEnvironmentId: EnvironmentId | null) {
+ const holders = useAtomValue(boatCloudHoldersAtom);
+ const phone = useAtomValue(phoneBoatCloudAtom);
+ // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reads this phone's Boat key from the keychain once per launch
+ useEffect(() => {
+ void ensurePhoneBoatCloudLoaded();
+ }, []);
+ return boatCloudStarterFor(holders, phone, preferredEnvironmentId);
+}
+
+/** The providers a new VM from this starter has sign-ins for, or null when unknown. */
+export function useBoatCloudProviderDrivers(preferredEnvironmentId: EnvironmentId | null) {
+ const holders = useAtomValue(boatCloudHoldersAtom);
+ const phone = useAtomValue(phoneBoatCloudAtom);
+ const starter = boatCloudStarterFor(holders, phone, preferredEnvironmentId);
+ const status =
+ starter?.kind === "machine"
+ ? holders.find((holder) => holder.environmentId === starter.environmentId)?.status
+ : starter?.kind === "phone"
+ ? phone
+ : null;
+ const chosen = status?.environments.find(
+ (environment) => environment.name === status.environment,
+ );
+ return chosen?.agentCredentials ? boatCloudProviderDrivers(chosen.agentCredentials) : null;
+}
+
+function errorMessage(error: unknown) {
+ return error instanceof Error ? error.message : "An error occurred.";
+}
+
+/** Pairs this app with the VM unless it is already connected. Throws what failed. */
+async function attach(
+ session: Pick,
+) {
+ if (appAtomRegistry.get(connectedEnvironmentIdsAtom).includes(session.environmentId)) return;
+ const paired = await runAtomCommand(
+ appAtomRegistry,
+ connectPairingCode,
+ {
+ host: session.httpBaseUrl,
+ pairingCode: session.pairingCode,
+ expectedEnvironmentId: session.environmentId,
+ },
+ { label: connectPairingCode.label, reportFailure: false, reportDefect: true },
+ );
+ if (paired._tag === "Failure") throw squashAtomCommandFailure(paired);
+}
+
+function waitForProject(
+ find: (projects: ReadonlyArray) => EnvironmentProject | undefined,
+): Promise {
+ const atom = environmentProjects.projectsAtom;
+ const now = find(appAtomRegistry.get(atom));
+ if (now) return Promise.resolve(now);
+ return new Promise((resolve) => {
+ const finish = (project: EnvironmentProject | null) => {
+ clearTimeout(timeout);
+ unsubscribe();
+ resolve(project);
+ };
+ const timeout = setTimeout(() => finish(null), PROJECT_APPEAR_TIMEOUT_MS);
+ const unsubscribe = appAtomRegistry.subscribe(atom, (projects) => {
+ const project = find(projects);
+ if (project) finish(project);
+ });
+ });
+}
+
+const cancelled = new Error("Boat cloud start cancelled");
+
+const HOLDER_STEPS = new Set(["machine", "server", "repository"]);
+
+const pendingBoatCloudSteps = (): ReadonlyArray =>
+ BOAT_CLOUD_START_STEPS.map((step) => ({ step, state: "pending", detail: null }));
+
+/** Starts the VM through a connected machine, following its steps in that machine's status. */
+async function startOnMachine(
+ environmentId: EnvironmentId,
+ repositoryUrl: string,
+ baseBranch: string | null,
+ onHolderSteps: (steps: ReadonlyArray) => void,
+): Promise {
+ const requestId = uuidv4();
+ const statusAtom = serverEnvironment.boatCloudStatus({ environmentId, input: {} });
+ const unsubscribe = appAtomRegistry.subscribe(statusAtom, (result) => {
+ const start = AsyncResult.getOrElse(result, () => null)?.starts.find(
+ (candidate) => candidate.requestId === requestId,
+ );
+ if (start) onHolderSteps(start.steps.filter((step) => HOLDER_STEPS.has(step.step)));
+ });
+ try {
+ const acquired = await runAtomCommand(
+ appAtomRegistry,
+ serverEnvironment.acquireBoatCloud,
+ {
+ environmentId,
+ input: { repositoryUrl, ...(baseBranch ? { baseBranch } : {}), requestId },
+ },
+ { label: serverEnvironment.acquireBoatCloud.label, reportFailure: false, reportDefect: true },
+ );
+ if (acquired._tag === "Failure") {
+ throw isAtomCommandInterrupted(acquired) ? cancelled : squashAtomCommandFailure(acquired);
+ }
+ return acquired.value;
+ } finally {
+ unsubscribe();
+ }
+}
+
+/**
+ * Gets a VM with the repository checked out and returns its project there, so
+ * a draft can move onto it, reporting every step on the way. The caller marks
+ * the last step, moving the thread, done. Null after telling the user what failed.
+ */
+export async function prepareBoatCloudProject(input: {
+ readonly starter: BoatCloudStarter;
+ readonly repositoryUrl: string;
+ readonly baseBranch: string | null;
+ readonly onProgress: (steps: ReadonlyArray) => void;
+}): Promise {
+ let steps = pendingBoatCloudSteps();
+ const set = (step: BoatCloudStartStep, patch: Partial) => {
+ steps = steps.map((entry) => (entry.step === step ? { ...entry, ...patch } : entry));
+ input.onProgress(steps);
+ };
+ const setHolderSteps = (holderSteps: ReadonlyArray) => {
+ for (const entry of holderSteps) set(entry.step, entry);
+ };
+ let current: BoatCloudStartStep = "machine";
+ try {
+ const session =
+ input.starter.kind === "machine"
+ ? await startOnMachine(
+ input.starter.environmentId,
+ input.repositoryUrl,
+ input.baseBranch,
+ setHolderSteps,
+ )
+ : await startOnPhone({
+ repositoryUrl: input.repositoryUrl,
+ baseBranch: input.baseBranch,
+ report: (step, state, detail) => Effect.sync(() => set(step, { state, detail })),
+ });
+ for (const step of HOLDER_STEPS) set(step, { state: "done" });
+ current = "connect";
+ set("connect", { state: "active" });
+ await attach(session);
+ set("connect", { state: "done" });
+ current = "project";
+ set("project", { state: "active" });
+ const project = await waitForProject((projects) =>
+ projects.find(
+ (candidate) =>
+ candidate.environmentId === session.environmentId &&
+ candidate.workspaceRoot === session.workspaceRoot,
+ ),
+ );
+ if (!project) throw new Error("The Boat cloud machine did not open the project.");
+ set("project", { state: "done" });
+ set("thread", { state: "active" });
+ return project;
+ } catch (error) {
+ const failed = steps.find((entry) => entry.state === "active")?.step ?? current;
+ set(failed, { state: "failed" });
+ if (error !== cancelled) Dialog.alert("Could not start on Boat cloud", errorMessage(error));
+ return null;
+ }
+}
+
+const wakesInFlight = new Map>();
+
+/** Whether this app is turning a VM back on, so its thread shows "Waking" rather than "Sleeping". */
+const boatCloudWakingAtom = Atom.family((_environmentId: EnvironmentId | null) =>
+ Atom.make(false).pipe(Atom.keepAlive),
+);
+
+/**
+ * Turns a stopped VM back on. A machine that owns it can also restore it from
+ * its backup and hands out a new pairing; the phone reconnects to the same server.
+ */
+function wakeBoatCloudMachine(environmentId: EnvironmentId, httpBaseUrl: string): Promise {
+ const inFlight = wakesInFlight.get(environmentId);
+ if (inFlight) return inFlight;
+ const waking = boatCloudWakingAtom(environmentId);
+ appAtomRegistry.set(waking, true);
+ const wake = (async () => {
+ await ensurePhoneBoatCloudLoaded();
+ const holders = appAtomRegistry.get(boatCloudHoldersAtom);
+ const phoneHasKey = appAtomRegistry.get(phoneBoatCloudAtom).connected;
+ // The machine that started the VM can restore it; any other machine with the key can wake it.
+ const owner =
+ holders.find((holder) =>
+ holder.status.machines.some((machine) => machine.environmentId === environmentId),
+ ) ?? (phoneHasKey ? undefined : holders.find((holder) => holder.status.connected));
+ if (owner) {
+ const woken = await runAtomCommand(
+ appAtomRegistry,
+ serverEnvironment.wakeBoatCloud,
+ { environmentId: owner.environmentId, input: { environmentId, httpBaseUrl } },
+ { label: serverEnvironment.wakeBoatCloud.label, reportFailure: false, reportDefect: true },
+ );
+ if (woken._tag === "Failure") {
+ if (!isAtomCommandInterrupted(woken)) {
+ Dialog.alert(
+ "Could not wake the Boat cloud machine",
+ errorMessage(squashAtomCommandFailure(woken)),
+ );
+ }
+ return;
+ }
+ // A restored VM has a new address, so pair again rather than wait for the old one.
+ await attach(woken.value).catch((error: unknown) =>
+ Dialog.alert("Could not reconnect to Boat cloud", errorMessage(error)),
+ );
+ return;
+ }
+ if (!phoneHasKey) return;
+ await wakeOnPhone(httpBaseUrl).catch((error: unknown) =>
+ Dialog.alert("Could not wake the Boat cloud machine", errorMessage(error)),
+ );
+ })().finally(() => {
+ wakesInFlight.delete(environmentId);
+ appAtomRegistry.set(waking, false);
+ });
+ wakesInFlight.set(environmentId, wake);
+ return wake;
+}
+
+export interface BoatCloudSleep {
+ readonly state: "sleeping" | "waking";
+ readonly wake: () => void;
+}
+
+/**
+ * A Boat VM that is not reachable is asleep: it stopped itself after its threads
+ * went quiet. A message sent meanwhile waits in the queue and wakes it, so its
+ * thread reads "Sleeping" rather than disconnected.
+ */
+export function useBoatCloudSleep(
+ environmentId: EnvironmentId | null,
+ phase: EnvironmentConnectionPhase | null,
+): BoatCloudSleep | null {
+ const machines = useAtomValue(boatCloudMachinesAtom);
+ const waking = useAtomValue(boatCloudWakingAtom(environmentId));
+ const httpBaseUrl = environmentId === null ? null : (machines.get(environmentId) ?? null);
+ if (environmentId === null || httpBaseUrl === null || phase === null || phase === "connected") {
+ return null;
+ }
+ // Retries cycle the phase, so only this app's own wake reads as waking.
+ return {
+ state: waking ? "waking" : "sleeping",
+ wake: () => void wakeBoatCloudMachine(environmentId, httpBaseUrl),
+ };
+}
diff --git a/apps/mobile/src/features/boatCloud/phoneBoatCloud.ts b/apps/mobile/src/features/boatCloud/phoneBoatCloud.ts
new file mode 100644
index 000000000000..f55075327b78
--- /dev/null
+++ b/apps/mobile/src/features/boatCloud/phoneBoatCloud.ts
@@ -0,0 +1,298 @@
+import {
+ BOAT_CLOUD_SNAPSHOT_NAME,
+ type BoatCloudEnvironmentAccess,
+ type BoatCloudStatus,
+} from "@t3tools/contracts";
+import { BoatApiError, cloudEnvironments, makeBoatApi } from "@t3tools/shared/boatCloudApi";
+import {
+ type ReportStep,
+ buildSnapshot,
+ exec,
+ findSandboxByHttpBaseUrl,
+ startMachine,
+ wakeMachine,
+} from "@t3tools/shared/boatCloudStart";
+import {
+ MACHINE_LABEL_FILTER,
+ VM_T3_PORT,
+ machineLabelOf,
+ machineStateOf,
+ npmInstallServerScript,
+ parseRepository,
+} from "@t3tools/shared/boatCloudVm";
+import * as Effect from "effect/Effect";
+import * as Option from "effect/Option";
+import * as Result from "effect/Result";
+import { HttpClient } from "effect/http";
+import { Atom } from "effect/reactivity";
+
+import * as Runtime from "../../lib/runtime";
+import { MobileSecureStorage } from "../../persistence/mobile-secure-storage";
+import { appAtomRegistry } from "../../state/atom-registry";
+
+/**
+ * The phone as a Boat cloud holder: with the key in its own keychain it starts
+ * and wakes VMs without any machine connected. A machine holding the key does
+ * more (session backups, an idle sweep), so a connected one is used first.
+ */
+
+const API_KEY_STORAGE_KEY = "t3code.boat-cloud.api-key";
+/** The Boat environment new VMs start on, by name, as a machine holder keeps it. */
+const ENVIRONMENT_STORAGE_KEY = "t3code.boat-cloud.environment";
+
+const DISCONNECTED: BoatCloudStatus = {
+ connected: false,
+ account: null,
+ setup: { status: "idle", step: null, error: null },
+ machines: [],
+ starts: [],
+ environment: null,
+ environments: [],
+ environmentsError: null,
+};
+
+export const phoneBoatCloudAtom = Atom.make(DISCONNECTED).pipe(
+ Atom.keepAlive,
+ Atom.withLabel("mobile-boat-cloud:phone"),
+);
+
+const setStatus = (update: (status: BoatCloudStatus) => BoatCloudStatus) =>
+ appAtomRegistry.update(phoneBoatCloudAtom, update);
+
+const notConnected = new BoatApiError({
+ status: 0,
+ code: "not_connected",
+ message: "Connect Boat in Settings, Cloud first.",
+ retryable: false,
+});
+
+const phoneApi = Effect.gen(function* () {
+ const key = yield* MobileSecureStorage.use((storage) => storage.getItem(API_KEY_STORAGE_KEY));
+ if (key === null) return yield* notConnected;
+ return makeBoatApi(yield* HttpClient.HttpClient, key);
+});
+
+const run = (effect: Effect.Effect) =>
+ Runtime.runtime.runPromise(effect);
+
+const errorMessage = (error: unknown) =>
+ error instanceof Error ? error.message : "Boat could not be reached.";
+
+// Bumped once the key is stored or removed, so a refresh that read the old key drops its result.
+let keyGeneration = 0;
+
+/** Reads the key, the snapshot and the VMs from Boat. Leaves a setup in progress alone. */
+export function refreshPhoneBoatCloud(): Promise {
+ const generation = keyGeneration;
+ const stale = () => generation !== keyGeneration;
+ return run(
+ Effect.gen(function* () {
+ const api = yield* phoneApi;
+ const [account, snapshot, sandboxes, environments, profiles, stored] = yield* Effect.all(
+ [
+ api.me(),
+ api.getSnapshotStatus(BOAT_CLOUD_SNAPSHOT_NAME),
+ api.listSandboxes(MACHINE_LABEL_FILTER),
+ api.listEnvironments().pipe(Effect.result),
+ // Without it the sign-ins are unknown, and every provider stays offered.
+ api.listCredentialProfiles().pipe(Effect.option),
+ MobileSecureStorage.use((storage) => storage.getItem(ENVIRONMENT_STORAGE_KEY)),
+ ],
+ { concurrency: "unbounded" },
+ );
+ if (stale()) return;
+ const listed = Result.isSuccess(environments)
+ ? cloudEnvironments(environments.success, Option.getOrNull(profiles))
+ : [];
+ // With none chosen yet, Boat's default becomes the choice, so later VMs stay on it.
+ const environment = stored ?? listed.find((candidate) => candidate.isDefault)?.name ?? null;
+ if (stored === null && environment !== null) {
+ yield* MobileSecureStorage.use((storage) =>
+ storage.setItem(ENVIRONMENT_STORAGE_KEY, environment),
+ );
+ if (stale()) return;
+ }
+ setStatus((status) => ({
+ ...status,
+ connected: true,
+ account,
+ setup:
+ status.setup.status === "running"
+ ? status.setup
+ : snapshot === "ready"
+ ? { status: "ready", step: null, error: null }
+ : status.setup.status === "failed"
+ ? status.setup
+ : { status: "idle", step: null, error: null },
+ machines: sandboxes.map((sandbox) => ({
+ sandboxId: sandbox.id,
+ environmentId: null,
+ label: machineLabelOf(sandbox.id),
+ httpBaseUrl: sandbox.subdomain
+ ? `https://${sandbox.subdomain}-${VM_T3_PORT}.on.boat.dev`
+ : null,
+ state: machineStateOf(sandbox.state),
+ repositories: [],
+ backedUpAt: null,
+ environment: sandbox.environment ?? null,
+ })),
+ environment,
+ environments: listed,
+ environmentsError: Result.isFailure(environments) ? environments.failure.message : null,
+ }));
+ }).pipe(
+ Effect.catchTags({
+ BoatApiError: (error) =>
+ Effect.sync(() => {
+ if (error.code === "not_connected" && !stale()) setStatus(() => DISCONNECTED);
+ }),
+ }),
+ ),
+ ).catch(() => undefined);
+}
+
+let loaded: Promise | null = null;
+
+/** Loads the phone's Boat state once per launch; Settings, Cloud refreshes it on open. */
+export function ensurePhoneBoatCloudLoaded(): Promise {
+ loaded ??= refreshPhoneBoatCloud();
+ return loaded;
+}
+
+/** Checks the key with Boat, then keeps it in this phone's keychain. Null on success. */
+export async function connectPhoneBoatCloud(apiKey: string): Promise {
+ try {
+ await run(
+ Effect.gen(function* () {
+ yield* makeBoatApi(yield* HttpClient.HttpClient, apiKey).me();
+ yield* MobileSecureStorage.use((storage) => storage.setItem(API_KEY_STORAGE_KEY, apiKey));
+ }),
+ );
+ } catch (error) {
+ return errorMessage(error);
+ }
+ keyGeneration += 1;
+ await refreshPhoneBoatCloud();
+ return null;
+}
+
+export async function disconnectPhoneBoatCloud(): Promise {
+ keyGeneration += 1;
+ await run(
+ MobileSecureStorage.use((storage) =>
+ Effect.all([
+ storage.removeItem(API_KEY_STORAGE_KEY),
+ storage.removeItem(ENVIRONMENT_STORAGE_KEY),
+ ]),
+ ),
+ ).catch(() => undefined);
+ setStatus(() => DISCONNECTED);
+}
+
+/** Picks the environment for the phone's new VMs, and optionally changes what it passes. Null on success. */
+export async function configurePhoneBoatCloudEnvironment(
+ environmentId: string,
+ access?: BoatCloudEnvironmentAccess,
+): Promise {
+ const chosen = appAtomRegistry
+ .get(phoneBoatCloudAtom)
+ .environments.find((environment) => environment.id === environmentId);
+ if (!chosen) return "Boat has no such environment.";
+ try {
+ await run(
+ Effect.gen(function* () {
+ const api = yield* phoneApi;
+ if (access) yield* api.updateEnvironment(environmentId, access);
+ yield* MobileSecureStorage.use((storage) =>
+ storage.setItem(ENVIRONMENT_STORAGE_KEY, chosen.name),
+ );
+ }),
+ );
+ } catch (error) {
+ return errorMessage(error);
+ }
+ await refreshPhoneBoatCloud();
+ return null;
+}
+
+/**
+ * Builds the snapshot from the phone. The VM installs T3 Code from npm, since
+ * the phone has no server build to upload, and gets no skills.
+ */
+export async function runPhoneBoatCloudSetup(): Promise {
+ if (appAtomRegistry.get(phoneBoatCloudAtom).setup.status === "running") return;
+ setStatus((status) => ({ ...status, setup: { status: "running", step: null, error: null } }));
+ try {
+ await run(
+ Effect.gen(function* () {
+ const api = yield* phoneApi;
+ yield* buildSnapshot({
+ api,
+ install: (sandboxId) =>
+ exec(api, sandboxId, npmInstallServerScript("latest"), 600).pipe(Effect.asVoid),
+ setStep: (step) =>
+ Effect.sync(() =>
+ setStatus((status) => ({
+ ...status,
+ setup: { status: "running", step, error: null },
+ })),
+ ),
+ });
+ }),
+ );
+ setStatus((status) => ({ ...status, setup: { status: "ready", step: null, error: null } }));
+ } catch (error) {
+ setStatus((status) => ({
+ ...status,
+ setup: { status: "failed", step: status.setup.step, error: errorMessage(error) },
+ }));
+ }
+}
+
+/** Gets a VM ready for the repository from the phone, reporting each step as it goes. */
+export function startOnPhone(input: {
+ readonly repositoryUrl: string;
+ readonly baseBranch: string | null;
+ readonly report: ReportStep;
+}) {
+ const repository = parseRepository(input.repositoryUrl);
+ if (repository === null) {
+ return Promise.reject(new Error("Boat cloud cannot clone this project's remote."));
+ }
+ return run(
+ Effect.gen(function* () {
+ const api = yield* phoneApi;
+ return yield* startMachine({
+ api,
+ repository,
+ baseBranch: input.baseBranch,
+ environment: appAtomRegistry.get(phoneBoatCloudAtom).environment,
+ // The phone keeps no record of stopped VMs, so it resumes them only for load.
+ knownWorkspaceRoots: () => [],
+ report: input.report,
+ });
+ }),
+ ).finally(() => void refreshPhoneBoatCloud());
+}
+
+/**
+ * Turns the VM behind a paired URL back on. Its identity lives on its disk, so
+ * the saved pairing reconnects without a new code. Fails when Boat deleted it.
+ */
+export function wakeOnPhone(httpBaseUrl: string) {
+ return run(
+ Effect.gen(function* () {
+ const api = yield* phoneApi;
+ const sandbox = yield* findSandboxByHttpBaseUrl(api, httpBaseUrl);
+ if (sandbox === null) {
+ return yield* new BoatApiError({
+ status: 0,
+ code: "gone",
+ message: "Boat deleted this VM, and this phone keeps no copy of it.",
+ retryable: false,
+ });
+ }
+ return yield* wakeMachine(api, sandbox.id);
+ }),
+ );
+}
diff --git a/apps/mobile/src/features/connection/environmentSections.test.ts b/apps/mobile/src/features/connection/environmentSections.test.ts
index eed95771b1a9..e7b6eff48de4 100644
--- a/apps/mobile/src/features/connection/environmentSections.test.ts
+++ b/apps/mobile/src/features/connection/environmentSections.test.ts
@@ -46,8 +46,14 @@ describe("mobile environment settings sections", () => {
isRelayManaged: true,
});
+ const boatVm = connectedEnvironment({
+ environmentId: "environment-boat",
+ isRelayManaged: false,
+ displayUrl: "https://box-node-1dca660d802d15b6-3773.on.boat.dev",
+ });
+
const sections = splitEnvironmentSections({
- connectedEnvironments: [cloud, local],
+ connectedEnvironments: [cloud, local, boatVm],
cloudEnvironments: [
cloudEnvironment("environment-cloud"),
cloudEnvironment("environment-new"),
diff --git a/apps/mobile/src/features/connection/environmentSections.ts b/apps/mobile/src/features/connection/environmentSections.ts
index fc8fd958ae2e..996e59a1fe73 100644
--- a/apps/mobile/src/features/connection/environmentSections.ts
+++ b/apps/mobile/src/features/connection/environmentSections.ts
@@ -1,4 +1,4 @@
-import type { EnvironmentId } from "@t3tools/contracts";
+import { type EnvironmentId, isBoatCloudHttpBaseUrl } from "@t3tools/contracts";
import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay";
import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
@@ -30,8 +30,10 @@ export function splitEnvironmentSections(input: EnvironmentSectionsInput): Envir
const savedEnvironmentIds = relayManagedEnvironmentIds(input.connectedEnvironments);
return {
+ // Boat cloud VMs come and go with threads; Settings, Cloud lists them instead.
localEnvironments: input.connectedEnvironments.filter(
- (environment) => !environment.isRelayManaged,
+ (environment) =>
+ !environment.isRelayManaged && !isBoatCloudHttpBaseUrl(environment.displayUrl),
),
connectedCloudEnvironments: input.connectedEnvironments.filter(
(environment) => environment.isRelayManaged,
diff --git a/apps/mobile/src/features/keyboard/CommandPalette.tsx b/apps/mobile/src/features/keyboard/CommandPalette.tsx
index 38b83e4c56ba..cf36195d085e 100644
--- a/apps/mobile/src/features/keyboard/CommandPalette.tsx
+++ b/apps/mobile/src/features/keyboard/CommandPalette.tsx
@@ -270,8 +270,14 @@ export function CommandPalette(props: {
const serverConfigs = useServerConfigs();
const startTriage = useStartTriage();
const newTaskFlow = useNewTaskFlow();
- const draftEnvironmentId =
- useActiveNewTask() === null ? null : (newTaskFlow.selectedProject?.environmentId ?? null);
+ const draftOpen = useActiveNewTask() !== null;
+ const draftEnvironmentId = draftOpen
+ ? (newTaskFlow.selectedProject?.environmentId ?? null)
+ : null;
+ // Offered on an open draft; its first send starts the VM.
+ const canStartBoatCloud =
+ draftOpen && newTaskFlow.boatCloud !== null && !newTaskFlow.boatCloud.selected;
+ const chooseBoatCloud = newTaskFlow.chooseBoatCloud;
const triageEnvironmentId = pickTriageEnvironment({
// The route names the machine even before the thread shell loads.
onScreen: activeThreadRef?.environmentId ?? draftEnvironmentId,
@@ -347,6 +353,17 @@ export function CommandPalette(props: {
run: () => openSettings(navigation, "SettingsArchive"),
},
];
+ if (canStartBoatCloud) {
+ actions.unshift({
+ key: "boatCloud",
+ kind: "action",
+ title: "Run on Boat cloud",
+ searchTerms: ["boat", "cloud", "vm", "remote", "new thread"],
+ run: () => {
+ chooseBoatCloud();
+ },
+ });
+ }
if (canTriage) {
actions.push({
key: "triage",
@@ -465,6 +482,7 @@ export function CommandPalette(props: {
activeThread,
activeThreadRef,
environments.length,
+ canStartBoatCloud,
canTriage,
navigation,
projects,
@@ -472,6 +490,7 @@ export function CommandPalette(props: {
savedConnectionsById,
selectDetail,
startTriage,
+ chooseBoatCloud,
threads,
triageEnvironmentId,
]);
diff --git a/apps/mobile/src/features/settings/SettingsCloudRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsCloudRouteScreen.tsx
new file mode 100644
index 000000000000..7bf4639bc5e7
--- /dev/null
+++ b/apps/mobile/src/features/settings/SettingsCloudRouteScreen.tsx
@@ -0,0 +1,349 @@
+import { useAtomValue } from "@effect/atom-react";
+import {
+ BOAT_CLOUD_REQUIRED_SCOPES,
+ BOAT_CLOUD_SETUP_STEPS,
+ type BoatCloudEnvironmentAccess,
+ type BoatCloudMachine,
+ type BoatCloudSetup,
+ type BoatCloudSetupStep,
+} from "@t3tools/contracts";
+import { useEffect, useState } from "react";
+import { View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+
+import { BoatIcon } from "../../components/BoatIcon";
+import { Button } from "../../components/ui/Button";
+import * as Dialog from "../../components/ui/Dialog";
+import { ListChoiceRow } from "../../components/ui/ListChoiceRow";
+import { ListRowLayout } from "../../components/ui/ListRowLayout";
+import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView";
+import { SecretTextInput } from "../../components/ui/SecretTextInput";
+import { cn } from "../../lib/cn";
+import { serverEnvironment } from "../../state/server";
+import { useAtomCommand } from "../../state/use-atom-command";
+import { useEnvironments } from "../../state/environments";
+import { boatCloudHoldersAtom } from "../boatCloud/boatCloud";
+import { StepGlyph, type StepState } from "../boatCloud/BoatCloudSteps";
+import {
+ configurePhoneBoatCloudEnvironment,
+ connectPhoneBoatCloud,
+ disconnectPhoneBoatCloud,
+ phoneBoatCloudAtom,
+ refreshPhoneBoatCloud,
+ runPhoneBoatCloudSetup,
+} from "../boatCloud/phoneBoatCloud";
+import { SettingsSection } from "./components/SettingsSection";
+import { SettingsScreen } from "./components/SettingsScreen";
+import { SettingsSwitchRow } from "./components/SettingsSwitchRow";
+
+const STEP_LABELS: Record = {
+ "create-vm": "Create a VM",
+ "install-server": "Install T3 Code server",
+ "copy-skills": "Copy this machine's skills",
+ "save-snapshot": "Save the T3 Code snapshot",
+};
+
+const ACCESS_LABELS: Record = {
+ passGithub: "GitHub access",
+ passSandboxCredentials: "Boat credentials",
+ passSecrets: "Secrets",
+ passAgentsCredentials: "Agent sign-ins",
+};
+const ACCESS_KEYS = Object.keys(ACCESS_LABELS) as ReadonlyArray;
+
+const MACHINE_STATE_LABELS: Record = {
+ running: "Running",
+ starting: "Starting",
+ stopped: "Stopped",
+ gone: "Saved copy",
+};
+
+function stepState(setup: BoatCloudSetup, index: number): StepState {
+ if (setup.status === "ready") return "done";
+ if (setup.step === null || setup.status === "idle") return "pending";
+ const current = BOAT_CLOUD_SETUP_STEPS.indexOf(setup.step);
+ if (index < current) return "done";
+ if (index > current) return "pending";
+ return setup.status === "failed" ? "failed" : "active";
+}
+
+function Separated(props: { readonly index: number; readonly children: React.ReactNode }) {
+ return (
+ 0 && "border-t border-border-subtle")}>{props.children}
+ );
+}
+
+export function SettingsCloudRouteScreen() {
+ const insets = useSafeAreaInsets();
+ const holders = useAtomValue(boatCloudHoldersAtom);
+ const phone = useAtomValue(phoneBoatCloudAtom);
+ const { environments } = useEnvironments();
+ const [apiKey, setApiKey] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [keyError, setKeyError] = useState(null);
+ const connect = useAtomCommand(serverEnvironment.connectBoatCloud, "Connect Boat");
+ const disconnect = useAtomCommand(serverEnvironment.disconnectBoatCloud, "Disconnect Boat");
+ const runSetup = useAtomCommand(serverEnvironment.runBoatCloudSetup, "Set up Boat cloud");
+ const configureEnvironment = useAtomCommand(
+ serverEnvironment.configureBoatCloudEnvironment,
+ "Change the Boat environment",
+ );
+ const [environmentBusy, setEnvironmentBusy] = useState(false);
+
+ // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reads this phone's Boat key and VMs from Boat when the page opens
+ useEffect(() => {
+ void refreshPhoneBoatCloud();
+ }, []);
+
+ // A machine builds the snapshot with its own server and skills, so one speaks first.
+ const machineHolder = holders.find((holder) => holder.status.connected) ?? null;
+ const machineSetupReady = machineHolder?.status.setup.status === "ready";
+ const phoneSetupReady = phone.setup.status === "ready";
+
+ // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- the phone reads the snapshot a machine just built from Boat, so it can start VMs once that machine disconnects
+ useEffect(() => {
+ if (machineSetupReady && !phoneSetupReady) void refreshPhoneBoatCloud();
+ }, [machineSetupReady, phoneSetupReady]);
+
+ const status = machineHolder?.status ?? (phone.connected ? phone : null);
+ const connected = status !== null;
+ const setup = status?.setup ?? null;
+ const machines = [
+ ...new Map(
+ [...phone.machines, ...holders.flatMap((holder) => holder.status.machines)].map((m) => [
+ m.sandboxId,
+ m,
+ ]),
+ ).values(),
+ ];
+ const labelOf = (environmentId: string) =>
+ environments.find((environment) => environment.environmentId === environmentId)?.label ??
+ environmentId;
+
+ const submitKey = async (value: string) => {
+ const key = value.trim();
+ if (!key || busy) return;
+ setBusy(true);
+ setKeyError(null);
+ // The phone keeps the key so it can start VMs alone; every machine gets it too.
+ const phoneError = await connectPhoneBoatCloud(key);
+ if (phoneError === null) {
+ await Promise.all(
+ holders.map((holder) =>
+ connect({ environmentId: holder.environmentId, input: { apiKey: key } }),
+ ),
+ );
+ setApiKey("");
+ } else {
+ setKeyError(phoneError);
+ }
+ setBusy(false);
+ };
+
+ const disconnectAll = () => {
+ void disconnectPhoneBoatCloud();
+ for (const holder of holders) {
+ void disconnect({ environmentId: holder.environmentId, input: {} });
+ }
+ };
+
+ const selectedEnvironment = status?.environments.find(
+ (environment) => environment.name === status.environment,
+ );
+ // The phone and every machine start their own VMs, so each keeps the same choice.
+ const applyEnvironment = async (environmentId: string, access?: BoatCloudEnvironmentAccess) => {
+ setEnvironmentBusy(true);
+ const [phoneError] = await Promise.all([
+ phone.connected ? configurePhoneBoatCloudEnvironment(environmentId, access) : null,
+ ...holders
+ .filter((holder) => holder.status.connected)
+ .map((holder) =>
+ configureEnvironment({
+ environmentId: holder.environmentId,
+ input: { environmentId, ...(access ? { access } : {}) },
+ }),
+ ),
+ ]);
+ if (phoneError) Dialog.alert("Could not change this phone's Boat environment", phoneError);
+ setEnvironmentBusy(false);
+ };
+
+ const startSetup = () => {
+ if (machineHolder) {
+ void runSetup({ environmentId: machineHolder.environmentId, input: {} });
+ } else {
+ void runPhoneBoatCloudSetup();
+ }
+ };
+
+ return (
+
+
+
+ {connected ? (
+
+ }
+ label={status.account ?? "Connected"}
+ description="Connected"
+ />
+
+
+
+
+ ) : (
+
+ void submitKey(value)}
+ />
+
+ )}
+
+
+ {connected && setup ? (
+
+ }
+ >
+ {BOAT_CLOUD_SETUP_STEPS.map((step, index) => {
+ const state = stepState(setup, index);
+ return (
+
+ }
+ label={STEP_LABELS[step]}
+ description={state === "failed" ? (setup.error ?? undefined) : undefined}
+ />
+
+ );
+ })}
+
+ ) : null}
+
+ {connected ? (
+
+ {status.environments.map((environment, index) => (
+ 0}
+ disabled={environmentBusy}
+ onPress={() => {
+ if (environment.id !== selectedEnvironment?.id) {
+ void applyEnvironment(environment.id);
+ }
+ }}
+ />
+ ))}
+ {selectedEnvironment
+ ? ACCESS_KEYS.map((key) => (
+
+
+ void applyEnvironment(selectedEnvironment.id, {
+ passGithub: selectedEnvironment.passGithub,
+ passSandboxCredentials: selectedEnvironment.passSandboxCredentials,
+ passSecrets: selectedEnvironment.passSecrets,
+ passAgentsCredentials: selectedEnvironment.passAgentsCredentials,
+ [key]: value,
+ })
+ }
+ />
+
+ ))
+ : null}
+
+ ) : null}
+
+ {connected ? (
+
+
+ {holders.map((holder) => (
+
+
+
+ ))}
+
+ ) : null}
+
+ {connected && machines.length > 0 ? (
+
+ {machines.map((machine, index) => (
+
+ 0
+ ? machine.repositories
+ .map((repository) => repository.split("/").slice(1).join("/"))
+ .join(", ")
+ : undefined
+ }
+ detail={MACHINE_STATE_LABELS[machine.state]}
+ />
+
+ ))}
+
+ ) : null}
+
+
+ );
+}
diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx
index bde67d793aeb..6e4300453aba 100644
--- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx
+++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx
@@ -292,6 +292,7 @@ function useSettingsIndexSections(): SettingsListSection[] {
serverPage("SettingsProviderAccounts", "person.crop.circle", gt("Providers")),
serverPage("SettingsThreads", "text.bubble", gt("Threads")),
page("SettingsScheduledTasks", "clock", gt("Scheduled tasks")),
+ page("SettingsCloud", "cloud", gt("Cloud")),
],
},
{
diff --git a/apps/mobile/src/features/settings/components/settings-page-targets.ts b/apps/mobile/src/features/settings/components/settings-page-targets.ts
index a92922a66d1e..8b64ee361249 100644
--- a/apps/mobile/src/features/settings/components/settings-page-targets.ts
+++ b/apps/mobile/src/features/settings/components/settings-page-targets.ts
@@ -19,6 +19,7 @@ export const SETTINGS_PAGE_TARGETS = [
"SettingsVoice",
"SettingsFollowUp",
"SettingsScheduledTasks",
+ "SettingsCloud",
"SettingsProjectGrouping",
"SettingsClientStorage",
"SettingsDiagnostics",
diff --git a/apps/mobile/src/features/settings/settings-search.ts b/apps/mobile/src/features/settings/settings-search.ts
index 49e1a8dbe082..0f9e53914661 100644
--- a/apps/mobile/src/features/settings/settings-search.ts
+++ b/apps/mobile/src/features/settings/settings-search.ts
@@ -50,6 +50,7 @@ const SETTINGS_SEARCH_TERMS = {
"microphone order",
"bluetooth",
],
+ SettingsCloud: ["boat", "cloud agents", "vm", "api key", "snapshot", "remote machine"],
SettingsScheduledTasks: ["automations", "schedule", "cron", "recurring", "webhook"],
SettingsProjectOverview: ["project", "overview", "checkouts", "delete project"],
SettingsOrganization: [
diff --git a/apps/mobile/src/features/threads/DraftEnvironmentControl.tsx b/apps/mobile/src/features/threads/DraftEnvironmentControl.tsx
index 21bb27a6dc79..48a8ab14d71d 100644
--- a/apps/mobile/src/features/threads/DraftEnvironmentControl.tsx
+++ b/apps/mobile/src/features/threads/DraftEnvironmentControl.tsx
@@ -2,6 +2,7 @@ import { Pressable } from "react-native";
import type { EnvironmentMachineKind } from "@t3tools/contracts";
import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
+import { BoatIcon } from "../../components/BoatIcon";
import { AppText as Text } from "../../components/ui/AppText";
import { SymbolView } from "../../components/ui/AppSymbol";
@@ -11,25 +12,31 @@ export function DraftEnvironmentControl(props: {
readonly label: string;
readonly connectionState: EnvironmentConnectionPhase;
readonly machineKind: EnvironmentMachineKind;
+ readonly boatCloud: boolean;
readonly disabled: boolean;
readonly onChooseEnvironment: () => void;
}) {
const disconnected = props.connectionState !== "connected";
+ const label = props.boatCloud ? "Boat cloud" : props.label;
return (
-
+ {props.boatCloud ? (
+
+ ) : (
+
+ )}
- on {props.label}
+ on {label}
{disconnected ? : null}
diff --git a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx
index 88ec0c06e43b..54f131911846 100644
--- a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx
+++ b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx
@@ -28,6 +28,7 @@ import { ScreenContent } from "../../components/ui/ScreenContent";
import { SymbolView } from "../../components/ui/AppSymbol";
import { AppText as Text } from "../../components/ui/AppText";
import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
+import { BoatIcon } from "../../components/BoatIcon";
import { ThemedSwitch } from "../../components/ui/ThemedSwitch";
import { cn } from "../../lib/cn";
import { useEnvironmentScope } from "../../state/session";
@@ -137,6 +138,33 @@ function EnvironmentPickerRow(props: {
);
}
+function BoatCloudPickerRow(props: {
+ readonly selected: boolean;
+ readonly disabled: boolean;
+ readonly onPress: () => void;
+}) {
+ return (
+
+
+
+
+ Boat cloud
+
+
+ {props.selected ? (
+
+ ) : null}
+
+ );
+}
+
function ToggleRow(props: {
readonly title: string;
readonly value: boolean;
@@ -198,7 +226,7 @@ export function NewTaskEnvironmentPickerRouteScreen() {
{
void Haptics.selectionAsync();
@@ -206,10 +234,23 @@ export function NewTaskEnvironmentPickerRouteScreen() {
if (switched) navigation.goBack();
});
}}
- selected={flow.selectedEnvironmentId === environment.environmentId}
+ selected={
+ flow.boatCloud?.selected !== true &&
+ flow.selectedEnvironmentId === environment.environmentId
+ }
title={environment.environmentLabel}
/>
))}
+ {flow.boatCloud ? (
+ {
+ void Haptics.selectionAsync();
+ if (flow.chooseBoatCloud()) navigation.goBack();
+ }}
+ />
+ ) : null}
diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
index 201204a8a57b..1ad23b33d15f 100644
--- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
+++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
@@ -68,6 +68,7 @@ import { projectEnvironment } from "../../state/projects";
import { sourceControlEnvironment } from "../../state/sourceControl";
import { useAtomCommand } from "../../state/use-atom-command";
import { ProjectCloneBanner } from "./ProjectCloneBanner";
+import { BoatCloudStartSteps } from "../boatCloud/BoatCloudSteps";
import {
describeUnavailableModelSelection,
isModelSelectionUnavailable,
@@ -654,6 +655,11 @@ export function NewTaskDraftScreen(props: {
return null;
}
+ if (flow.boatCloud?.startsOnSend) {
+ // The send continues on the VM once the draft renders on its project (see below).
+ await flow.startBoatCloud();
+ return null;
+ }
const message = flow.buildPendingTaskMessage(creationMetadata, {
currentCheckoutBranch: queuesInsteadOfStarting ? null : flow.currentCheckoutBranchName,
});
@@ -693,6 +699,14 @@ export function NewTaskDraftScreen(props: {
return message.messageId;
}
+ const boatCloudSendPending = flow.boatCloud?.sendPending === true;
+ // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- a Boat cloud send resumes once the draft renders on the VM project, because handleStart reads that render's project and server config
+ useEffect(() => {
+ if (!boatCloudSendPending || flow.submitting) return;
+ flow.finishBoatCloudSend();
+ void handleStart();
+ }, [boatCloudSendPending, flow, handleStart]);
+
// Keep the current surface until the local draft selection is ready. Publishing
// an empty loading surface here flashes between two already-cached threads.
if (!selectedProject) return null;
@@ -729,6 +743,7 @@ export function NewTaskDraftScreen(props: {
connectionState={connectionState}
label={selectedEnvironmentLabel}
machineKind={resolveEnvironmentMachineKind(selectedEnvironmentServerConfig)}
+ boatCloud={flow.boatCloud?.selected === true}
disabled={isComposerInteractionLocked}
onChooseEnvironment={() => openContextPicker("NewTaskEnvironment")}
/>
@@ -872,7 +887,9 @@ export function NewTaskDraftScreen(props: {
(
(
+ {flow.boatCloud?.steps ? (
+
+
+
+
+
+ ) : null}
{projectClone && projectClone.phase !== "done" && selectedProject ? (
void,
) => Promise;
readonly onReconnectEnvironment: () => void;
+ /** Set while the thread's Boat VM is stopped; it reads as asleep, not disconnected. */
+ readonly boatCloudSleep?: "sleeping" | "waking" | null;
readonly canSwitchThreadProvider: boolean;
readonly onRespondToApproval: ApprovalResponder;
readonly onSelectUserInputOption: (
@@ -770,6 +772,7 @@ function ThreadDetailBody(
: null;
const floatingStatus = ((): FloatingWorkingStatus | null => {
const connectionStatus = connectionFloatingStatus({
+ boatCloudSleep: props.boatCloudSleep ?? null,
connectionError: connectionRetrying ? retryConnectionError : props.connectionError,
connectionState: props.connectionStateLabel,
connectionAwaitingApproval: props.connectionAwaitingApproval === true,
diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
index 852219599e41..36658fc1b890 100644
--- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
+++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
@@ -1,4 +1,5 @@
import * as Dialog from "../../components/ui/Dialog";
+import { useBoatCloudSleep } from "../boatCloud/boatCloud";
import { useRememberThreadProject } from "./useRememberThreadProject";
import { useOpenPullRequest } from "../../native/useOpenPullRequest";
import { makeTurnCommandMetadata } from "../../lib/commandMetadata";
@@ -514,6 +515,7 @@ function ThreadRouteContent(
const routeConnectionState =
routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState);
const routeConnectionError = routeEnvironmentRuntime?.connectionError ?? null;
+ const boatCloudSleep = useBoatCloudSleep(environmentId, routeConnectionState);
const selectedThreadWithDraftSettings = selectedThread
? {
...selectedThread,
@@ -1092,7 +1094,16 @@ function ThreadRouteContent(
serverConfig={serverConfig}
onStopThread={awaitingBootstrapTurn ? handleCancelWorktreeSetup : handleStopThread}
onResumeQueue={composer.canResume ? handleResumeQueue : null}
- onSendMessage={composer.onSendMessage}
+ onSendMessage={
+ boatCloudSleep
+ ? (...args) => {
+ // The message waits in the queue until the VM it wakes is connected.
+ boatCloudSleep.wake();
+ return composer.onSendMessage(...args);
+ }
+ : composer.onSendMessage
+ }
+ boatCloudSleep={boatCloudSleep?.state ?? null}
offersSideChat={composer.sideChatUnavailableReason === null}
onReconnectEnvironment={handleReconnectEnvironment}
canSwitchThreadProvider={composer.canSwitchThreadProvider}
diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx
index 55028a58b7df..bdceb7dfb399 100644
--- a/apps/mobile/src/features/threads/floating-working-control.tsx
+++ b/apps/mobile/src/features/threads/floating-working-control.tsx
@@ -28,6 +28,7 @@ import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator";
import { AppText as Text } from "../../components/ui/AppText";
import { SymbolView } from "../../components/ui/AppSymbol";
import { ControlPill } from "../../components/ui/ControlPill";
+import { cn } from "../../lib/cn";
import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
import { BrowserPreviewButton } from "../browser/browser-preview-button";
import { DevicePreviewButton } from "../devices/device-preview-button";
@@ -475,7 +476,12 @@ function FloatingStatusLabel(props: {
{props.status.tone === "reconnecting" ? (
) : (
-
+
)}
{
+ it.each([
+ ["offline", false, "You are offline"],
+ ["unsupported", false, "Client not supported"],
+ ["reconnecting", true, "Waiting for Boat VM to approve this device"],
+ ["error", false, "Sleeping. Your next message wakes it."],
+ ["reconnecting", false, "Sleeping. Your next message wakes it."],
+ ] as const)(
+ "a sleeping Boat VM in %s (awaiting approval %s) reads %s",
+ (state, awaiting, label) => {
+ const status = connectionFloatingStatus({
+ connectionError: null,
+ connectionState: state,
+ connectionAwaitingApproval: awaiting,
+ environmentLabel: "Boat VM",
+ onReconnect: () => undefined,
+ boatCloudSleep: "sleeping",
+ });
+ expect(status?.kind === "connection" ? status.label : null).toBe(label);
+ },
+ );
+});
diff --git a/apps/mobile/src/features/threads/floating-working-status.ts b/apps/mobile/src/features/threads/floating-working-status.ts
index 6beb9cb6cb01..4dd7e0fbb84f 100644
--- a/apps/mobile/src/features/threads/floating-working-status.ts
+++ b/apps/mobile/src/features/threads/floating-working-status.ts
@@ -43,7 +43,7 @@ export type FloatingWorkingStatus =
| { readonly kind: "preparing"; readonly label: string }
| {
readonly kind: "connection";
- readonly tone: "reconnecting" | "unavailable";
+ readonly tone: "reconnecting" | "unavailable" | "sleeping";
readonly label: string;
readonly onPress: () => void;
};
@@ -54,6 +54,7 @@ export function connectionFloatingStatus(input: {
readonly connectionAwaitingApproval?: boolean;
readonly environmentLabel: string | null;
readonly onReconnect: () => void;
+ readonly boatCloudSleep?: "sleeping" | "waking" | null;
}): FloatingWorkingStatus | null {
const environmentLabel = input.environmentLabel ?? "Environment";
const unavailable = (label: string): FloatingWorkingStatus => ({
@@ -63,6 +64,9 @@ export function connectionFloatingStatus(input: {
onPress: input.onReconnect,
});
+ // A Boat VM reads as asleep in any unconnected phase, so a real diagnostic outranks it.
+ if (input.connectionState === "offline") return unavailable("You are offline");
+ if (input.connectionState === "unsupported") return unavailable("Client not supported");
if (input.connectionState === "reconnecting" && input.connectionAwaitingApproval) {
return {
kind: "connection",
@@ -71,6 +75,22 @@ export function connectionFloatingStatus(input: {
onPress: input.onReconnect,
};
}
+ if (input.boatCloudSleep === "waking") {
+ return {
+ kind: "connection",
+ tone: "reconnecting",
+ label: `Waking ${environmentLabel}...`,
+ onPress: input.onReconnect,
+ };
+ }
+ if (input.boatCloudSleep === "sleeping") {
+ return {
+ kind: "connection",
+ tone: "sleeping",
+ label: "Sleeping. Your next message wakes it.",
+ onPress: input.onReconnect,
+ };
+ }
switch (input.connectionState) {
case "connecting":
case "reconnecting":
@@ -83,10 +103,6 @@ export function connectionFloatingStatus(input: {
: `Failed to connect. Retrying ${environmentLabel}...`,
onPress: input.onReconnect,
};
- case "offline":
- return unavailable("You are offline");
- case "unsupported":
- return unavailable("Client not supported");
case "error":
return unavailable(
input.connectionError
diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.test.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.test.tsx
index 05ccf29b2ae7..32782e17922e 100644
--- a/apps/mobile/src/features/threads/new-task-flow-provider.test.tsx
+++ b/apps/mobile/src/features/threads/new-task-flow-provider.test.tsx
@@ -36,6 +36,8 @@ const mocks = vi.hoisted(() => ({
serverConfig: vi.fn(),
openScratch: vi.fn(),
prepareTriage: vi.fn(),
+ boatCloudStarter: vi.fn(),
+ prepareBoatCloud: vi.fn(),
}));
vi.mock("../../state/atom-registry", () => ({
appAtomRegistry: {
@@ -110,6 +112,12 @@ vi.mock("../../state/workspace", () => ({
}));
vi.mock("../../state/shell", () => ({ useEnvironmentShellReadiness: () => ({}) }));
vi.mock("../../lib/clock", () => ({ useNowMinuteMs: () => 0 }));
+vi.mock("../boatCloud/boatCloud", () => ({
+ boatCloudMachinesAtom: Atom.make(new Map([["vm", "https://vm-3773.on.boat.dev"]])),
+ useBoatCloudStarter: mocks.boatCloudStarter,
+ useBoatCloudProviderDrivers: () => null,
+ prepareBoatCloudProject: mocks.prepareBoatCloud,
+}));
import { NewTaskFlowProvider, useNewTaskFlow } from "./new-task-flow-provider";
@@ -132,6 +140,7 @@ function setup() {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
vi.useFakeTimers();
vi.clearAllMocks();
+ mocks.boatCloudStarter.mockReturnValue(null);
const registry = AtomRegistry.make();
mocks.registry.mockReturnValue(registry);
const projects = [project("one", "a"), project("one", "b"), project("two", "b")];
@@ -368,6 +377,71 @@ it("uses cached repository capability and branch defaults while offline", async
expect(screen.current.selectedBranchName).toBe("main");
});
+it("starts a Boat VM only when the first send asks, always in a new worktree", async () => {
+ using screen = setup();
+ const repositoryIdentity = {
+ canonicalKey: "github.com/pingdotgg/t3code",
+ locator: {
+ source: "git-remote" as const,
+ remoteName: "upstream",
+ remoteUrl: "https://github.com/pingdotgg/t3code.git",
+ },
+ origin: {
+ canonicalKey: "github.com/fork/t3code",
+ remoteUrl: "https://github.com/fork/t3code.git",
+ },
+ };
+ const local = { ...project("one", "a"), repositoryIdentity };
+ const vm = { ...project("vm", "t3code"), repositoryIdentity };
+ mocks.projects.mockReturnValue([local, vm]);
+ mocks.boatCloudStarter.mockReturnValue({ kind: "phone" });
+ await screen.render(true);
+ const draftKey = screen.current.draftKey!;
+ act(() => screen.current.setWorkspaceMode("local"));
+ expect(screen.current.workspaceMode).toBe("local");
+
+ act(() => void screen.current.chooseBoatCloud());
+ expect(mocks.prepareBoatCloud).not.toHaveBeenCalled();
+ expect(screen.current.selectedProject).toEqual(local);
+ expect(screen.current.boatCloud).toMatchObject({ selected: true, startsOnSend: true });
+ expect(screen.current.workspaceMode).toBe("worktree");
+ expect(screen.current.startFromOrigin).toBe(true);
+ act(() => screen.current.setWorkspaceMode("local"));
+ act(() => screen.current.setStartFromOrigin(false));
+ expect(screen.current.workspaceMode).toBe("worktree");
+ expect(screen.current.startFromOrigin).toBe(true);
+
+ mocks.prepareBoatCloud.mockResolvedValue(vm);
+ await act(async () => screen.current.startBoatCloud());
+ expect(mocks.prepareBoatCloud).toHaveBeenCalledWith(
+ expect.objectContaining({ repositoryUrl: "https://github.com/fork/t3code.git" }),
+ );
+ expect(screen.current.draftKey).toBe(draftKey);
+ expect(screen.current.selectedProject).toEqual(vm);
+ expect(screen.current.boatCloud).toMatchObject({ startsOnSend: false, sendPending: true });
+ expect(screen.current.workspaceMode).toBe("worktree");
+
+ act(() => screen.current.finishBoatCloudSend());
+ expect(screen.current.boatCloud).toMatchObject({ selected: true, sendPending: false });
+ expect(screen.current.workspaceMode).toBe("worktree");
+});
+
+it("drops a Boat cloud choice when a machine is picked before sending", async () => {
+ using screen = setup();
+ const repositoryIdentity = {
+ canonicalKey: "github.com/pingdotgg/t3code",
+ locator: { source: "git-remote" as const, remoteName: "origin", remoteUrl: "git@x:y.git" },
+ };
+ mocks.projects.mockReturnValue([{ ...project("one", "a"), repositoryIdentity }]);
+ mocks.boatCloudStarter.mockReturnValue({ kind: "phone" });
+ await screen.render(true);
+ act(() => void screen.current.chooseBoatCloud());
+ expect(screen.current.boatCloud?.selected).toBe(true);
+ await act(async () => void (await screen.current.switchEnvironment(EnvironmentId.make("one"))));
+ expect(screen.current.boatCloud).toMatchObject({ selected: false, startsOnSend: false });
+ expect(mocks.prepareBoatCloud).not.toHaveBeenCalled();
+});
+
const playbookPrompt = (machine: string) =>
`Read [triage-playbook.md](/${machine}/triage-playbook.md) and follow it. `;
diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx
index 5852206095a8..1001034ef322 100644
--- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx
+++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx
@@ -5,6 +5,7 @@ import * as Dialog from "../../components/ui/Dialog";
import React, { useEffect, useRef, useState } from "react";
import type {
+ BoatCloudStepProgress,
EnvironmentId,
ModelSelection,
ProviderOptionSelection,
@@ -12,6 +13,7 @@ import type {
ServerProvider,
} from "@t3tools/contracts";
import {
+ AuthOrchestrationOperateScope,
CommandId,
DEFAULT_PROVIDER_INTERACTION_MODE,
MessageId,
@@ -106,6 +108,13 @@ import {
import { usePrepareTriage } from "../triage/usePrepareTriage";
import { useEnvironmentShellReadiness } from "../../state/shell";
import { useConnectionsReady, useWorkspaceEnvironments } from "../../state/workspace";
+import { readEnvironmentScope } from "../../state/session";
+import {
+ boatCloudMachinesAtom,
+ prepareBoatCloudProject,
+ useBoatCloudProviderDrivers,
+ useBoatCloudStarter,
+} from "../boatCloud/boatCloud";
type WorkspaceMode = "local" | "worktree";
@@ -184,6 +193,21 @@ type NewTaskFlowContextValue = {
readonly selectEnvironment: (environmentId: EnvironmentId) => void;
readonly switchEnvironment: (environmentId: EnvironmentId) => Promise;
readonly switchingToEnvironmentId: EnvironmentId | null;
+ /** Null when Boat cloud is not set up and the draft is not on a Boat VM. */
+ readonly boatCloud: {
+ readonly selected: boolean;
+ /** Chosen while the draft is still here: the first send starts the VM. */
+ readonly startsOnSend: boolean;
+ /** The draft moved onto its VM and its first send should go out there now. */
+ readonly sendPending: boolean;
+ /** The start's steps, kept after a failure so the user sees which one. */
+ readonly steps: ReadonlyArray | null;
+ } | null;
+ /** Picks Boat cloud; nothing starts until the first send. */
+ readonly chooseBoatCloud: () => boolean;
+ /** Starts a VM and moves the draft onto its checkout of the same repository. */
+ readonly startBoatCloud: () => Promise;
+ readonly finishBoatCloudSend: () => void;
readonly setSelectedModelKey: (
key: string | null,
options?: ReadonlyArray,
@@ -309,6 +333,11 @@ export function NewTaskFlowProvider(
projects.some((project) => project.environmentId === selectedEnvironmentIdOverride)
? selectedEnvironmentIdOverride
: (projects[0]?.environmentId ?? null);
+ const boatCloudMachines = useAtomValue(boatCloudMachinesAtom);
+ const boatCloudDrivers = useBoatCloudProviderDrivers(selectedEnvironmentId);
+ const onBoatCloudVm =
+ selectedEnvironmentId !== null && boatCloudMachines.has(selectedEnvironmentId);
+ const boatCloudSelected = onBoatCloudVm || flowState.boatCloud === "chosen";
const projectsForEnvironment = pipe(
projects,
Arr.filter((project) => project.environmentId === selectedEnvironmentId),
@@ -394,8 +423,8 @@ export function NewTaskFlowProvider(
Arr.filter((branch) => !branch.isRemote),
);
- const resolveWorkspaceSelection = (selection: ComposerDraftWorkspaceSelection | undefined) =>
- resolveNewTaskDraftWorkspace({
+ const resolveWorkspaceSelection = (selection: ComposerDraftWorkspaceSelection | undefined) => {
+ const resolved = resolveNewTaskDraftWorkspace({
selection,
canChooseWorkspace,
defaultMode: defaultWorkspaceMode,
@@ -404,6 +433,12 @@ export function NewTaskFlowProvider(
branches: allBranchRefs,
projectCwd: selectedProject?.workspaceRoot ?? null,
});
+ // Threads on a Boat VM share its checkout, so each one gets its own worktree,
+ // started from the remote branch: the VM's clone has none of this machine's local branches.
+ return boatCloudSelected
+ ? { ...resolved, mode: "worktree" as const, worktreePath: null, startFromOrigin: true }
+ : resolved;
+ };
const workspaceSelection = resolveWorkspaceSelection(selectedProjectDraft.workspaceSelection);
const workspaceMode = workspaceSelection.mode;
const selectedBranchName = workspaceSelection.branch;
@@ -413,16 +448,24 @@ export function NewTaskFlowProvider(
(workspaceMode === "worktree"
? selectedProject?.workspaceRoot
: (selectedWorktreePath ?? selectedProject?.workspaceRoot)) || null;
- const selectedEnvironmentServerConfig = serverConfigForCwd(
- projectDefaults.serverConfig,
- workspaceCwd,
- );
+ const environmentServerConfig = serverConfigForCwd(projectDefaults.serverConfig, workspaceCwd);
+ // A Boat VM can only sign in the providers its credentials cover.
+ const selectedEnvironmentServerConfig =
+ flowState.boatCloud === "chosen" && environmentServerConfig && boatCloudDrivers
+ ? {
+ ...environmentServerConfig,
+ providers: environmentServerConfig.providers.filter((provider) =>
+ boatCloudDrivers.has(provider.driver),
+ ),
+ }
+ : environmentServerConfig;
// Keep the user's explicit choice separate from the resolved display value:
// only the explicit flag is ever written back to the draft, so the resolved
// value keeps tracking the server setting when the config loads late.
const draftStartFromOrigin = selectedProjectDraft.workspaceSelection?.startFromOrigin;
const startFromOrigin =
- isGitRepo && (draftStartFromOrigin ?? projectSettings.settings.newWorktreesStartFromOrigin);
+ boatCloudSelected ||
+ (isGitRepo && (draftStartFromOrigin ?? projectSettings.settings.newWorktreesStartFromOrigin));
const defaultRuntimeMode = projectSettings.settings.defaultRuntimeMode;
const managedPolicy = selectedEnvironmentServerConfig?.managedPolicy;
const runtimeMode = resolveNewThreadRuntimeMode(
@@ -587,6 +630,7 @@ export function NewTaskFlowProvider(
selectedProject,
);
updateFlow({
+ boatCloud: "off",
// A name or title match may be another repository, which must not receive the PR checkout.
draftKey: match
? carryDraftContentTo(match, {
@@ -605,6 +649,7 @@ export function NewTaskFlowProvider(
);
const latestSwitchRef = useRef